From fdc4550d25bf002c6b6f93ef65028119a6231ffd Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 31 Jul 2025 10:09:18 +0000 Subject: [PATCH 1/4] Make DummyTransport#send_event call super This is important because that's how actual Transports behave. The specs had to be changed because their expectations were based on behavior specific to DummyTransport. Reasons for changing spec expectations: Log spec creates a transaction and we run profiler for every transaction, but due to sampling decision profile event is being dropped and we end up with a client report. SessionFlush calls capture_envelope which bypasses send_event, but the test actually does send two error events, so now that DummyTransport#send_event calls super, we end up (correctly) with 3 envelopes: 2 from errors, and 1 from session. --- sentry-ruby/lib/sentry/transport/dummy_transport.rb | 1 + .../spec/sentry/rack/capture_exceptions_spec.rb | 12 ++++++++---- sentry-ruby/spec/sentry_spec.rb | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/sentry-ruby/lib/sentry/transport/dummy_transport.rb b/sentry-ruby/lib/sentry/transport/dummy_transport.rb index 66cda814d..4179ba1cc 100644 --- a/sentry-ruby/lib/sentry/transport/dummy_transport.rb +++ b/sentry-ruby/lib/sentry/transport/dummy_transport.rb @@ -12,6 +12,7 @@ def initialize(*) def send_event(event) @events << event + super end def send_envelope(envelope) diff --git a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb index a8f1b6fba..7ff6f4e4a 100644 --- a/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb +++ b/sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb @@ -667,11 +667,15 @@ def will_be_sampled_by_sdk Sentry.session_flusher.flush - expect(sentry_envelopes.count).to eq(1) - envelope = sentry_envelopes.first + expect(sentry_envelopes.count).to eq(3) - expect(envelope.items.length).to eq(1) - item = envelope.items.first + session_envelope = sentry_envelopes.find do |envelope| + envelope.items.any? { |item| item.type == 'sessions' } + end + + expect(session_envelope).not_to be_nil + expect(session_envelope.items.length).to eq(1) + item = session_envelope.items.first expect(item.type).to eq('sessions') expect(item.payload[:attrs]).to eq({ release: 'test-release', environment: 'test' }) expect(item.payload[:aggregates].first).to eq({ exited: 10, errored: 2, started: now_bucket.iso8601 }) diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index f5b1f4aea..5f0e2c97f 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -390,7 +390,8 @@ Sentry.get_current_client.flush - expect(sentry_envelopes.size).to be(2) + # 3 envelopes: log, transaction, and client_report about dropped profile + expect(sentry_envelopes.size).to be(3) log_event = sentry_logs.first From aebfd6d7688f4b60844317d8e7160fb9d6451928 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 31 Jul 2025 08:57:57 +0000 Subject: [PATCH 2/4] Add DSN#local? --- sentry-ruby/lib/sentry/dsn.rb | 32 +++++++++++++++++++++++++++++ sentry-ruby/spec/sentry/dsn_spec.rb | 25 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/sentry-ruby/lib/sentry/dsn.rb b/sentry-ruby/lib/sentry/dsn.rb index 3877b57fb..0be43a0e3 100644 --- a/sentry-ruby/lib/sentry/dsn.rb +++ b/sentry-ruby/lib/sentry/dsn.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true require "uri" +require "ipaddr" +require "resolv" module Sentry class DSN PORT_MAP = { "http" => 80, "https" => 443 }.freeze REQUIRED_ATTRIBUTES = %w[host path public_key project_id].freeze + LOCALHOST_NAMES = %w[localhost 127.0.0.1 ::1 [::1]].freeze + LOCALHOST_PATTERN = /\.local(host|domain)?$/i attr_reader :scheme, :secret_key, :port, *REQUIRED_ATTRIBUTES @@ -49,5 +53,33 @@ def csp_report_uri def envelope_endpoint "#{path}/api/#{project_id}/envelope/" end + + def local? + @local ||= (localhost? || private_ip? || resolved_ips_private?) + end + + def localhost? + LOCALHOST_NAMES.include?(host.downcase) || LOCALHOST_PATTERN.match?(host) + end + + def private_ip? + @private_ip ||= begin + begin + IPAddr.new(host).private? + rescue IPAddr::InvalidAddressError + false + end + end + end + + def resolved_ips_private? + @resolved_ips_private ||= begin + begin + Resolv.getaddresses(host).any? { |ip| IPAddr.new(ip).private? } + rescue Resolv::ResolvError, IPAddr::InvalidAddressError + false + end + end + end end end diff --git a/sentry-ruby/spec/sentry/dsn_spec.rb b/sentry-ruby/spec/sentry/dsn_spec.rb index 181c6a1b9..04f2b8d11 100644 --- a/sentry-ruby/spec/sentry/dsn_spec.rb +++ b/sentry-ruby/spec/sentry/dsn_spec.rb @@ -37,4 +37,29 @@ expect(subject.csp_report_uri).to eq("http://sentry.localdomain:3000/api/42/security/?sentry_key=12345") end end + + describe "#local?" do + it "returns true for localhost" do + expect(described_class.new("http://12345:67890@localhost/sentry/42").local?).to eq(true) + end + + it "returns true for 127.0.0.1" do + expect(described_class.new("http://12345:67890@127.0.0.1/sentry/42").local?).to eq(true) + end + it "returns true for ::1" do + expect(described_class.new("http://12345:67890@[::1]/sentry/42").local?).to eq(true) + end + + it "returns true for private IP" do + expect(described_class.new("http://12345:67890@192.168.0.1/sentry/42").local?).to eq(true) + end + + it "returns true for private IP with port" do + expect(described_class.new("http://12345:67890@192.168.0.1:3000/sentry/42").local?).to eq(true) + end + + it "returns false for non-local domain" do + expect(described_class.new("http://12345:67890@sentry.io/sentry/42").local?).to eq(false) + end + end end From 055ec08686e69b3590c0c55522f3788243fc490c Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 28 Jul 2025 14:05:49 +0000 Subject: [PATCH 3/4] Add Sentry::DebugTransport for testing/debugging --- sentry-ruby/lib/sentry/configuration.rb | 5 ++ sentry-ruby/lib/sentry/test_helper.rb | 8 +++ sentry-ruby/lib/sentry/transport.rb | 1 + .../lib/sentry/transport/debug_transport.rb | 70 +++++++++++++++++++ .../sentry/transport/debug_transport_spec.rb | 62 ++++++++++++++++ sentry-ruby/spec/spec_helper.rb | 8 +++ 6 files changed, 154 insertions(+) create mode 100644 sentry-ruby/lib/sentry/transport/debug_transport.rb create mode 100644 sentry-ruby/spec/sentry/transport/debug_transport_spec.rb diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index 0a0f7236d..eea7d4fce 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -196,6 +196,11 @@ def capture_exception_frame_locals=(value) # @return [Logger] attr_accessor :sdk_logger + # File path for DebugTransport to log events to. If not set, defaults to a temporary file. + # This is useful for debugging and testing purposes. + # @return [String, nil] + attr_accessor :sdk_debug_transport_log_file + # @deprecated Use {#sdk_logger=} instead. def logger=(logger) warn "[sentry] `config.logger=` is deprecated. Please use `config.sdk_logger=` instead." diff --git a/sentry-ruby/lib/sentry/test_helper.rb b/sentry-ruby/lib/sentry/test_helper.rb index 5c04da796..7fb313b57 100644 --- a/sentry-ruby/lib/sentry/test_helper.rb +++ b/sentry-ruby/lib/sentry/test_helper.rb @@ -4,6 +4,9 @@ module Sentry module TestHelper DUMMY_DSN = "http://12345:67890@sentry.localdomain/sentry/42" + # Not really real, but it will be resolved as a non-local for testing needs + REAL_DSN = "https://user:pass@getsentry.io/project/42" + # Alters the existing SDK configuration with test-suitable options. Mainly: # - Sets a dummy DSN instead of `nil` or an actual DSN. # - Sets the transport to DummyTransport, which allows easy access to the captured events. @@ -46,6 +49,11 @@ 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 + # pop testing layer created by `setup_sentry_test` # but keep the base layer to avoid nil-pointer errors # TODO: find a way to notify users if they somehow popped the test layer before calling this method diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index 2877982c2..0c93e7dfa 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -223,3 +223,4 @@ def reject_rate_limited_items(envelope) require "sentry/transport/dummy_transport" require "sentry/transport/http_transport" require "sentry/transport/spotlight_transport" +require "sentry/transport/debug_transport" diff --git a/sentry-ruby/lib/sentry/transport/debug_transport.rb b/sentry-ruby/lib/sentry/transport/debug_transport.rb new file mode 100644 index 000000000..58d3ffb3e --- /dev/null +++ b/sentry-ruby/lib/sentry/transport/debug_transport.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "pathname" +require "delegate" + +module Sentry + # DebugTransport is a transport that logs events to a file for debugging purposes. + # + # It can optionally also send events to Sentry via HTTP transport if a real DSN + # is provided. + class DebugTransport < SimpleDelegator + DEFAULT_LOG_FILE_PATH = File.join("log", "sentry_debug_events.log") + + attr_reader :log_file, :backend + + def initialize(configuration) + @log_file = initialize_log_file(configuration) + @backend = initialize_backend(configuration) + + super(@backend) + end + + def send_event(event) + log_envelope(envelope_from_event(event)) + backend.send_event(event) + end + + def log_envelope(envelope) + envelope_json = { + timestamp: Time.now.utc.iso8601, + envelope_headers: envelope.headers, + items: envelope.items.map do |item| + { headers: item.headers, payload: item.payload } + end + } + + File.open(log_file, "a") { |file| file << JSON.dump(envelope_json) << "\n" } + end + + def logged_envelopes + return [] unless File.exist?(log_file) + + File.readlines(log_file).map do |line| + JSON.parse(line) + end + end + + def clear + File.write(log_file, "") + log_debug("DebugTransport: Cleared events from #{log_file}") + end + + private + + def initialize_backend(configuration) + backend = configuration.dsn.local? ? DummyTransport : HTTPTransport + backend.new(configuration) + end + + def initialize_log_file(configuration) + log_file = Pathname(configuration.sdk_debug_transport_log_file || DEFAULT_LOG_FILE_PATH) + + FileUtils.mkdir_p(log_file.dirname) unless log_file.dirname.exist? + + log_file + end + end +end diff --git a/sentry-ruby/spec/sentry/transport/debug_transport_spec.rb b/sentry-ruby/spec/sentry/transport/debug_transport_spec.rb new file mode 100644 index 000000000..90b283ff3 --- /dev/null +++ b/sentry-ruby/spec/sentry/transport/debug_transport_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.describe Sentry do + let(:client) { Sentry.get_current_client } + let(:transport) { Sentry.get_current_client.transport } + let(:error) { StandardError.new("test error") } + + before do + perform_basic_setup + + setup_sentry_test do |config| + config.dsn = dsn + config.transport.transport_class = Sentry::DebugTransport + config.debug = true + end + end + + after do + teardown_sentry_test + end + + context "with local DSN for testing" do + let(:dsn) { Sentry::TestHelper::DUMMY_DSN } + + describe ".capture_exception with debug transport" do + it "logs envelope data and stores an event internally" do + Sentry.capture_exception(error) + + expect(transport.events.count).to be(1) + expect(transport.backend.events.count).to be(1) + expect(transport.backend.envelopes.count).to be(1) + + event = transport.logged_envelopes.last + item = event["items"].first + payload = item["payload"] + + expect(payload["exception"]["values"].first["value"]).to include("test error") + end + end + end + + context "with a real DSN for testing" do + let(:dsn) { Sentry::TestHelper::REAL_DSN } + + describe ".capture_exception with debug transport" do + it "sends an event and logs envelope" do + stub_request(:post, "https://getsentry.io/project/api/42/envelope/") + .to_return(status: 200, body: "", headers: {}) + + Sentry.capture_exception(error) + + expect(transport.logged_envelopes.count).to be(1) + + event = transport.logged_envelopes.last + item = event["items"].first + payload = item["payload"] + + expect(payload["exception"]["values"].first["value"]).to include("test error") + end + end + end +end diff --git a/sentry-ruby/spec/spec_helper.rb b/sentry-ruby/spec/spec_helper.rb index 3dc57f956..031fac7a4 100644 --- a/sentry-ruby/spec/spec_helper.rb +++ b/sentry-ruby/spec/spec_helper.rb @@ -79,6 +79,14 @@ end config.after(:each) do + if Sentry.initialized? + transport = Sentry.get_current_client&.transport + + if transport.is_a?(Sentry::DebugTransport) + transport.clear + end + end + reset_sentry_globals! end From adc27b3c5098f427f52ccd79f7db5dc42e8706ab Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Wed, 30 Jul 2025 13:49:50 +0000 Subject: [PATCH 4/4] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d898b89..226075393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Internal - Factor out do_request in HTTP transport ([#2662](https://github.com/getsentry/sentry-ruby/pull/2662)) +- Add `Sentry::DebugTransport` that captures events and stores them as JSON for debugging purposes ([#2664](https://github.com/getsentry/sentry-ruby/pull/2664)) ## 5.26.0