diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9f8d319..bcd61f2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Feature - Propagated sampling rates as specified in [Traces](https://develop.sentry.dev/sdk/telemetry/traces/#propagated-random-value) docs ([#2671](https://github.com/getsentry/sentry-ruby/pull/2671)) +- Support for defining custom Rails log subscribers that work with Sentry Structured Logging ([#2689](https://github.com/getsentry/sentry-ruby/pull/2689)) ### Internal diff --git a/sentry-rails/lib/sentry/rails/log_subscriber.rb b/sentry-rails/lib/sentry/rails/log_subscriber.rb new file mode 100644 index 000000000..5de6cdf6e --- /dev/null +++ b/sentry-rails/lib/sentry/rails/log_subscriber.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "active_support/log_subscriber" + +module Sentry + module Rails + # Base class for Sentry log subscribers that extends ActiveSupport::LogSubscriber + # to provide structured logging capabilities for Rails components. + # + # This class follows Rails' LogSubscriber pattern and provides common functionality + # for capturing Rails instrumentation events and logging them through Sentry's + # structured logging system. + # + # @example Creating a custom log subscriber + # class MySubscriber < Sentry::Rails::LogSubscriber + # attach_to :my_component + # + # def my_event(event) + # log_structured_event( + # message: "My event occurred", + # level: :info, + # attributes: { + # duration_ms: event.duration, + # custom_data: event.payload[:custom_data] + # } + # ) + # end + # end + class LogSubscriber < ActiveSupport::LogSubscriber + class << self + if ::Rails.version.to_f < 6.0 + # Rails 5.x does not provide detach_from + def detach_from(namespace, notifications = ActiveSupport::Notifications) + listeners = public_instance_methods(false) + .flat_map { |key| + notifications.notifier.listeners_for("#{key}.#{namespace}") + } + .select { |listener| listener.instance_variable_get(:@delegate).is_a?(self) } + + listeners.map do |listener| + notifications.notifier.unsubscribe(listener) + end + end + end + end + + protected + + # Log a structured event using Sentry's structured logger + # + # @param message [String] The log message + # @param level [Symbol] The log level (:trace, :debug, :info, :warn, :error, :fatal) + # @param attributes [Hash] Additional structured attributes to include + def log_structured_event(message:, level: :info, attributes: {}) + Sentry.logger.public_send(level, message, **attributes) + rescue => e + # Silently handle any errors in logging to avoid breaking the application + Sentry.configuration.sdk_logger.debug("Failed to log structured event: #{e.message}") + end + + # Calculate duration in milliseconds from an event + # + # @param event [ActiveSupport::Notifications::Event] The event + # @return [Float] Duration in milliseconds + def duration_ms(event) + event.duration.round(2) + end + end + end +end diff --git a/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb b/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb new file mode 100644 index 000000000..8f20cf3ed --- /dev/null +++ b/sentry-rails/lib/sentry/rails/log_subscribers/parameter_filter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Sentry + module Rails + module LogSubscribers + # Shared utility module for filtering sensitive parameters in log subscribers. + # + # This module provides consistent parameter filtering across all Sentry Rails + # log subscribers, leveraging Rails' built-in parameter filtering when available. + # It automatically detects the correct Rails parameter filtering API based on + # the Rails version and includes the appropriate implementation module. + # + # @example Usage in a log subscriber + # class MySubscriber < Sentry::Rails::LogSubscriber + # include Sentry::Rails::LogSubscribers::ParameterFilter + # + # def my_event(event) + # if Sentry.configuration.send_default_pii && event.payload[:params] + # filtered_params = filter_sensitive_params(event.payload[:params]) + # attributes[:params] = filtered_params unless filtered_params.empty? + # end + # end + # end + module ParameterFilter + EMPTY_HASH = {}.freeze + + if ::Rails.version.to_f >= 6.0 + def self.backend + ActiveSupport::ParameterFilter + end + else + def self.backend + ActionDispatch::Http::ParameterFilter + end + end + + # Filter sensitive parameters from a hash, respecting Rails configuration. + # + # @param params [Hash] The parameters to filter + # @return [Hash] Filtered parameters with sensitive data removed + def filter_sensitive_params(params) + return EMPTY_HASH unless params.is_a?(Hash) + + filter_parameters = ::Rails.application.config.filter_parameters + parameter_filter = ParameterFilter.backend.new(filter_parameters) + + parameter_filter.filter(params) + end + end + end + end +end diff --git a/sentry-rails/spec/dummy/test_rails_app/app.rb b/sentry-rails/spec/dummy/test_rails_app/app.rb index 874b6d1ae..42f4d8d04 100644 --- a/sentry-rails/spec/dummy/test_rails_app/app.rb +++ b/sentry-rails/spec/dummy/test_rails_app/app.rb @@ -71,6 +71,17 @@ def self.name configure_app(app) + # Configure parameter filtering for consistent test behavior + app.config.filter_parameters.concat( + [:custom_secret, + :api_key, + :credit_card, + :authorization, + :password, + :token] + ) + app.config.filter_parameters.uniq! + app.routes.append do get "/exception", to: "hello#exception" get "/view_exception", to: "hello#view_exception" diff --git a/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb b/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb new file mode 100644 index 000000000..c803e7763 --- /dev/null +++ b/sentry-rails/spec/sentry/rails/log_subscriber_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +require "spec_helper" + +require "sentry/rails/log_subscriber" +require "sentry/rails/log_subscribers/parameter_filter" + +RSpec.describe Sentry::Rails::LogSubscriber, type: :request do + let!(:test_subscriber) { test_subscriber_class.new } + + after do + Sentry.logger.clear if Sentry.logger.respond_to?(:clear) + test_subscriber_class.detach_from(:test_component) + end + + context "with no parameter filtering" do + let(:test_subscriber_class) do + Class.new(described_class) do + attach_to :test_component + + def test_event(event) + log_structured_event( + message: "Test event occurred", + attributes: { + duration_ms: duration_ms(event), + test_data: event.payload[:test_data], + component: "test_component" + } + ) + end + + def error_test_event(event) + log_structured_event( + message: "Error test event", + level: :error, + attributes: { + duration_ms: duration_ms(event), + error_data: event.payload[:error_data] + } + ) + end + end + end + + before do + make_basic_app do |config| + config.enable_logs = true + config.structured_logger_class = Sentry::DebugStructuredLogger + end + end + + describe "ActiveSupport notifications integration" do + it "responds to real ActiveSupport notifications and logs structured events" do + ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "sample_data") do + sleep(0.01) + end + + logged_events = Sentry.logger.logged_events + expect(logged_events).not_to be_empty + + log_event = logged_events.first + expect(log_event["level"]).to eq("info") + expect(log_event["message"]).to eq("Test event occurred") + expect(log_event["attributes"]["test_data"]).to eq("sample_data") + expect(log_event["attributes"]["component"]).to eq("test_component") + expect(log_event["attributes"]["duration_ms"]).to be_a(Float) + expect(log_event["attributes"]["duration_ms"]).to be > 0 + expect(log_event["timestamp"]).to be_a(String) + end + + it "uses appropriate log level based on duration thresholds" do + ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "fast") do + sleep(0.1) + end + + logged_events = Sentry.logger.logged_events + expect(logged_events.size).to eq(1) + + log_event = logged_events.first + expect(log_event["level"]).to eq("info") + expect(log_event["attributes"]["test_data"]).to eq("fast") + expect(log_event["attributes"]["duration_ms"]).to be > 50 + end + + it "handles events with various payload data types" do + test_payloads = [ + { test_data: "string_value" }, + { test_data: { nested: "hash" } }, + { test_data: [1, 2, 3] }, + { test_data: nil } + ] + + expected_values = [ + "string_value", + { "nested" => "hash" }, + [1, 2, 3], + nil + ] + + test_payloads.each do |payload| + ActiveSupport::Notifications.instrument("test_event.test_component", payload) do + sleep 0.01 + end + end + + logged_events = Sentry.logger.logged_events + expect(logged_events.size).to eq(test_payloads.size) + + logged_events.each_with_index do |log_event, index| + expect(log_event["message"]).to eq("Test event occurred") + expect(log_event["attributes"]["test_data"]).to eq(expected_values[index]) + expect(log_event["level"]).to eq("info") + end + end + + it "calculates duration correctly from real events" do + ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "duration_test") do + sleep(0.05) # 50ms + end + + logged_events = Sentry.logger.logged_events + log_event = logged_events.first + duration = log_event["attributes"]["duration_ms"] + + expect(duration).to be_a(Float) + expect(duration).to be >= 40.0 + expect(duration).to be < 100.0 + expect(duration.round(2)).to eq(duration) + end + end + + describe "error handling" do + it "handles logging errors gracefully and logs to sdk_logger" do + failing_logger = double("failing_logger") + + allow(failing_logger).to receive(:error).and_raise(StandardError.new("Logging failed")) + + sdk_logger_output = StringIO.new + sdk_logger = ::Logger.new(sdk_logger_output) + + allow(Sentry).to receive(:logger).and_return(failing_logger) + allow(Sentry).to receive(:configuration).and_return(double("configuration", sdk_logger: sdk_logger)) + + expect { + ActiveSupport::Notifications.instrument("error_test_event.test_component", error_data: "error_test") do + sleep 0.01 + end + }.not_to raise_error + + sdk_output = sdk_logger_output.string + expect(sdk_output).to include("Failed to log structured event: Logging failed") + end + end + + describe "Rails version compatibility" do + context "when Rails version is less than 6.0", skip: Rails.version.to_f >= 6.0 ? "Rails 6.0+" : false do + it "provides custom detach_from implementation" do + temp_subscriber_class = Class.new(described_class) do + attach_to :temp_test + + def temp_event(event) + log_structured_event(message: "Temp event", attributes: { data: event.payload[:data] }) + end + end + + ActiveSupport::Notifications.instrument("temp_event.temp_test", data: "before_detach") do + sleep 0.01 + end + + initial_log_count = Sentry.logger.logged_events.size + expect(initial_log_count).to be > 0 + + temp_subscriber_class.detach_from(:temp_test) + + Sentry.logger.clear + + ActiveSupport::Notifications.instrument("temp_event.temp_test", data: "after_detach") do + sleep 0.01 + end + + expect(Sentry.logger.logged_events).to be_empty + end + end + + context "when Rails version is 6.0 or higher", skip: Rails.version.to_f < 6.0 ? "Rails 5.x" : false do + it "uses Rails built-in detach_from method" do + expect(described_class).to respond_to(:detach_from) + + temp_subscriber_class = Class.new(described_class) do + attach_to :temp_test_rails6 + + def temp_event(event) + log_structured_event(message: "Temp event Rails 6+", attributes: { data: event.payload[:data] }) + end + end + + ActiveSupport::Notifications.instrument("temp_event.temp_test_rails6", data: "test") do + sleep 0.01 + end + + initial_log_count = Sentry.logger.logged_events.size + expect(initial_log_count).to be > 0 + + temp_subscriber_class.detach_from(:temp_test_rails6) + + Sentry.logger.clear + + ActiveSupport::Notifications.instrument("temp_event.temp_test_rails6", data: "after_detach") do + sleep 0.01 + end + + expect(Sentry.logger.logged_events).to be_empty + end + end + end + end + + context "parameter filtering integration" do + let(:test_subscriber_class) do + Class.new(described_class) do + include Sentry::Rails::LogSubscribers::ParameterFilter + + attach_to :filtering_test + + def filtering_event(event) + attributes = { + duration_ms: duration_ms(event), + component: "filtering_test" + } + + if Sentry.configuration.send_default_pii && event.payload[:params] + filtered_params = filter_sensitive_params(event.payload[:params]) + attributes[:params] = filtered_params unless filtered_params.empty? + end + + log_structured_event( + message: "Filtering event occurred", + attributes: attributes + ) + end + end + end + + before do + make_basic_app do |config, app| + config.enable_logs = true + config.structured_logger_class = Sentry::DebugStructuredLogger + config.send_default_pii = true + app.config.filter_parameters += [:custom_secret, :api_key, :credit_card, :authorization] + end + end + + it_behaves_like "parameter filtering" do + let(:test_instance) { test_subscriber } + end + end +end diff --git a/sentry-rails/spec/spec_helper.rb b/sentry-rails/spec/spec_helper.rb index e3accdf98..4f1b8f5c0 100644 --- a/sentry-rails/spec/spec_helper.rb +++ b/sentry-rails/spec/spec_helper.rb @@ -54,6 +54,8 @@ expect(Sentry::Rails::Tracing.subscribed_tracing_events).to be_empty Sentry::Rails::Tracing.remove_active_support_notifications_patch + Sentry::TestHelper.clear_sentry_events + if defined?(Sentry::Rails::ActiveJobExtensions) Sentry::Rails::ActiveJobExtensions::SentryReporter.detach_event_handlers end diff --git a/sentry-rails/spec/support/shared_examples_for_parameter_filter.rb b/sentry-rails/spec/support/shared_examples_for_parameter_filter.rb new file mode 100644 index 000000000..ceafd7f9a --- /dev/null +++ b/sentry-rails/spec/support/shared_examples_for_parameter_filter.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +RSpec.shared_examples "parameter filtering" do |subscriber_class| + let(:test_instance) { subscriber_class.new } + + describe "#filter_sensitive_params" do + context "when params is not a hash" do + it "returns empty hash for nil" do + result = test_instance.filter_sensitive_params(nil) + expect(result).to eq({}) + end + + it "returns empty hash for non-hash objects" do + result = test_instance.filter_sensitive_params("not a hash") + expect(result).to eq({}) + end + + it "returns empty hash for arrays" do + result = test_instance.filter_sensitive_params([1, 2, 3]) + expect(result).to eq({}) + end + end + + context "when params is a valid hash" do + it "preserves non-sensitive parameters" do + params = { + "name" => "John Doe", + "email" => "john@example.com", + "age" => 30, + "preferences" => { "theme" => "dark" } + } + + result = test_instance.filter_sensitive_params(params) + + expect(result).to include("name" => "John Doe") + expect(result).to include("email" => "john@example.com") + expect(result).to include("age" => 30) + expect(result).to include("preferences" => { "theme" => "dark" }) + end + + it "filters default sensitive parameters" do + params = { + "name" => "John Doe", + "password" => "secret123", + "password_confirmation" => "secret123", + "normal_param" => "safe_value" + } + + result = test_instance.filter_sensitive_params(params) + + expect(result).to include("name" => "John Doe") + expect(result).to include("normal_param" => "safe_value") + expect(result).to include("password" => "[FILTERED]") + expect(result).to include("password_confirmation" => "[FILTERED]") + end + + it "filters custom configured sensitive parameters" do + params = { + "name" => "John Doe", + "custom_secret" => "top_secret", + "api_key" => "abc123xyz", + "credit_card" => "1234-5678-9012-3456", + "authorization" => "Bearer token123", + "normal_param" => "safe_value" + } + + result = test_instance.filter_sensitive_params(params) + + expect(result).to include("name" => "John Doe") + expect(result).to include("normal_param" => "safe_value") + expect(result).to include("custom_secret" => "[FILTERED]") + expect(result).to include("api_key" => "[FILTERED]") + expect(result).to include("credit_card" => "[FILTERED]") + expect(result).to include("authorization" => "[FILTERED]") + end + + it "handles mixed sensitive and non-sensitive parameters" do + params = { + "user_id" => 123, + "username" => "johndoe", + "password" => "secret", + "session_token" => "abc123", + "preferences" => { + "notifications" => true, + "api_key" => "sensitive_key" + } + } + + result = test_instance.filter_sensitive_params(params) + + expect(result).to include("user_id" => 123) + expect(result).to include("username" => "johndoe") + expect(result).to include("password" => "[FILTERED]") + expect(result).to include("session_token" => "[FILTERED]") + expect(result).to have_key("preferences") + end + + it "returns a new hash and doesn't modify the original" do + original_params = { + "name" => "John", + "password" => "secret" + } + original_copy = original_params.dup + + result = test_instance.filter_sensitive_params(original_params) + + expect(original_params).to eq(original_copy) + expect(result).not_to equal(original_params) + end + + it "handles empty hash" do + result = test_instance.filter_sensitive_params({}) + expect(result).to eq({}) + end + end + + context "with Rails filter_parameters configuration" do + it "respects dynamically added filter parameters" do + original_filter_params = Rails.application.config.filter_parameters.dup + + begin + Rails.application.config.filter_parameters += [:dynamic_secret] + + params = { + "name" => "John", + "dynamic_secret" => "should_be_filtered", + "normal_param" => "value" + } + + result = test_instance.filter_sensitive_params(params) + + expect(result).to include("name" => "John") + expect(result).to include("normal_param" => "value") + expect(result).to include("dynamic_secret" => "[FILTERED]") + ensure + Rails.application.config.filter_parameters = original_filter_params + end + end + end + end +end diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index 986e6d1a8..728675b98 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -13,6 +13,7 @@ require "sentry/utils/sample_rand" require "sentry/configuration" require "sentry/structured_logger" +require "sentry/debug_structured_logger" require "sentry/event" require "sentry/error_event" require "sentry/transaction_event" @@ -639,13 +640,16 @@ def logger @logger ||= if configuration.enable_logs # Initialize the public-facing Structured Logger if logs are enabled - # This creates a StructuredLogger instance that implements Sentry's SDK telemetry logs protocol + # Use configured structured logger class or default to StructuredLogger # @see https://develop.sentry.dev/sdk/telemetry/logs/ - StructuredLogger.new(configuration) + logger_class = configuration.structured_logger_class || StructuredLogger + logger_class.new(configuration) else warn <<~STR [sentry] `Sentry.logger` will no longer be used as internal SDK logger when `enable_logs` feature is turned on. Use Sentry.configuration.sdk_logger for SDK-specific logging needs." + + Caller: #{caller.first} STR configuration.sdk_logger diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index eea7d4fce..7e8ef503b 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -201,6 +201,18 @@ def capture_exception_frame_locals=(value) # @return [String, nil] attr_accessor :sdk_debug_transport_log_file + # File path for DebugStructuredLogger to log events to. If not set, defaults to a temporary file. + # This is useful for debugging and testing structured logging. + # @return [String, nil] + attr_accessor :sdk_debug_structured_logger_log_file + + # The class to use as a structured logger. + # If this option is not set, it will return `nil`, and Sentry will use + # `Sentry::StructuredLogger` by default when logs are enabled. + # + # @return [Class, nil] + attr_reader :structured_logger_class + # @deprecated Use {#sdk_logger=} instead. def logger=(logger) warn "[sentry] `config.logger=` is deprecated. Please use `config.sdk_logger=` instead." @@ -612,6 +624,14 @@ def profiler_class=(profiler_class) @profiler_class = profiler_class end + def structured_logger_class=(klass) + unless klass.is_a?(Class) + raise Sentry::Error.new("config.structured_logger_class must be a class. got: #{klass.class}") + end + + @structured_logger_class = klass + end + def sending_allowed? spotlight || sending_to_dsn_allowed? end diff --git a/sentry-ruby/lib/sentry/debug_structured_logger.rb b/sentry-ruby/lib/sentry/debug_structured_logger.rb new file mode 100644 index 000000000..88f32d3e7 --- /dev/null +++ b/sentry-ruby/lib/sentry/debug_structured_logger.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "pathname" +require "delegate" + +module Sentry + # DebugStructuredLogger is a logger that captures structured log events to a file for debugging purposes. + # + # It can optionally also send log events to Sentry via the normal structured logger if logging + # is enabled. + class DebugStructuredLogger < SimpleDelegator + DEFAULT_LOG_FILE_PATH = File.join("log", "sentry_debug_logs.log") + + attr_reader :log_file, :backend + + def initialize(configuration) + @log_file = initialize_log_file(configuration) + @backend = initialize_backend(configuration) + + super(@backend) + end + + # Override all log level methods to capture events + %i[trace debug info warn error fatal].each do |level| + define_method(level) do |message, parameters = [], **attributes| + log_event = capture_log_event(level, message, parameters, **attributes) + backend.public_send(level, message, parameters, **attributes) + log_event + end + end + + def log(level, message, parameters:, **attributes) + log_event = capture_log_event(level, message, parameters, **attributes) + backend.log(level, message, parameters: parameters, **attributes) + log_event + end + + def capture_log_event(level, message, parameters, **attributes) + log_event_json = { + timestamp: Time.now.utc.iso8601, + level: level.to_s, + message: message, + parameters: parameters, + attributes: attributes + } + + File.open(log_file, "a") { |file| file << JSON.dump(log_event_json) << "\n" } + log_event_json + end + + def logged_events + return [] unless File.exist?(log_file) + + File.readlines(log_file).map do |line| + JSON.parse(line) + end + end + + def clear + File.write(log_file, "") + if backend.respond_to?(:config) + backend.config.sdk_logger.debug("DebugStructuredLogger: Cleared events from #{log_file}") + end + end + + private + + def initialize_backend(configuration) + if configuration.enable_logs + StructuredLogger.new(configuration) + else + # Create a no-op logger if logging is disabled + NoOpLogger.new + end + end + + def initialize_log_file(configuration) + log_file = Pathname(configuration.sdk_debug_structured_logger_log_file || DEFAULT_LOG_FILE_PATH) + + FileUtils.mkdir_p(log_file.dirname) unless log_file.dirname.exist? + + log_file + end + + # No-op logger for when structured logging is disabled + class NoOpLogger + %i[trace debug info warn error fatal log].each do |method| + define_method(method) { |*args, **kwargs| nil } + end + end + end +end diff --git a/sentry-ruby/lib/sentry/test_helper.rb b/sentry-ruby/lib/sentry/test_helper.rb index 7fb313b57..3c40b8dd1 100644 --- a/sentry-ruby/lib/sentry/test_helper.rb +++ b/sentry-ruby/lib/sentry/test_helper.rb @@ -2,6 +2,8 @@ module Sentry module TestHelper + module_function + DUMMY_DSN = "http://12345:67890@sentry.localdomain/sentry/42" # Not really real, but it will be resolved as a non-local for testing needs @@ -49,10 +51,7 @@ def setup_sentry_test(&block) def teardown_sentry_test return unless Sentry.initialized? - transport = Sentry.get_current_client&.transport - if transport.is_a?(Sentry::DebugTransport) - transport.clear - end + clear_sentry_events # pop testing layer created by `setup_sentry_test` # but keep the base layer to avoid nil-pointer errors @@ -63,6 +62,14 @@ def teardown_sentry_test Sentry::Scope.global_event_processors.clear end + def clear_sentry_events + return unless Sentry.initialized? + + [Sentry.get_current_client.transport, Sentry.logger].each do |obj| + obj.clear if obj.respond_to?(:clear) + end + end + # @return [Transport] def sentry_transport Sentry.get_current_client.transport diff --git a/sentry-ruby/spec/sentry/debug_structured_logger_spec.rb b/sentry-ruby/spec/sentry/debug_structured_logger_spec.rb new file mode 100644 index 000000000..df22230d9 --- /dev/null +++ b/sentry-ruby/spec/sentry/debug_structured_logger_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +RSpec.describe Sentry::DebugStructuredLogger do + let(:configuration) do + config = Sentry::Configuration.new + config.enable_logs = true + config.dsn = Sentry::TestHelper::DUMMY_DSN + config + end + + let(:debug_logger) { described_class.new(configuration) } + + before do + debug_logger.clear + end + + after do + debug_logger.clear + end + + describe "#initialize" do + it "creates a debug logger with structured logger backend" do + expect(debug_logger.backend).to be_a(Sentry::StructuredLogger) + end + + it "creates a log file" do + expect(debug_logger.log_file).to be_a(Pathname) + end + + context "when logs are disabled" do + let(:configuration) do + config = Sentry::Configuration.new + config.enable_logs = false + config.dsn = Sentry::TestHelper::DUMMY_DSN + config + end + + it "creates a no-op logger backend" do + expect(debug_logger.backend).to be_a(Sentry::DebugStructuredLogger::NoOpLogger) + end + end + end + + describe "logging methods" do + %i[trace debug info warn error fatal].each do |level| + describe "##{level}" do + it "captures log events to file" do + debug_logger.public_send(level, "Test #{level} message", test_attr: "value") + + logged_events = debug_logger.logged_events + expect(logged_events).not_to be_empty + + log_event = logged_events.last + expect(log_event["level"]).to eq(level.to_s) + expect(log_event["message"]).to eq("Test #{level} message") + expect(log_event["attributes"]["test_attr"]).to eq("value") + expect(log_event["timestamp"]).to be_a(String) + end + + it "handles parameters correctly" do + debug_logger.public_send(level, "Test message", ["param1", "param2"], extra_attr: "extra") + + logged_events = debug_logger.logged_events + log_event = logged_events.last + + expect(log_event["parameters"]).to eq(["param1", "param2"]) + expect(log_event["attributes"]["extra_attr"]).to eq("extra") + end + end + end + end + + describe "#log" do + it "captures log events with specified level" do + debug_logger.log(:info, "Test log message", parameters: [], custom_attr: "custom_value") + + logged_events = debug_logger.logged_events + expect(logged_events).not_to be_empty + + log_event = logged_events.last + expect(log_event["level"]).to eq("info") + expect(log_event["message"]).to eq("Test log message") + expect(log_event["attributes"]["custom_attr"]).to eq("custom_value") + end + end + + describe "#logged_events" do + it "returns empty array when no events logged" do + expect(debug_logger.logged_events).to eq([]) + end + + it "returns all logged events" do + debug_logger.info("First message") + debug_logger.warn("Second message") + debug_logger.error("Third message") + + logged_events = debug_logger.logged_events + expect(logged_events.length).to eq(3) + + expect(logged_events[0]["message"]).to eq("First message") + expect(logged_events[1]["message"]).to eq("Second message") + expect(logged_events[2]["message"]).to eq("Third message") + end + end + + describe "#clear" do + it "clears logged events" do + debug_logger.info("Test message") + expect(debug_logger.logged_events).not_to be_empty + + debug_logger.clear + expect(debug_logger.logged_events).to be_empty + end + end + + describe "JSON serialization" do + it "handles complex data types" do + debug_logger.info("Complex data", + string: "text", + number: 42, + boolean: true, + array: [1, 2, 3], + hash: { nested: "value" } + ) + + logged_events = debug_logger.logged_events + log_event = logged_events.last + + expect(log_event["attributes"]["string"]).to eq("text") + expect(log_event["attributes"]["number"]).to eq(42) + expect(log_event["attributes"]["boolean"]).to eq(true) + expect(log_event["attributes"]["array"]).to eq([1, 2, 3]) + expect(log_event["attributes"]["hash"]).to eq({ "nested" => "value" }) + end + end +end diff --git a/spec/support/test_helper.rb b/spec/support/test_helper.rb index 002cd0d11..ebc66d2d3 100644 --- a/spec/support/test_helper.rb +++ b/spec/support/test_helper.rb @@ -39,6 +39,14 @@ def logged_envelopes Sentry.get_current_client.transport.logged_envelopes end + def logged_structured_events + if Sentry.logger.is_a?(Sentry::DebugStructuredLogger) + Sentry.logger.logged_events + else + [] + end + end + # TODO: move this to a shared helper for all gems def perform_basic_setup Sentry.init do |config|