Skip to content

Commit 84dc861

Browse files
committed
Add DebugStructuredLogger
1 parent e70ac03 commit 84dc861

File tree

7 files changed

+275
-6
lines changed

7 files changed

+275
-6
lines changed

sentry-rails/spec/spec_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
expect(Sentry::Rails::Tracing.subscribed_tracing_events).to be_empty
5555
Sentry::Rails::Tracing.remove_active_support_notifications_patch
5656

57+
Sentry::TestHelper.clear_sentry_events
58+
5759
if defined?(Sentry::Rails::ActiveJobExtensions)
5860
Sentry::Rails::ActiveJobExtensions::SentryReporter.detach_event_handlers
5961
end

sentry-ruby/lib/sentry-ruby.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
require "sentry/utils/sample_rand"
1414
require "sentry/configuration"
1515
require "sentry/structured_logger"
16+
require "sentry/debug_structured_logger"
1617
require "sentry/event"
1718
require "sentry/error_event"
1819
require "sentry/transaction_event"
@@ -639,9 +640,10 @@ def logger
639640
@logger ||=
640641
if configuration.enable_logs
641642
# Initialize the public-facing Structured Logger if logs are enabled
642-
# This creates a StructuredLogger instance that implements Sentry's SDK telemetry logs protocol
643+
# Use configured structured logger class or default to StructuredLogger
643644
# @see https://develop.sentry.dev/sdk/telemetry/logs/
644-
StructuredLogger.new(configuration)
645+
logger_class = configuration.structured_logger_class || StructuredLogger
646+
logger_class.new(configuration)
645647
else
646648
warn <<~STR
647649
[sentry] `Sentry.logger` will no longer be used as internal SDK logger when `enable_logs` feature is turned on.

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ def capture_exception_frame_locals=(value)
201201
# @return [String, nil]
202202
attr_accessor :sdk_debug_transport_log_file
203203

204+
# File path for DebugStructuredLogger to log events to. If not set, defaults to a temporary file.
205+
# This is useful for debugging and testing structured logging.
206+
# @return [String, nil]
207+
attr_accessor :sdk_debug_structured_logger_log_file
208+
209+
# The class to use as a structured logger.
210+
# If this option is not set, it will return `nil`, and Sentry will use
211+
# `Sentry::StructuredLogger` by default when logs are enabled.
212+
#
213+
# @return [Class, nil]
214+
attr_reader :structured_logger_class
215+
204216
# @deprecated Use {#sdk_logger=} instead.
205217
def logger=(logger)
206218
warn "[sentry] `config.logger=` is deprecated. Please use `config.sdk_logger=` instead."
@@ -612,6 +624,14 @@ def profiler_class=(profiler_class)
612624
@profiler_class = profiler_class
613625
end
614626

