Skip to content

Add Sentry::DebugTransport for testing/debugging #2664

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
32 changes: 32 additions & 0 deletions sentry-ruby/lib/sentry/dsn.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions sentry-ruby/lib/sentry/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions sentry-ruby/lib/sentry/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
70 changes: 70 additions & 0 deletions sentry-ruby/lib/sentry/transport/debug_transport.rb
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Backend Initialization Fails Without DSN Check

The initialize_backend method in DebugTransport calls configuration.dsn.local? without checking if configuration.dsn is nil. This raises a NoMethodError if the DSN is not configured.

Locations (1)
Fix in Cursor Fix in Web

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
1 change: 1 addition & 0 deletions sentry-ruby/lib/sentry/transport/dummy_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def initialize(*)

def send_event(event)
@events << event
super
end

def send_envelope(envelope)
Expand Down
25 changes: 25 additions & 0 deletions sentry-ruby/spec/sentry/dsn_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 8 additions & 4 deletions sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did these values change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sl0thentr0py because the test actually sends 2 errors and flushes session which sends another event, so total is 3 envelopes, previously dummy transport wouldn't catch 2 other envelopes


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 })
Expand Down
62 changes: 62 additions & 0 deletions sentry-ruby/spec/sentry/transport/debug_transport_spec.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion sentry-ruby/spec/sentry_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we actually shouldn't be sending the dropped profile client report here since we don't have profiles_sample_rate enabled, we should fix this separately

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sl0thentr0py yeah it seemed suspicious, I reported an issue about this #2669

expect(sentry_envelopes.size).to be(3)

log_event = sentry_logs.first

Expand Down
8 changes: 8 additions & 0 deletions sentry-ruby/spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@
end

config.after(:each) do
if Sentry.initialized?
transport = Sentry.get_current_client&.transport

if transport.is_a?(Sentry::DebugTransport)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we aren't actually using the DebugTransport anywhere in the tests so why is this needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sl0thentr0py another PR will actually use this in a couple of e2e test

transport.clear
end
end

reset_sentry_globals!
end

Expand Down
Loading