Skip to content

Commit 6bd141c

Browse files
authored
Add Sentry::DebugTransport for testing/debugging (#2664)
* 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. * Add DSN#local? * Add Sentry::DebugTransport for testing/debugging * Update CHANGELOG
1 parent 36920ac commit 6bd141c

File tree

12 files changed

+223
-5
lines changed

12 files changed

+223
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
### Internal
44

55
- Factor out do_request in HTTP transport ([#2662](https://github.com/getsentry/sentry-ruby/pull/2662))
6+
- Add `Sentry::DebugTransport` that captures events and stores them as JSON for debugging purposes ([#2664](https://github.com/getsentry/sentry-ruby/pull/2664))
67

78
## 5.26.0
89

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ def capture_exception_frame_locals=(value)
196196
# @return [Logger]
197197
attr_accessor :sdk_logger
198198

199+
# File path for DebugTransport to log events to. If not set, defaults to a temporary file.
200+
# This is useful for debugging and testing purposes.
201+
# @return [String, nil]
202+
attr_accessor :sdk_debug_transport_log_file
203+
199204
# @deprecated Use {#sdk_logger=} instead.
200205
def logger=(logger)
201206
warn "[sentry] `config.logger=` is deprecated. Please use `config.sdk_logger=` instead."

sentry-ruby/lib/sentry/dsn.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
# frozen_string_literal: true
22

33
require "uri"
4+
require "ipaddr"
5+
require "resolv"
46

57
module Sentry
68
class DSN
79
PORT_MAP = { "http" => 80, "https" => 443 }.freeze
810
REQUIRED_ATTRIBUTES = %w[host path public_key project_id].freeze
11+
LOCALHOST_NAMES = %w[localhost 127.0.0.1 ::1 [::1]].freeze
12+
LOCALHOST_PATTERN = /\.local(host|domain)?$/i
913

1014
attr_reader :scheme, :secret_key, :port, *REQUIRED_ATTRIBUTES
1115

@@ -49,5 +53,33 @@ def csp_report_uri
4953
def envelope_endpoint
5054
"#{path}/api/#{project_id}/envelope/"
5155
end
56+
57+
def local?
58+
@local ||= (localhost? || private_ip? || resolved_ips_private?)
59+
end
60+
61+
def localhost?
62+
LOCALHOST_NAMES.include?(host.downcase) || LOCALHOST_PATTERN.match?(host)
63+
end
64+
65+
def private_ip?
66+
@private_ip ||= begin
67+
begin
68+
IPAddr.new(host).private?
69+
rescue IPAddr::InvalidAddressError
70+
false
71+
end
72+
end
73+
end
74+
75+
def resolved_ips_private?
76+
@resolved_ips_private ||= begin
77+
begin
78+
Resolv.getaddresses(host).any? { |ip| IPAddr.new(ip).private? }
79+
rescue Resolv::ResolvError, IPAddr::InvalidAddressError
80+
false
81+
end
82+
end
83+
end
5284
end
5385
end

sentry-ruby/lib/sentry/test_helper.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ module Sentry
44
module TestHelper
55
DUMMY_DSN = "http://12345:67890@sentry.localdomain/sentry/42"
66

7+
# Not really real, but it will be resolved as a non-local for testing needs
8+
REAL_DSN = "https://user:pass@getsentry.io/project/42"
9+
710
# Alters the existing SDK configuration with test-suitable options. Mainly:
811
# - Sets a dummy DSN instead of `nil` or an actual DSN.
912
# - Sets the transport to DummyTransport, which allows easy access to the captured events.
@@ -46,6 +49,11 @@ def setup_sentry_test(&block)
4649
def teardown_sentry_test
4750
return unless Sentry.initialized?
4851

52+
transport = Sentry.get_current_client&.transport
53+
if transport.is_a?(Sentry::DebugTransport)
54+
transport.clear
55+
end
56+
4957
# pop testing layer created by `setup_sentry_test`
5058
# but keep the base layer to avoid nil-pointer errors
5159
# TODO: find a way to notify users if they somehow popped the test layer before calling this method

sentry-ruby/lib/sentry/transport.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,4 @@ def reject_rate_limited_items(envelope)
223223
require "sentry/transport/dummy_transport"
224224
require "sentry/transport/http_transport"
225225
require "sentry/transport/spotlight_transport"
226+
require "sentry/transport/debug_transport"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "fileutils"
5+
require "pathname"
6+
require "delegate"
7+
8+
module Sentry
9+
# DebugTransport is a transport that logs events to a file for debugging purposes.
10+
#
11+
# It can optionally also send events to Sentry via HTTP transport if a real DSN
12+
# is provided.
13+
class DebugTransport < SimpleDelegator
14+
DEFAULT_LOG_FILE_PATH = File.join("log", "sentry_debug_events.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+
def send_event(event)
26+
log_envelope(envelope_from_event(event))
27+
backend.send_event(event)
28+
end
29+
30+
def log_envelope(envelope)
31+
envelope_json = {
32+
timestamp: Time.now.utc.iso8601,
33+
envelope_headers: envelope.headers,
34+
items: envelope.items.map do |item|
35+
{ headers: item.headers, payload: item.payload }
36+
end
37+
}
38+
39+
File.open(log_file, "a") { |file| file << JSON.dump(envelope_json) << "\n" }
40+
end
41+
42+
def logged_envelopes
43+
return [] unless File.exist?(log_file)
44+
45+
File.readlines(log_file).map do |line|
46+
JSON.parse(line)
47+
end
48+
end
49+
50+
def clear
51+
File.write(log_file, "")
52+
log_debug("DebugTransport: Cleared events from #{log_file}")
53+
end
54+
55+
private
56+
57+
def initialize_backend(configuration)
58+
backend = configuration.dsn.local? ? DummyTransport : HTTPTransport
59+
backend.new(configuration)
60+
end
61+
62+
def initialize_log_file(configuration)
63+
log_file = Pathname(configuration.sdk_debug_transport_log_file || DEFAULT_LOG_FILE_PATH)
64+
65+
FileUtils.mkdir_p(log_file.dirname) unless log_file.dirname.exist?
66+
67+
log_file
68+
end
69+
end
70+
end

sentry-ruby/lib/sentry/transport/dummy_transport.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def initialize(*)
1212

1313
def send_event(event)
1414
@events << event
15+
super
1516
end
1617

1718
def send_envelope(envelope)

sentry-ruby/spec/sentry/dsn_spec.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,29 @@
3737
expect(subject.csp_report_uri).to eq("http://sentry.localdomain:3000/api/42/security/?sentry_key=12345")
3838
end
3939
end
40+
41+
describe "#local?" do
42+
it "returns true for localhost" do
43+
expect(described_class.new("http://12345:67890@localhost/sentry/42").local?).to eq(true)
44+
end
45+
46+
it "returns true for 127.0.0.1" do
47+
expect(described_class.new("http://12345:67890@127.0.0.1/sentry/42").local?).to eq(true)
48+
end
49+
it "returns true for ::1" do
50+
expect(described_class.new("http://12345:67890@[::1]/sentry/42").local?).to eq(true)
51+
end
52+
53+
it "returns true for private IP" do
54+
expect(described_class.new("http://12345:67890@192.168.0.1/sentry/42").local?).to eq(true)
55+
end
56+
57+
it "returns true for private IP with port" do
58+
expect(described_class.new("http://12345:67890@192.168.0.1:3000/sentry/42").local?).to eq(true)
59+
end
60+
61+
it "returns false for non-local domain" do
62+
expect(described_class.new("http://12345:67890@sentry.io/sentry/42").local?).to eq(false)
63+
end
64+
end
4065
end

sentry-ruby/spec/sentry/rack/capture_exceptions_spec.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,11 +667,15 @@ def will_be_sampled_by_sdk
667667

668668
Sentry.session_flusher.flush
669669

670-
expect(sentry_envelopes.count).to eq(1)
671-
envelope = sentry_envelopes.first
670+
expect(sentry_envelopes.count).to eq(3)
672671

673-
expect(envelope.items.length).to eq(1)
674-
item = envelope.items.first
672+
session_envelope = sentry_envelopes.find do |envelope|
673+
envelope.items.any? { |item| item.type == 'sessions' }
674+
end
675+
676+
expect(session_envelope).not_to be_nil
677+
expect(session_envelope.items.length).to eq(1)
678+
item = session_envelope.items.first
675679
expect(item.type).to eq('sessions')
676680
expect(item.payload[:attrs]).to eq({ release: 'test-release', environment: 'test' })
677681
expect(item.payload[:aggregates].first).to eq({ exited: 10, errored: 2, started: now_bucket.iso8601 })
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Sentry do
4+
let(:client) { Sentry.get_current_client }
5+
let(:transport) { Sentry.get_current_client.transport }
6+
let(:error) { StandardError.new("test error") }
7+
8+
before do
9+
perform_basic_setup
10+
11+
setup_sentry_test do |config|
12+
config.dsn = dsn
13+
config.transport.transport_class = Sentry::DebugTransport
14+
config.debug = true
15+
end
16+
end
17+
18+
after do
19+
teardown_sentry_test
20+
end
21+
22+
context "with local DSN for testing" do
23+
let(:dsn) { Sentry::TestHelper::DUMMY_DSN }
24+
25+
describe ".capture_exception with debug transport" do
26+
it "logs envelope data and stores an event internally" do
27+
Sentry.capture_exception(error)
28+
29+
expect(transport.events.count).to be(1)
30+
expect(transport.backend.events.count).to be(1)
31+
expect(transport.backend.envelopes.count).to be(1)
32+
33+
event = transport.logged_envelopes.last
34+
item = event["items"].first
35+
payload = item["payload"]
36+
37+
expect(payload["exception"]["values"].first["value"]).to include("test error")
38+
end
39+
end
40+
end
41+
42+
context "with a real DSN for testing" do
43+
let(:dsn) { Sentry::TestHelper::REAL_DSN }
44+
45+
describe ".capture_exception with debug transport" do
46+
it "sends an event and logs envelope" do
47+
stub_request(:post, "https://getsentry.io/project/api/42/envelope/")
48+
.to_return(status: 200, body: "", headers: {})
49+
50+
Sentry.capture_exception(error)
51+
52+
expect(transport.logged_envelopes.count).to be(1)
53+
54+
event = transport.logged_envelopes.last
55+
item = event["items"].first
56+
payload = item["payload"]
57+
58+
expect(payload["exception"]["values"].first["value"]).to include("test error")
59+
end
60+
end
61+
end
62+
end

0 commit comments

Comments
 (0)