627+
def structured_logger_class=(klass)
628+
unless klass.is_a?(Class)
629+
raise Sentry::Error.new("config.structured_logger_class must be a class. got: #{klass.class}")
630+
end
631+
632+
@structured_logger_class = klass
633+
end
634+
615635
def sending_allowed?
616636
spotlight || sending_to_dsn_allowed?
617637
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "fileutils"
5+
require "pathname"
6+
require "delegate"
7+
8+
module Sentry
9+
# DebugStructuredLogger is a logger that captures structured log events to a file for debugging purposes.
10+
#
11+
# It can optionally also send log events to Sentry via the normal structured logger if logging
12+
# is enabled.
13+
class DebugStructuredLogger < SimpleDelegator
14+
DEFAULT_LOG_FILE_PATH = File.join("log", "sentry_debug_logs.log")
15+
16+
attr_reader :log_file, :backend
17+
18+
def initialize(configuration)
19+
@log_file = initialize_log_file(configuration)
20+
@backend = initialize_backend(configuration)
21+
22+
super(@backend)
23+
end
24+
25+
# Override all log level methods to capture events
26+
%i[trace debug info warn error fatal].each do |level|
27+
define_method(level) do |message, parameters = [], **attributes|
28+
log_event = capture_log_event(level, message, parameters, **attributes)
29+
backend.public_send(level, message, parameters, **attributes)
30+
log_event
31+
end
32+
end
33+
34+
def log(level, message, parameters:, **attributes)
35+
log_event = capture_log_event(level, message, parameters, **attributes)
36+
backend.log(level, message, parameters: parameters, **attributes)
37+
log_event
38+
end
39+
40+
def capture_log_event(level, message, parameters, **attributes)
41+
log_event_json = {
42+
timestamp: Time.now.utc.iso8601,
43+
level: level.to_s,
44+
message: message,
45+
parameters: parameters,
46+
attributes: attributes
47+
}
48+
49+
File.open(log_file, "a") { |file| file << JSON.dump(log_event_json) << "\n" }
50+
log_event_json
51+
end
52+
53+
def logged_events
54+
return [] unless File.exist?(log_file)
55+
56+
File.readlines(log_file).map do |line|
57+
JSON.parse(line)
58+
end
59+
end
60+
61+
def clear
62+
File.write(log_file, "")
63+
if backend.respond_to?(:config)
64+
backend.config.sdk_logger.debug("DebugStructuredLogger: Cleared events from #{log_file}")
65+
end
66+
end
67+
68+
private
69+
70+
def initialize_backend(configuration)
71+
if configuration.enable_logs
72+
StructuredLogger.new(configuration)
73+
else
74+
# Create a no-op logger if logging is disabled
75+
NoOpLogger.new
76+
end
77+
end
78+
79+
def initialize_log_file(configuration)
80+
log_file = Pathname(configuration.sdk_debug_structured_logger_log_file || DEFAULT_LOG_FILE_PATH)
81+
82+
FileUtils.mkdir_p(log_file.dirname) unless log_file.dirname.exist?
83+
84+
log_file
85+
end
86+
87+
# No-op logger for when structured logging is disabled
88+
class NoOpLogger
89+
%i[trace debug info warn error fatal log].each do |method|
90+
define_method(method) { |*args, **kwargs| nil }
91+
end
92+
end
93+
end
94+
end

sentry-ruby/lib/sentry/test_helper.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module Sentry
44
module TestHelper
5+
module_function
6+
57
DUMMY_DSN = "http://12345:[email protected]/sentry/42"
68

79
# Not really real, but it will be resolved as a non-local for testing needs
@@ -49,10 +51,7 @@ def setup_sentry_test(&block)
4951
def teardown_sentry_test
5052
return unless Sentry.initialized?
5153

52-
transport = Sentry.get_current_client&.transport
53-
if transport.is_a?(Sentry::DebugTransport)
54-
transport.clear
55-
end
54+
clear_sentry_events
5655

5756
# pop testing layer created by `setup_sentry_test`
5857
# but keep the base layer to avoid nil-pointer errors
@@ -63,6 +62,14 @@ def teardown_sentry_test
6362
Sentry::Scope.global_event_processors.clear
6463
end
6564

