diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index 800668982..e1571e8c8 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -304,8 +304,13 @@ def send_envelope(envelope) rescue => e log_error("Envelope sending failed", e, debug: configuration.debug) - envelope.items.map(&:data_category).each do |data_category| - transport.record_lost_event(:network_error, data_category) + envelope.items.each do |item| + transport.record_lost_event( + :network_error, + item.data_category, + num: item.item_count, + num_bytes: item.lost_event_byte_size + ) end raise diff --git a/sentry-ruby/lib/sentry/envelope/item.rb b/sentry-ruby/lib/sentry/envelope/item.rb index 3f6d5f9bb..4a03c8855 100644 --- a/sentry-ruby/lib/sentry/envelope/item.rb +++ b/sentry-ruby/lib/sentry/envelope/item.rb @@ -25,6 +25,13 @@ def self.data_category(type) end end + def self.byte_data_category(data_category) + case data_category + when "log_item" then "log_byte" + when "trace_metric" then "trace_metric_byte" + end + end + def initialize(headers, payload) @headers = headers @payload = payload @@ -33,6 +40,20 @@ def initialize(headers, payload) @size_limit = SIZE_LIMITS[type] end + def byte_data_category + self.class.byte_data_category(data_category) + end + + def item_count + headers[:item_count] || 1 + end + + def lost_event_byte_size + return unless byte_data_category + + (payload.is_a?(String) ? payload : JSON.generate(payload)).bytesize + end + def to_s [JSON.generate(@headers), @payload.is_a?(String) ? @payload : JSON.generate(@payload)].join("\n") end diff --git a/sentry-ruby/lib/sentry/telemetry_event_buffer.rb b/sentry-ruby/lib/sentry/telemetry_event_buffer.rb index 1ced8e360..29f348af8 100644 --- a/sentry-ruby/lib/sentry/telemetry_event_buffer.rb +++ b/sentry-ruby/lib/sentry/telemetry_event_buffer.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "json" require "sentry/threaded_periodic_worker" require "sentry/envelope" @@ -58,7 +59,11 @@ def add_item(item) if size >= @max_items_before_drop log_debug("[#{self.class}] exceeded max capacity, dropping event") - @client.transport.record_lost_event(:queue_overflow, @data_category) + @client.transport.record_lost_event( + :queue_overflow, + @data_category, + num_bytes: JSON.generate(item.to_h).bytesize + ) else @pending_items << item end @@ -92,6 +97,7 @@ def send_items ) discarded_count = 0 + discarded_bytes = 0 envelope_items = [] if @before_send @@ -102,6 +108,7 @@ def send_items envelope_items << processed_item.to_h else discarded_count += 1 + discarded_bytes += JSON.generate(item.to_h).bytesize end end else @@ -109,7 +116,7 @@ def send_items end unless discarded_count.zero? - @client.transport.record_lost_event(:before_send, @data_category, num: discarded_count) + @client.transport.record_lost_event(:before_send, @data_category, num: discarded_count, num_bytes: discarded_bytes) end return if envelope_items.empty? diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index d799dc7e2..71bd2f878 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -64,7 +64,7 @@ def send_envelope(envelope) end rescue Sentry::SizeExceededError serialized_items&.each do |item| - record_lost_event(:send_error, item.data_category) + record_lost_event(:send_error, item.data_category, num: item.item_count, num_bytes: item.lost_event_byte_size) end end @@ -167,11 +167,16 @@ def envelope_from_event(event) envelope end - def record_lost_event(reason, data_category, num: 1) + def record_lost_event(reason, data_category, num: 1, num_bytes: nil) return unless @send_client_reports return unless CLIENT_REPORT_REASONS.include?(reason) @discarded_events[[reason, data_category]] += num + + return unless num_bytes + + byte_category = Envelope::Item.byte_data_category(data_category) + @discarded_events[[reason, byte_category]] += num_bytes if byte_category end def flush @@ -211,7 +216,13 @@ def reject_rate_limited_items(envelope) envelope.items.reject! do |item| if is_rate_limited?(item.data_category) log_debug("[Transport] Envelope item [#{item.type}] not sent: rate limiting") - record_lost_event(:ratelimit_backoff, item.data_category) + + record_lost_event( + :ratelimit_backoff, + item.data_category, + num: item.item_count, + num_bytes: item.lost_event_byte_size + ) true else diff --git a/sentry-ruby/spec/sentry/envelope/item_spec.rb b/sentry-ruby/spec/sentry/envelope/item_spec.rb index 9a46d8d33..0b5feed0d 100644 --- a/sentry-ruby/spec/sentry/envelope/item_spec.rb +++ b/sentry-ruby/spec/sentry/envelope/item_spec.rb @@ -21,4 +21,50 @@ end end end + + describe '.byte_data_category' do + [ + ['log_item', 'log_byte'], + ['trace_metric', 'trace_metric_byte'], + ['error', nil], + ['transaction', nil], + ['default', nil] + ].each do |data_category, byte_category| + it "maps data category #{data_category} to byte category #{byte_category.inspect}" do + expect(described_class.byte_data_category(data_category)).to eq(byte_category) + end + end + end + + describe '#item_count' do + it "returns the item_count header when present" do + item = described_class.new({ type: "log", item_count: 5 }, { items: [] }) + expect(item.item_count).to eq(5) + end + + it "defaults to 1 when the header is absent" do + item = described_class.new({ type: "event" }, {}) + expect(item.item_count).to eq(1) + end + end + + describe '#lost_event_byte_size' do + it "returns the serialized payload byte size for byte-tracked items" do + payload = { items: [{ body: "hello" }] } + item = described_class.new({ type: "log", item_count: 1 }, payload) + expect(item.lost_event_byte_size).to eq(JSON.generate(payload).bytesize) + expect(item.lost_event_byte_size).to be > 0 + end + + it "uses the payload as-is when it is already serialized" do + payload = JSON.generate({ items: [{ body: "hello" }] }) + item = described_class.new({ type: "log", item_count: 1 }, payload) + expect(item.lost_event_byte_size).to eq(payload.bytesize) + end + + it "returns nil for items without a byte category" do + item = described_class.new({ type: "event" }, { foo: "bar" }) + expect(item.lost_event_byte_size).to be_nil + end + end end diff --git a/sentry-ruby/spec/sentry/metrics_spec.rb b/sentry-ruby/spec/sentry/metrics_spec.rb index fe9739793..433971f2d 100644 --- a/sentry-ruby/spec/sentry/metrics_spec.rb +++ b/sentry-ruby/spec/sentry/metrics_spec.rb @@ -313,7 +313,7 @@ expect(sentry_metrics.count).to eq(1) expect(sentry_metrics.first[:name]).to eq("test.allowed") - expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:before_send, 'trace_metric', num: 2) + expect(Sentry.get_current_client.transport).to have_recorded_lost_event(:before_send, 'trace_metric', num: 2, num_bytes: a_value > 0) end end end diff --git a/sentry-ruby/spec/sentry/structured_logger_spec.rb b/sentry-ruby/spec/sentry/structured_logger_spec.rb index 3b91640fd..4b4aa8023 100644 --- a/sentry-ruby/spec/sentry/structured_logger_spec.rb +++ b/sentry-ruby/spec/sentry/structured_logger_spec.rb @@ -241,6 +241,7 @@ expect(sentry_logs.size).to be(1) expect(transport.discarded_events).to include([:before_send, "log_item"] => 2) + expect(transport.discarded_events[[:before_send, "log_byte"]]).to be > 0 end end end diff --git a/sentry-ruby/spec/sentry/transport_spec.rb b/sentry-ruby/spec/sentry/transport_spec.rb index af4e1b5cb..a7cb90a41 100644 --- a/sentry-ruby/spec/sentry/transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport_spec.rb @@ -214,6 +214,83 @@ end end + context "byte-count client outcomes" do + let(:log_events) do + 3.times.map do + Sentry::LogEvent.new(level: :info, body: "User has logged in!") + end + end + + let(:metric_events) do + 3.times.map do + Sentry::MetricEvent.new(name: "my.metric", type: "counter", value: 1) + end + end + + let(:log_envelope) do + envelope = Sentry::Envelope.new + envelope.add_item( + { type: "log", item_count: log_events.size, content_type: "application/vnd.sentry.items.log+json" }, + { items: log_events.map(&:to_h) } + ) + envelope + end + + let(:metric_envelope) do + envelope = Sentry::Envelope.new + envelope.add_item( + { type: "trace_metric", item_count: metric_events.size, content_type: "application/vnd.sentry.items.trace-metric+json" }, + { items: metric_events.map(&:to_h) } + ) + envelope + end + + describe "#record_lost_event" do + it "fans out into the paired byte category" do + subject.record_lost_event(:ratelimit_backoff, "log_item", num: 3, num_bytes: 1243) + + expect(subject.discarded_events[[:ratelimit_backoff, "log_item"]]).to eq(3) + expect(subject.discarded_events[[:ratelimit_backoff, "log_byte"]]).to eq(1243) + end + + it "does not record a byte category for non-byte-tracked categories" do + subject.record_lost_event(:ratelimit_backoff, "error", num: 1, num_bytes: 1243) + + expect(subject.discarded_events.keys).to contain_exactly([:ratelimit_backoff, "error"]) + end + + it "does not record bytes when num_bytes is nil" do + subject.record_lost_event(:ratelimit_backoff, "log_item") + + expect(subject.discarded_events.keys).to contain_exactly([:ratelimit_backoff, "log_item"]) + end + end + + context "when a batched log item is rate limited" do + before { subject.rate_limits.merge!("log_item" => Time.now + 60) } + + it "records log_item count and log_byte size" do + log_item = log_envelope.items.first + subject.send_envelope(log_envelope) + + expect(subject.discarded_events[[:ratelimit_backoff, "log_item"]]).to eq(3) + expect(subject.discarded_events[[:ratelimit_backoff, "log_byte"]]).to eq(JSON.generate(log_item.payload).bytesize) + end + end + + context "when a batched trace metric item is rate limited" do + before { subject.rate_limits.merge!("trace_metric" => Time.now + 60) } + + it "records trace_metric count and trace_metric_byte size" do + metric_item = metric_envelope.items.first + subject.send_envelope(metric_envelope) + + expect(subject.discarded_events[[:ratelimit_backoff, "trace_metric"]]).to eq(3) + expect(subject.discarded_events[[:ratelimit_backoff, "trace_metric_byte"]]).to eq(JSON.generate(metric_item.payload).bytesize) + end + end + end + context "log events" do let(:log_events) do 5.times.map do |i| diff --git a/sentry-ruby/spec/spec_helper.rb b/sentry-ruby/spec/spec_helper.rb index 6aaf34d4a..c2cc7a056 100644 --- a/sentry-ruby/spec/spec_helper.rb +++ b/sentry-ruby/spec/spec_helper.rb @@ -93,9 +93,16 @@ reset_sentry_globals! end - RSpec::Matchers.define :have_recorded_lost_event do |reason, data_category, num: 1| + RSpec::Matchers.define :have_recorded_lost_event do |reason, data_category, num: 1, num_bytes: nil| match do |transport| expect(transport.discarded_events[[reason, data_category]]).to eq(num) + + next true unless num_bytes + + byte_category = Sentry::Envelope::Item.byte_data_category(data_category) + expect(transport.discarded_events[[reason, byte_category]]).to match(num_bytes) + + true end end end diff --git a/sentry-ruby/spec/support/shared_examples_for_telemetry_event_buffers.rb b/sentry-ruby/spec/support/shared_examples_for_telemetry_event_buffers.rb index 576779723..e26286e16 100644 --- a/sentry-ruby/spec/support/shared_examples_for_telemetry_event_buffers.rb +++ b/sentry-ruby/spec/support/shared_examples_for_telemetry_event_buffers.rb @@ -109,7 +109,7 @@ it "records lost event when dropping due to queue overflow" do max_items_before_drop.times { subject.add_item(event) } - expect(client.transport).to receive(:record_lost_event).with(:queue_overflow, subject.data_category) + expect(client.transport).to receive(:record_lost_event).with(:queue_overflow, subject.data_category, num_bytes: a_value > 0) subject.add_item(event) end