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 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/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/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/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/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 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/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/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 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