65+
def clear_sentry_events
66+
return unless Sentry.initialized?
67+
68+
[Sentry.get_current_client.transport, Sentry.logger].each do |obj|
69+
obj.clear if obj.respond_to?(:clear)
70+
end
71+
end
72+
6673
# @return [Transport]
6774
def sentry_transport
6875
Sentry.get_current_client.transport
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Sentry::DebugStructuredLogger do
4+
let(:configuration) do
5+
config = Sentry::Configuration.new
6+
config.enable_logs = true
7+
config.dsn = Sentry::TestHelper::DUMMY_DSN
8+
config
9+
end
10+
11+
let(:debug_logger) { described_class.new(configuration) }
12+
13+
before do
14+
debug_logger.clear
15+
end
16+
17+
after do
18+
debug_logger.clear
19+
end
20+
21+
describe "#initialize" do
22+
it "creates a debug logger with structured logger backend" do
23+
expect(debug_logger.backend).to be_a(Sentry::StructuredLogger)
24+
end
25+
26+
it "creates a log file" do
27+
expect(debug_logger.log_file).to be_a(Pathname)
28+
end
29+
30+
context "when logs are disabled" do
31+
let(:configuration) do
32+
config = Sentry::Configuration.new
33+
config.enable_logs = false
34+
config.dsn = Sentry::TestHelper::DUMMY_DSN
35+
config
36+
end
37+
38+
it "creates a no-op logger backend" do
39+
expect(debug_logger.backend).to be_a(Sentry::DebugStructuredLogger::NoOpLogger)
40+
end
41+
end
42+
end
43+
44+
describe "logging methods" do
45+
%i[trace debug info warn error fatal].each do |level|
46+
describe "##{level}" do
47+
it "captures log events to file" do
48+
debug_logger.public_send(level, "Test #{level} message", test_attr: "value")
49+
50+
logged_events = debug_logger.logged_events
51+
expect(logged_events).not_to be_empty
52+
53+
log_event = logged_events.last
54+
expect(log_event["level"]).to eq(level.to_s)
55+
expect(log_event["message"]).to eq("Test #{level} message")
56+
expect(log_event["attributes"]["test_attr"]).to eq("value")
57+
expect(log_event["timestamp"]).to be_a(String)
58+
end
59+
60+
it "handles parameters correctly" do
61+
debug_logger.public_send(level, "Test message", ["param1", "param2"], extra_attr: "extra")
62+
63+
logged_events = debug_logger.logged_events
64+
log_event = logged_events.last
65+
66+
expect(log_event["parameters"]).to eq(["param1", "param2"])
67+
expect(log_event["attributes"]["extra_attr"]).to eq("extra")
68+
end
69+
end
70+
end
71+
end
72+
73+
describe "#log" do
74+
it "captures log events with specified level" do
75+
debug_logger.log(:info, "Test log message", parameters: [], custom_attr: "custom_value")
76+
77+
logged_events = debug_logger.logged_events
78+
expect(logged_events).not_to be_empty
79+
80+
log_event = logged_events.last
81+
expect(log_event["level"]).to eq("info")
82+
expect(log_event["message"]).to eq("Test log message")
83+
expect(log_event["attributes"]["custom_attr"]).to eq("custom_value")
84+
end
85+
end
86+
87+
describe "#logged_events" do
88+
it "returns empty array when no events logged" do
89+
expect(debug_logger.logged_events).to eq([])
90+
end
91+
92+
it "returns all logged events" do
93+
debug_logger.info("First message")
94+
debug_logger.warn("Second message")
95+
debug_logger.error("Third message")
96+
97+
logged_events = debug_logger.logged_events
98+
expect(logged_events.length).to eq(3)
99+
100+
expect(logged_events[0]["message"]).to eq("First message")
101+
expect(logged_events[1]["message"]).to eq("Second message")
102+
expect(logged_events[2]["message"]).to eq("Third message")
103+
end
104+
end
105+
106+
describe "#clear" do
107+
it "clears logged events" do
108+
debug_logger.info("Test message")
109+
expect(debug_logger.logged_events).not_to be_empty
110+
111+
debug_logger.clear
112+
expect(debug_logger.logged_events).to be_empty
113+
end
114+
end
115+
116+
describe "JSON serialization" do
117+
it "handles complex data types" do
118+
debug_logger.info("Complex data",
119+
string: "text",
120+
number: 42,
121+
boolean: true,
122+
array: [1, 2, 3],
123+
hash: { nested: "value" }
124+
)
125+
126+
logged_events = debug_logger.logged_events
127+
log_event = logged_events.last
128+
129+
expect(log_event["attributes"]["string"]).to eq("text")
130+
expect(log_event["attributes"]["number"]).to eq(42)
131+
expect(log_event["attributes"]["boolean"]).to eq(true)
132+
expect(log_event["attributes"]["array"]).to eq([1, 2, 3])
133+
expect(log_event["attributes"]["hash"]).to eq({ "nested" => "value" })
134+
end
135+
end
136+
end

spec/support/test_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ def logged_envelopes
3939
Sentry.get_current_client.transport.logged_envelopes
4040
end
4141

42+
def logged_structured_events
43+
if Sentry.logger.is_a?(Sentry::DebugStructuredLogger)
44+
Sentry.logger.logged_events
45+
else
46+
[]
47+
end
48+
end
49+
4250
# TODO: move this to a shared helper for all gems
4351
def perform_basic_setup
4452
Sentry.init do |config|

0 commit comments

Comments
 (0)