From 0eaa8679cebeb4fac83d3ed355b0e564beb3ccfb Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:46:14 +0100 Subject: [PATCH 01/25] feat: add clientreport package - create global aggregator - add data types for outcomes, discard events & client report - create discard reason enum --- internal/clientreport/aggregator.go | 94 +++++++++++++++++++++++++++++ internal/clientreport/global.go | 30 +++++++++ internal/clientreport/outcome.go | 18 ++++++ internal/clientreport/reason.go | 33 ++++++++++ internal/clientreport/report.go | 23 +++++++ 5 files changed, 198 insertions(+) create mode 100644 internal/clientreport/aggregator.go create mode 100644 internal/clientreport/global.go create mode 100644 internal/clientreport/outcome.go create mode 100644 internal/clientreport/reason.go create mode 100644 internal/clientreport/report.go diff --git a/internal/clientreport/aggregator.go b/internal/clientreport/aggregator.go new file mode 100644 index 000000000..fbcb103ec --- /dev/null +++ b/internal/clientreport/aggregator.go @@ -0,0 +1,94 @@ +package clientreport + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/getsentry/sentry-go/internal/ratelimit" +) + +// Aggregator collects discarded event outcomes for client reports. +// Uses atomic operations to be safe for concurrent use. +type Aggregator struct { + mu sync.Mutex + outcomes map[OutcomeKey]*atomic.Int64 + + enabled atomic.Bool +} + +// NewAggregator creates a new client report aggregator. +func NewAggregator() *Aggregator { + a := &Aggregator{ + outcomes: make(map[OutcomeKey]*atomic.Int64), + } + a.enabled.Store(true) + return a +} + +// SetEnabled enables or disables outcome recording. +func (a *Aggregator) SetEnabled(enabled bool) { + a.enabled.Store(enabled) +} + +// IsEnabled returns whether outcome recording is enabled. +func (a *Aggregator) IsEnabled() bool { + return a.enabled.Load() +} + +// RecordOutcome records a discarded event outcome. +func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { + if !a.enabled.Load() || quantity <= 0 { + return + } + + key := OutcomeKey{Reason: reason, Category: category} + + a.mu.Lock() + counter, exists := a.outcomes[key] + if !exists { + counter = &atomic.Int64{} + a.outcomes[key] = counter + } + a.mu.Unlock() + + counter.Add(quantity) +} + +// TakeReport atomically takes all accumulated outcomes and returns a ClientReport. +func (a *Aggregator) TakeReport() *ClientReport { + a.mu.Lock() + defer a.mu.Unlock() + + if len(a.outcomes) == 0 { + return nil + } + + var events []DiscardedEvent + for key, counter := range a.outcomes { + quantity := counter.Swap(0) + if quantity > 0 { + events = append(events, DiscardedEvent{ + Reason: key.Reason, + Category: key.Category, + Quantity: quantity, + }) + } + } + + // Clear empty counters to prevent unbounded growth + for key, counter := range a.outcomes { + if counter.Load() == 0 { + delete(a.outcomes, key) + } + } + + if len(events) == 0 { + return nil + } + + return &ClientReport{ + Timestamp: time.Now(), + DiscardedEvents: events, + } +} diff --git a/internal/clientreport/global.go b/internal/clientreport/global.go new file mode 100644 index 000000000..393113b57 --- /dev/null +++ b/internal/clientreport/global.go @@ -0,0 +1,30 @@ +package clientreport + +import ( + "sync" + + "github.com/getsentry/sentry-go/internal/ratelimit" +) + +var ( + globalAggregator *Aggregator + globalAggregatorOnce sync.Once +) + +// Global returns the global client report aggregator singleton. +func Global() *Aggregator { + globalAggregatorOnce.Do(func() { + globalAggregator = NewAggregator() + }) + return globalAggregator +} + +// Record is a convenience function for recording an outcome to the global aggregator. +func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { + Global().RecordOutcome(reason, category, quantity) +} + +// RecordOne is a convenience function for recording a single outcome to the global aggregator. +func RecordOne(reason DiscardReason, category ratelimit.Category) { + Global().RecordOutcome(reason, category, 1) +} diff --git a/internal/clientreport/outcome.go b/internal/clientreport/outcome.go new file mode 100644 index 000000000..a65c6b2ee --- /dev/null +++ b/internal/clientreport/outcome.go @@ -0,0 +1,18 @@ +package clientreport + +import ( + "github.com/getsentry/sentry-go/internal/ratelimit" +) + +// OutcomeKey uniquely identifies an outcome bucket for aggregation. +type OutcomeKey struct { + Reason DiscardReason + Category ratelimit.Category +} + +// DiscardedEvent represents a single discard event outcome for the OutcomeKey. +type DiscardedEvent struct { + Reason DiscardReason `json:"reason"` + Category ratelimit.Category `json:"category"` + Quantity int64 `json:"quantity"` +} diff --git a/internal/clientreport/reason.go b/internal/clientreport/reason.go new file mode 100644 index 000000000..6a916c8dd --- /dev/null +++ b/internal/clientreport/reason.go @@ -0,0 +1,33 @@ +package clientreport + +// DiscardReason represents why an item was discarded. +type DiscardReason string + +const ( + // ReasonQueueOverflow indicates the transport queue was full. + ReasonQueueOverflow DiscardReason = "queue_overflow" + + // ReasonBufferOverflow indicates that an internal buffer was full. + ReasonBufferOverflow DiscardReason = "buffer_overflow" + + // ReasonRateLimitBackoff indicates the item was dropped due to rate limiting. + ReasonRateLimitBackoff DiscardReason = "ratelimit_backoff" + + // ReasonBeforeSend indicates the item was dropped due to a BeforeSend callback. + ReasonBeforeSend DiscardReason = "before_send" + + // ReasonEventProcessor indicates the item was dropped due to an event processor callback. + ReasonEventProcessor DiscardReason = "event_processor" + + // ReasonSampleRate indicates the item was dropped due to sampling. + ReasonSampleRate DiscardReason = "sample_rate" + + // ReasonNetworkError indicates an HTTP request failed (connection error). + ReasonNetworkError DiscardReason = "network_error" + + // ReasonSendError indicates HTTP returned an error status (4xx, 5xx). + ReasonSendError DiscardReason = "send_error" + + // ReasonInternalError indicates an internal SDK error. + ReasonInternalError DiscardReason = "internal_sdk_error" +) diff --git a/internal/clientreport/report.go b/internal/clientreport/report.go new file mode 100644 index 000000000..9d482cb4e --- /dev/null +++ b/internal/clientreport/report.go @@ -0,0 +1,23 @@ +package clientreport + +import ( + "encoding/json" + "time" + + "github.com/getsentry/sentry-go/internal/protocol" +) + +// ClientReport is the payload sent to Sentry for tracking discarded events. +type ClientReport struct { + Timestamp time.Time `json:"timestamp"` + DiscardedEvents []DiscardedEvent `json:"discarded_events"` +} + +// ToEnvelopeItem converts the ClientReport to an envelope item. +func (r *ClientReport) ToEnvelopeItem() (*protocol.EnvelopeItem, error) { + payload, err := json.Marshal(r) + if err != nil { + return nil, err + } + return protocol.NewClientReportItem(payload), nil +} From 9693573e25feba5ef563d468f4c04ee6b1957349 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:48:35 +0100 Subject: [PATCH 02/25] feat: record client report outcomes --- client.go | 14 ++++++++++++++ internal/http/transport.go | 12 ++++++++++++ internal/protocol/envelope.go | 25 +++++++++++++++++++------ internal/telemetry/ring_buffer.go | 4 ++++ internal/telemetry/scheduler.go | 13 +++++++++++-- transport.go | 15 +++++++++++++-- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/client.go b/client.go index fd6cba163..a90bd1627 100644 --- a/client.go +++ b/client.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debug" "github.com/getsentry/sentry-go/internal/debuglog" httpInternal "github.com/getsentry/sentry-go/internal/http" @@ -563,6 +564,7 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { log = client.options.BeforeSendLog(log) if log == nil { debuglog.Println("Log dropped due to BeforeSendLog callback.") + clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryLog) return false } } @@ -570,11 +572,13 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { if client.telemetryProcessor != nil { if !client.telemetryProcessor.Add(log) { debuglog.Print("Dropping log: telemetry buffer full or category missing") + // Note: processor tracks client report return false } } else if client.batchLogger != nil { if !client.batchLogger.Send(log) { debuglog.Printf("Dropping log [%s]: buffer full", log.Level) + clientreport.RecordOne(clientreport.ReasonBufferOverflow, ratelimit.CategoryLog) return false } } @@ -591,6 +595,7 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { metric = client.options.BeforeSendMetric(metric) if metric == nil { debuglog.Println("Metric dropped due to BeforeSendMetric callback.") + clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryTraceMetric) return false } } @@ -598,11 +603,13 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { if client.telemetryProcessor != nil { if !client.telemetryProcessor.Add(metric) { debuglog.Printf("Dropping metric: telemetry buffer full or category missing") + // Note: processor tracks client report return false } } else if client.batchMeter != nil { if !client.batchMeter.Send(metric) { debuglog.Printf("Dropping metric %q: buffer full", metric.Name) + clientreport.RecordOne(clientreport.ReasonBufferOverflow, ratelimit.CategoryTraceMetric) return false } } @@ -811,6 +818,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod // (errors, messages) are sampled here. Does not apply to check-ins. if event.Type != transactionType && event.Type != checkInType && !sample(client.options.SampleRate) { debuglog.Println("Event dropped due to SampleRate hit.") + clientreport.RecordOne(clientreport.ReasonSampleRate, ratelimit.CategoryError) return nil } @@ -827,6 +835,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod if client.options.BeforeSendTransaction != nil { if event = client.options.BeforeSendTransaction(event, hint); event == nil { debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.") + clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryTransaction) return nil } } @@ -835,6 +844,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod if client.options.BeforeSend != nil { if event = client.options.BeforeSend(event, hint); event == nil { debuglog.Println("Event dropped due to BeforeSend callback.") + clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryError) return nil } } @@ -905,18 +915,22 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod for _, processor := range client.eventProcessors { id := event.EventID + category := event.toCategory() event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id) + clientreport.RecordOne(clientreport.ReasonEventProcessor, category) return nil } } for _, processor := range globalEventProcessors { id := event.EventID + category := event.toCategory() event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id) + clientreport.RecordOne(clientreport.ReasonEventProcessor, category) return nil } } diff --git a/internal/http/transport.go b/internal/http/transport.go index 51bd68778..78bba3447 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -14,6 +14,7 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" @@ -207,12 +208,14 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p category := categoryFromEnvelope(envelope) if t.disabled(category) { + clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) return nil } request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("There was an issue creating the request: %v", err) + clientreport.RecordOne(clientreport.ReasonInternalError, category) return err } identifier := util.EnvelopeIdentifier(envelope) @@ -226,6 +229,7 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p response, err := t.client.Do(request) if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) + clientreport.RecordOne(clientreport.ReasonNetworkError, category) return err } util.HandleHTTPResponse(response, identifier) @@ -363,6 +367,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { + clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) return nil } @@ -378,6 +383,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { return nil default: atomic.AddInt64(&t.droppedCount, 1) + clientreport.RecordOne(clientreport.ReasonQueueOverflow, category) return ErrTransportQueueFull } } @@ -466,6 +472,7 @@ func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) { func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { + clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) return false } @@ -475,18 +482,23 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("Failed to create request from envelope: %v", err) + clientreport.RecordOne(clientreport.ReasonInternalError, category) return false } response, err := t.client.Do(request) if err != nil { debuglog.Printf("HTTP request failed: %v", err) + clientreport.RecordOne(clientreport.ReasonNetworkError, category) return false } defer response.Body.Close() identifier := util.EnvelopeIdentifier(envelope) success := util.HandleHTTPResponse(response, identifier) + if !success { + clientreport.RecordOne(clientreport.ReasonSendError, category) + } t.mu.Lock() if t.limits == nil { diff --git a/internal/protocol/envelope.go b/internal/protocol/envelope.go index 0ec278426..fa03ed57e 100644 --- a/internal/protocol/envelope.go +++ b/internal/protocol/envelope.go @@ -41,12 +41,13 @@ type EnvelopeItemType string // Constants for envelope item types as defined in the Sentry documentation. const ( - EnvelopeItemTypeEvent EnvelopeItemType = "event" - EnvelopeItemTypeTransaction EnvelopeItemType = "transaction" - EnvelopeItemTypeCheckIn EnvelopeItemType = "check_in" - EnvelopeItemTypeAttachment EnvelopeItemType = "attachment" - EnvelopeItemTypeLog EnvelopeItemType = "log" - EnvelopeItemTypeTraceMetric EnvelopeItemType = "trace_metric" + EnvelopeItemTypeEvent EnvelopeItemType = "event" + EnvelopeItemTypeTransaction EnvelopeItemType = "transaction" + EnvelopeItemTypeCheckIn EnvelopeItemType = "check_in" + EnvelopeItemTypeAttachment EnvelopeItemType = "attachment" + EnvelopeItemTypeLog EnvelopeItemType = "log" + EnvelopeItemTypeTraceMetric EnvelopeItemType = "trace_metric" + EnvelopeItemTypeClientReport EnvelopeItemType = "client_report" ) // EnvelopeItemHeader represents the header of an envelope item. @@ -229,3 +230,15 @@ func NewTraceMetricItem(itemCount int, payload []byte) *EnvelopeItem { Payload: payload, } } + +// NewClientReportItem creates a new envelope item for client reports. +func NewClientReportItem(payload []byte) *EnvelopeItem { + length := len(payload) + return &EnvelopeItem{ + Header: &EnvelopeItemHeader{ + Type: EnvelopeItemTypeClientReport, + Length: &length, + }, + Payload: payload, + } +} diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 7305d1fc8..49eb7b651 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -5,6 +5,7 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/ratelimit" ) @@ -84,6 +85,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { b.tail = (b.tail + 1) % b.capacity atomic.AddInt64(&b.dropped, 1) + clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(oldItem, "buffer_full_drop_oldest") } @@ -91,6 +93,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { case OverflowPolicyDropNewest: atomic.AddInt64(&b.dropped, 1) + clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(item, "buffer_full_drop_newest") } @@ -98,6 +101,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { default: atomic.AddInt64(&b.dropped, 1) + clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(item, "unknown_overflow_policy") } diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index 5bf206a54..636d3933d 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" @@ -209,8 +210,16 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category items = buffer.PollIfReady() } - // drop the current batch if rate-limited or if transport is full - if len(items) == 0 || s.isRateLimited(category) || !s.transport.HasCapacity() { + if len(items) == 0 { + return + } + + if s.isRateLimited(category) { + clientreport.Record(clientreport.ReasonRateLimitBackoff, category, int64(len(items))) + return + } + if !s.transport.HasCapacity() { + clientreport.Record(clientreport.ReasonQueueOverflow, category, int64(len(items))) return } diff --git a/transport.go b/transport.go index 1b5657502..fe0956308 100644 --- a/transport.go +++ b/transport.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" httpinternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" @@ -386,11 +387,13 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) category := event.toCategory() if t.disabled(category) { + clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) return } request, err := getRequestFromEvent(ctx, event, t.dsn) if err != nil { + clientreport.RecordOne(clientreport.ReasonInternalError, category) return } @@ -423,6 +426,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) ) default: debuglog.Println("Event dropped due to transport buffer being full.") + clientreport.RecordOne(clientreport.ReasonQueueOverflow, category) } t.buffer <- b @@ -645,12 +649,15 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve return } - if t.disabled(event.toCategory()) { + category := event.toCategory() + if t.disabled(category) { + clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) return } request, err := getRequestFromEvent(ctx, event, t.dsn) if err != nil { + clientreport.RecordOne(clientreport.ReasonInternalError, category) return } @@ -665,9 +672,13 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve response, err := t.client.Do(request) if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) + clientreport.RecordOne(clientreport.ReasonNetworkError, category) return } - util.HandleHTTPResponse(response, identifier) + success := util.HandleHTTPResponse(response, identifier) + if !success && response.StatusCode != http.StatusTooManyRequests { + clientreport.RecordOne(clientreport.ReasonSendError, category) + } t.mu.Lock() if t.limits == nil { From 6ee16a263ea2748e47b1b629a2880c014c1d95a3 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:38:13 +0100 Subject: [PATCH 03/25] feat: send client report envelopes add functionality to: - attach client reports to existing envelopes - for the async transports the background routines also get invoked with a ticker to periodically send client reports --- internal/clientreport/global.go | 5 ++ internal/clientreport/utils.go | 19 +++++ internal/http/transport.go | 2 + internal/ratelimit/category.go | 3 + internal/telemetry/scheduler.go | 27 +++++++ transport.go | 138 +++++++++++++++++++++++--------- 6 files changed, 158 insertions(+), 36 deletions(-) create mode 100644 internal/clientreport/utils.go diff --git a/internal/clientreport/global.go b/internal/clientreport/global.go index 393113b57..cac654bcf 100644 --- a/internal/clientreport/global.go +++ b/internal/clientreport/global.go @@ -28,3 +28,8 @@ func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { func RecordOne(reason DiscardReason, category ratelimit.Category) { Global().RecordOutcome(reason, category, 1) } + +// TakeReport returns a client report for sending. +func TakeReport() *ClientReport { + return Global().TakeReport() +} diff --git a/internal/clientreport/utils.go b/internal/clientreport/utils.go new file mode 100644 index 000000000..81ae260b5 --- /dev/null +++ b/internal/clientreport/utils.go @@ -0,0 +1,19 @@ +package clientreport + +import ( + "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/getsentry/sentry-go/internal/protocol" +) + +// AttachToEnvelope adds a client report to the envelope if the aggregator has outcomes available. +func AttachToEnvelope(envelope *protocol.Envelope) { + r := TakeReport() + if r != nil { + rItem, err := r.ToEnvelopeItem() + if err != nil { + envelope.AddItem(rItem) + } else { + debuglog.Printf("failed to serialize client report: %v, with err: %e", r, err) + } + } +} diff --git a/internal/http/transport.go b/internal/http/transport.go index 78bba3447..68c3db7c0 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -206,6 +206,8 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p return ErrEmptyEnvelope } + // the sync transport needs to attach client reports when available + clientreport.AttachToEnvelope(envelope) category := categoryFromEnvelope(envelope) if t.disabled(category) { clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) diff --git a/internal/ratelimit/category.go b/internal/ratelimit/category.go index aec8bb8d0..e9d1d187d 100644 --- a/internal/ratelimit/category.go +++ b/internal/ratelimit/category.go @@ -21,6 +21,7 @@ const ( CategoryError Category = "error" CategoryTransaction Category = "transaction" CategoryLog Category = "log_item" + CategoryLogByte Category = "log_byte" CategoryMonitor Category = "monitor" CategoryTraceMetric Category = "trace_metric" ) @@ -47,6 +48,8 @@ func (c Category) String() string { return "CategoryTransaction" case CategoryLog: return "CategoryLog" + case CategoryLogByte: + return "CategoryLogByte" case CategoryMonitor: return "CategoryMonitor" case CategoryTraceMetric: diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index 636d3933d..9b7b08ea1 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -11,6 +11,10 @@ import ( "github.com/getsentry/sentry-go/internal/ratelimit" ) +const ( + defaultClientReportsTick = time.Second * 30 +) + // Scheduler implements a weighted round-robin scheduler for processing buffered events. type Scheduler struct { buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem] @@ -142,10 +146,30 @@ func (s *Scheduler) run() { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() + clientReportsTicker := time.NewTicker(defaultClientReportsTick) for { select { case <-ticker.C: s.cond.Broadcast() + case <-clientReportsTicker.C: + report := clientreport.TakeReport() + if report != nil { + header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Sdk: s.sdkInfo} + if s.dsn != nil { + header.Dsn = s.dsn.String() + } + envelope := protocol.NewEnvelope(header) + item, err := report.ToEnvelopeItem() + if err != nil { + debuglog.Printf("error sending client report: %v", err) + continue + } + envelope.AddItem(item) + if err := s.transport.SendEnvelope(envelope); err != nil { + debuglog.Printf("error sending envelope: %v", err) + continue + } + } case <-s.ctx.Done(): return } @@ -237,6 +261,7 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category return } envelope.AddItem(item) + clientreport.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } @@ -254,6 +279,7 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category return } envelope.AddItem(item) + clientreport.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } @@ -292,6 +318,7 @@ func (s *Scheduler) sendItem(item protocol.EnvelopeItemConvertible) { return } envelope.AddItem(envItem) + clientreport.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } diff --git a/transport.go b/transport.go index fe0956308..c1b351e83 100644 --- a/transport.go +++ b/transport.go @@ -22,8 +22,9 @@ import ( ) const ( - defaultBufferSize = 1000 - defaultTimeout = time.Second * 30 + defaultBufferSize = 1000 + defaultTimeout = time.Second * 30 + defaultClientReportsTick = time.Second * 30 ) // Transport is used by the Client to deliver events to remote server. @@ -125,6 +126,19 @@ func encodeAttachment(enc *json.Encoder, b io.Writer, attachment *Attachment) er return nil } +func encodeClientReport(enc *json.Encoder, cr *clientreport.ClientReport) error { + payload, err := json.Marshal(cr) + if err != nil { + return err + } + err = encodeEnvelopeItem(enc, string(protocol.EnvelopeItemTypeClientReport), payload) + if err != nil { + return err + } + + return nil +} + func encodeEnvelopeItem(enc *json.Encoder, itemType string, body json.RawMessage) error { // Item header err := enc.Encode(struct { @@ -175,6 +189,19 @@ func encodeEnvelopeMetrics(enc *json.Encoder, count int, body json.RawMessage) e return err } +// envelopeHeader represents the header of a Sentry envelope. +type envelopeHeader struct { + EventID EventID `json:"event_id,omitempty"` + SentAt time.Time `json:"sent_at"` + Dsn string `json:"dsn,omitempty"` + Sdk map[string]string `json:"sdk,omitempty"` + Trace map[string]string `json:"trace,omitempty"` +} + +func encodeEnvelopeHeader(enc *json.Encoder, header *envelopeHeader) error { + return enc.Encode(header) +} + func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) { var b bytes.Buffer enc := json.NewEncoder(&b) @@ -188,13 +215,7 @@ func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMes } // Envelope header - err := enc.Encode(struct { - EventID EventID `json:"event_id"` - SentAt time.Time `json:"sent_at"` - Dsn string `json:"dsn"` - Sdk map[string]string `json:"sdk"` - Trace map[string]string `json:"trace,omitempty"` - }{ + err := encodeEnvelopeHeader(enc, &envelopeHeader{ EventID: event.EventID, SentAt: sentAt, Trace: trace, @@ -230,29 +251,52 @@ func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMes } } + // attach client report if exists + r := clientreport.TakeReport() + if r != nil { + if err := encodeClientReport(enc, r); err != nil { + return nil, err + } + } return &b, nil } -func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.Request, err error) { - defer func() { - if r != nil { - r.Header.Set("User-Agent", fmt.Sprintf("%s/%s", event.Sdk.Name, event.Sdk.Version)) - r.Header.Set("Content-Type", "application/x-sentry-envelope") +// getRequestFromEnvelope creates an HTTP request from a pre-built envelope. +// sdkName and sdkVersion are used for User-Agent and authentication headers. +func getRequestFromEnvelope(ctx context.Context, dsn *Dsn, envelope *bytes.Buffer, sdkName, sdkVersion string) (*http.Request, error) { + if ctx == nil { + ctx = context.Background() + } - auth := fmt.Sprintf("Sentry sentry_version=%s, "+ - "sentry_client=%s/%s, sentry_key=%s", apiVersion, event.Sdk.Name, event.Sdk.Version, dsn.GetPublicKey()) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + dsn.GetAPIURL().String(), + envelope, + ) + if err != nil { + return nil, err + } - // The key sentry_secret is effectively deprecated and no longer needs to be set. - // However, since it was required in older self-hosted versions, - // it should still passed through to Sentry if set. - if dsn.GetSecretKey() != "" { - auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey()) - } + request.Header.Set("User-Agent", fmt.Sprintf("%s/%s", sdkName, sdkVersion)) + request.Header.Set("Content-Type", "application/x-sentry-envelope") - r.Header.Set("X-Sentry-Auth", auth) - } - }() + auth := fmt.Sprintf("Sentry sentry_version=%s, "+ + "sentry_client=%s/%s, sentry_key=%s", apiVersion, sdkName, sdkVersion, dsn.GetPublicKey()) + + // The key sentry_secret is effectively deprecated and no longer needs to be set. + // However, since it was required in older self-hosted versions, + // it should still be passed through to Sentry if set. + if dsn.GetSecretKey() != "" { + auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey()) + } + + request.Header.Set("X-Sentry-Auth", auth) + return request, nil +} + +func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (*http.Request, error) { body := getRequestBodyFromEvent(event) if body == nil { return nil, errors.New("event could not be marshaled") @@ -263,16 +307,7 @@ func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.R return nil, err } - if ctx == nil { - ctx = context.Background() - } - - return http.NewRequestWithContext( - ctx, - http.MethodPost, - dsn.GetAPIURL().String(), - envelope, - ) + return getRequestFromEnvelope(ctx, dsn, envelope, event.Sdk.Name, event.Sdk.Version) } // ================================ @@ -513,6 +548,7 @@ func (t *HTTPTransport) Close() { } func (t *HTTPTransport) worker() { + crTicker := time.NewTicker(defaultClientReportsTick) for b := range t.buffer { // Signal that processing of the current batch has started. close(b.started) @@ -527,6 +563,36 @@ func (t *HTTPTransport) worker() { select { case <-t.done: return + case <-crTicker.C: + r := clientreport.TakeReport() + if r != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if err := encodeEnvelopeHeader(enc, &envelopeHeader{ + SentAt: time.Now(), + Dsn: t.dsn.String(), + }); err != nil { + break loop + } + if err := encodeClientReport(enc, r); err != nil { + break loop + } + req, err := getRequestFromEnvelope(context.Background(), t.dsn, &buf, sdkIdentifier, SDKVersion) + if err != nil { + debuglog.Printf("There was an issue when creating the request: %e", err) + break loop + } + response, err := t.client.Do(req) + if err != nil { + debuglog.Printf("There was an issue with sending an event: %e", err) + break loop + } + + // Drain body up to a limit and close it, allowing the + // transport to reuse TCP connections. + _, _ = io.CopyN(io.Discard, response.Body, util.MaxDrainResponseBytes) + response.Body.Close() + } case item, open := <-b.items: if !open { break loop @@ -537,7 +603,7 @@ func (t *HTTPTransport) worker() { response, err := t.client.Do(item.request) if err != nil { - debuglog.Printf("There was an issue with sending an event: %v", err) + debuglog.Printf("There was an issue with sending an event: %e", err) continue } util.HandleHTTPResponse(response, item.eventIdentifier) From 20571fa7051c7fe21ecb7d60c5a9bcdba04f9b74 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:43:32 +0100 Subject: [PATCH 04/25] feat: hide aggregator API --- internal/clientreport/aggregator.go | 18 +++++++++--------- internal/clientreport/global.go | 14 +++++++------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/clientreport/aggregator.go b/internal/clientreport/aggregator.go index fbcb103ec..c23372c19 100644 --- a/internal/clientreport/aggregator.go +++ b/internal/clientreport/aggregator.go @@ -8,18 +8,18 @@ import ( "github.com/getsentry/sentry-go/internal/ratelimit" ) -// Aggregator collects discarded event outcomes for client reports. +// aggregator collects discarded event outcomes for client reports. // Uses atomic operations to be safe for concurrent use. -type Aggregator struct { +type aggregator struct { mu sync.Mutex outcomes map[OutcomeKey]*atomic.Int64 enabled atomic.Bool } -// NewAggregator creates a new client report aggregator. -func NewAggregator() *Aggregator { - a := &Aggregator{ +// newAggregator creates a new client report aggregator. +func newAggregator() *aggregator { + a := &aggregator{ outcomes: make(map[OutcomeKey]*atomic.Int64), } a.enabled.Store(true) @@ -27,17 +27,17 @@ func NewAggregator() *Aggregator { } // SetEnabled enables or disables outcome recording. -func (a *Aggregator) SetEnabled(enabled bool) { +func (a *aggregator) SetEnabled(enabled bool) { a.enabled.Store(enabled) } // IsEnabled returns whether outcome recording is enabled. -func (a *Aggregator) IsEnabled() bool { +func (a *aggregator) IsEnabled() bool { return a.enabled.Load() } // RecordOutcome records a discarded event outcome. -func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { +func (a *aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { if !a.enabled.Load() || quantity <= 0 { return } @@ -56,7 +56,7 @@ func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Cate } // TakeReport atomically takes all accumulated outcomes and returns a ClientReport. -func (a *Aggregator) TakeReport() *ClientReport { +func (a *aggregator) TakeReport() *ClientReport { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/clientreport/global.go b/internal/clientreport/global.go index cac654bcf..f375671f4 100644 --- a/internal/clientreport/global.go +++ b/internal/clientreport/global.go @@ -7,29 +7,29 @@ import ( ) var ( - globalAggregator *Aggregator + globalAggregator *aggregator globalAggregatorOnce sync.Once ) -// Global returns the global client report aggregator singleton. -func Global() *Aggregator { +// global returns the global client report aggregator singleton. +func global() *aggregator { globalAggregatorOnce.Do(func() { - globalAggregator = NewAggregator() + globalAggregator = newAggregator() }) return globalAggregator } // Record is a convenience function for recording an outcome to the global aggregator. func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { - Global().RecordOutcome(reason, category, quantity) + global().RecordOutcome(reason, category, quantity) } // RecordOne is a convenience function for recording a single outcome to the global aggregator. func RecordOne(reason DiscardReason, category ratelimit.Category) { - Global().RecordOutcome(reason, category, 1) + global().RecordOutcome(reason, category, 1) } // TakeReport returns a client report for sending. func TakeReport() *ClientReport { - return Global().TakeReport() + return global().TakeReport() } From 8165f4e87a1d4cead7714dd10e020f03e2c75583 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:43:44 +0100 Subject: [PATCH 05/25] feat: add EnableClientReports option --- client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client.go b/client.go index a90bd1627..36014a714 100644 --- a/client.go +++ b/client.go @@ -249,6 +249,8 @@ type ClientOptions struct { EnableLogs bool // DisableMetrics controls when metrics should be emitted. DisableMetrics bool + // EnableClientReports controls when client reports should be emitted. + EnableClientReports bool // TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced. // Each element can be either: // - A single-element slice [code] for a specific status code From 5e70ac76a30c17c6dc6f3f50470890a75132a933 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:51:42 +0100 Subject: [PATCH 06/25] feat: handle client report enabling --- client.go | 1 + internal/clientreport/aggregator.go | 2 +- internal/clientreport/global.go | 11 ++++++++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/client.go b/client.go index 36014a714..b723fb599 100644 --- a/client.go +++ b/client.go @@ -408,6 +408,7 @@ func NewClient(options ClientOptions) (*Client, error) { client.batchMeter.Start() } + clientreport.SetEnabled(options.EnableClientReports) client.setupIntegrations() return &client, nil diff --git a/internal/clientreport/aggregator.go b/internal/clientreport/aggregator.go index c23372c19..5f6059a32 100644 --- a/internal/clientreport/aggregator.go +++ b/internal/clientreport/aggregator.go @@ -38,7 +38,7 @@ func (a *aggregator) IsEnabled() bool { // RecordOutcome records a discarded event outcome. func (a *aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { - if !a.enabled.Load() || quantity <= 0 { + if !a.IsEnabled() || quantity <= 0 { return } diff --git a/internal/clientreport/global.go b/internal/clientreport/global.go index f375671f4..6627fe6d9 100644 --- a/internal/clientreport/global.go +++ b/internal/clientreport/global.go @@ -19,17 +19,22 @@ func global() *aggregator { return globalAggregator } -// Record is a convenience function for recording an outcome to the global aggregator. +// SetEnabled enables or disables client report recording. +func SetEnabled(b bool) { + global().enabled.Store(b) +} + +// Record allows recording an outcome to the global aggregator. func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { global().RecordOutcome(reason, category, quantity) } -// RecordOne is a convenience function for recording a single outcome to the global aggregator. +// RecordOne allows recording a single outcome to the global aggregator. func RecordOne(reason DiscardReason, category ratelimit.Category) { global().RecordOutcome(reason, category, 1) } -// TakeReport returns a client report for sending. +// TakeReport returns the aggregated client report outcomes. func TakeReport() *ClientReport { return global().TakeReport() } From 5716d73226dfb13ff78a18b9abea1e65a1c2d301 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:53:01 +0100 Subject: [PATCH 07/25] chore: modify option to match the spec --- client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index b723fb599..ab821b028 100644 --- a/client.go +++ b/client.go @@ -249,8 +249,8 @@ type ClientOptions struct { EnableLogs bool // DisableMetrics controls when metrics should be emitted. DisableMetrics bool - // EnableClientReports controls when client reports should be emitted. - EnableClientReports bool + // SendClientReports controls when client reports should be emitted. + SendClientReports bool // TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced. // Each element can be either: // - A single-element slice [code] for a specific status code @@ -408,7 +408,7 @@ func NewClient(options ClientOptions) (*Client, error) { client.batchMeter.Start() } - clientreport.SetEnabled(options.EnableClientReports) + clientreport.SetEnabled(options.SendClientReports) client.setupIntegrations() return &client, nil From c944ef9f7930fb94737c73f59d9c01da83abcace Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:58:20 +0100 Subject: [PATCH 08/25] fix: invert error check --- internal/clientreport/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/clientreport/utils.go b/internal/clientreport/utils.go index 81ae260b5..f385f234c 100644 --- a/internal/clientreport/utils.go +++ b/internal/clientreport/utils.go @@ -10,7 +10,7 @@ func AttachToEnvelope(envelope *protocol.Envelope) { r := TakeReport() if r != nil { rItem, err := r.ToEnvelopeItem() - if err != nil { + if err == nil { envelope.AddItem(rItem) } else { debuglog.Printf("failed to serialize client report: %v, with err: %e", r, err) From 078f77208acf6fbce5d11791e58d7c5935600078 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:12:02 +0100 Subject: [PATCH 09/25] fix: misc fixes --- internal/clientreport/utils.go | 2 +- transport.go | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/clientreport/utils.go b/internal/clientreport/utils.go index f385f234c..6c4d353cf 100644 --- a/internal/clientreport/utils.go +++ b/internal/clientreport/utils.go @@ -13,7 +13,7 @@ func AttachToEnvelope(envelope *protocol.Envelope) { if err == nil { envelope.AddItem(rItem) } else { - debuglog.Printf("failed to serialize client report: %v, with err: %e", r, err) + debuglog.Printf("failed to serialize client report: %v, with err: %v", r, err) } } } diff --git a/transport.go b/transport.go index c1b351e83..8a2611e5a 100644 --- a/transport.go +++ b/transport.go @@ -571,21 +571,25 @@ func (t *HTTPTransport) worker() { if err := encodeEnvelopeHeader(enc, &envelopeHeader{ SentAt: time.Now(), Dsn: t.dsn.String(), + Sdk: map[string]string{ + "name": sdkIdentifier, + "version": SDKVersion, + }, }); err != nil { - break loop + continue } if err := encodeClientReport(enc, r); err != nil { - break loop + continue } req, err := getRequestFromEnvelope(context.Background(), t.dsn, &buf, sdkIdentifier, SDKVersion) if err != nil { - debuglog.Printf("There was an issue when creating the request: %e", err) - break loop + debuglog.Printf("There was an issue when creating the request: %v", err) + continue } response, err := t.client.Do(req) if err != nil { - debuglog.Printf("There was an issue with sending an event: %e", err) - break loop + debuglog.Printf("There was an issue with sending an event: %v", err) + continue } // Drain body up to a limit and close it, allowing the @@ -603,7 +607,7 @@ func (t *HTTPTransport) worker() { response, err := t.client.Do(item.request) if err != nil { - debuglog.Printf("There was an issue with sending an event: %e", err) + debuglog.Printf("There was an issue with sending an event: %v", err) continue } util.HandleHTTPResponse(response, item.eventIdentifier) From 7ca61ce9dc4ba5c582e6f1536d6d48c6c75544ed Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:28:33 +0100 Subject: [PATCH 10/25] fix: do not record server-side rejections --- internal/http/transport.go | 2 +- transport.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/http/transport.go b/internal/http/transport.go index 68c3db7c0..793fa0621 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -498,7 +498,7 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { identifier := util.EnvelopeIdentifier(envelope) success := util.HandleHTTPResponse(response, identifier) - if !success { + if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { clientreport.RecordOne(clientreport.ReasonSendError, category) } diff --git a/transport.go b/transport.go index 8a2611e5a..aea29203a 100644 --- a/transport.go +++ b/transport.go @@ -746,7 +746,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve return } success := util.HandleHTTPResponse(response, identifier) - if !success && response.StatusCode != http.StatusTooManyRequests { + if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { clientreport.RecordOne(clientreport.ReasonSendError, category) } From bbbdfd30a0e5209d5683fb668e933f92c94fe2fb Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:17:46 +0100 Subject: [PATCH 11/25] feat: record log bytes and span counts --- client.go | 32 ++++++++++++++- interfaces.go | 33 +++++++++++++++- internal/clientreport/reason.go | 5 +++ internal/clientreport/utils.go | 57 +++++++++++++++++++++++++++ internal/http/transport.go | 18 ++++----- internal/protocol/envelope.go | 16 ++++++++ internal/ratelimit/category.go | 3 ++ internal/telemetry/bucketed_buffer.go | 2 + internal/telemetry/ring_buffer.go | 4 +- internal/telemetry/scheduler.go | 8 +++- log.go | 1 + transport.go | 13 ++++++ 12 files changed, 176 insertions(+), 16 deletions(-) diff --git a/client.go b/client.go index ab821b028..b20fb634d 100644 --- a/client.go +++ b/client.go @@ -564,10 +564,12 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { } if client.options.BeforeSendLog != nil { + approxSize := log.ApproximateSize() log = client.options.BeforeSendLog(log) if log == nil { debuglog.Println("Log dropped due to BeforeSendLog callback.") clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryLog) + clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategoryLogByte, int64(approxSize)) return false } } @@ -582,6 +584,7 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { if !client.batchLogger.Send(log) { debuglog.Printf("Dropping log [%s]: buffer full", log.Level) clientreport.RecordOne(clientreport.ReasonBufferOverflow, ratelimit.CategoryLog) + clientreport.Record(clientreport.ReasonBufferOverflow, ratelimit.CategoryLogByte, int64(log.ApproximateSize())) return false } } @@ -836,11 +839,18 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod switch event.Type { case transactionType: if client.options.BeforeSendTransaction != nil { - if event = client.options.BeforeSendTransaction(event, hint); event == nil { + spanCountBefore := event.GetSpanCount() + event = client.options.BeforeSendTransaction(event, hint) + if event == nil { debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.") clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryTransaction) + clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategorySpan, 1) // count the transaction root itself return nil } + // Track spans removed by the callback + if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { + clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategorySpan, int64(droppedSpans)) + } } case checkInType: // not a default case, since we shouldn't apply BeforeSend on check-in events default: @@ -919,23 +929,43 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod for _, processor := range client.eventProcessors { id := event.EventID category := event.toCategory() + spanCountBefore := event.GetSpanCount() event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id) clientreport.RecordOne(clientreport.ReasonEventProcessor, category) + if category == ratelimit.CategoryTransaction { + clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) + } return nil } + // Track spans removed by the processor + if category == ratelimit.CategoryTransaction { + if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { + clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) + } + } } for _, processor := range globalEventProcessors { id := event.EventID category := event.toCategory() + spanCountBefore := event.GetSpanCount() event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id) clientreport.RecordOne(clientreport.ReasonEventProcessor, category) + if category == ratelimit.CategoryTransaction { + clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore+1)) + } return nil } + // Track spans removed by the processor + if category == ratelimit.CategoryTransaction { + if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { + clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) + } + } } return event diff --git a/interfaces.go b/interfaces.go index 5dd9a6472..453b7e8d1 100644 --- a/interfaces.go +++ b/interfaces.go @@ -513,7 +513,7 @@ func (e *Event) ToEnvelopeItem() (*protocol.EnvelopeItem, error) { var item *protocol.EnvelopeItem switch e.Type { case transactionType: - item = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeTransaction, eventBody) + item = protocol.NewTransactionItem(e.GetSpanCount(), eventBody) case checkInType: item = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeCheckIn, eventBody) case logEvent.Type: @@ -553,6 +553,15 @@ func (e *Event) GetDynamicSamplingContext() map[string]string { return trace } +// GetSpanCount returns the number of spans in the transaction including the transaction itself. It is used for client +// reports. Returns 0 for non-transaction events. +func (e *Event) GetSpanCount() int { + if e.Type != transactionType { + return 0 + } + return len(e.Spans) + 1 +} + // TODO: Event.Contexts map[string]interface{} => map[string]EventContext, // to prevent accidentally storing T when we mean *T. // For example, the TraceContext must be stored as *TraceContext to pick up the @@ -755,6 +764,28 @@ type Log struct { Severity int `json:"severity_number,omitempty"` Body string `json:"body"` Attributes map[string]Attribute `json:"attributes,omitempty"` + + // approximateSize is the pre-computed approximate size in bytes. + approximateSize int +} + +// ApproximateSize returns the pre-computed approximate serialized size in bytes. +func (l *Log) ApproximateSize() int { + return l.approximateSize +} + +// computeLogSize estimates the serialized JSON size of a log entry. +func computeLogSize(l *Log) int { + // Base overhead: timestamp, trace_id, level, severity, JSON structure + size := len(l.Body) + 60 + for k, v := range l.Attributes { + // Key + type/value JSON overhead + size += len(k) + 20 + if s, ok := v.Value.(string); ok { + size += len(s) + } + } + return size } // GetCategory returns the rate limit category for logs. diff --git a/internal/clientreport/reason.go b/internal/clientreport/reason.go index 6a916c8dd..a8929571d 100644 --- a/internal/clientreport/reason.go +++ b/internal/clientreport/reason.go @@ -26,6 +26,11 @@ const ( ReasonNetworkError DiscardReason = "network_error" // ReasonSendError indicates HTTP returned an error status (4xx, 5xx). + // + // The party that drops an envelope is responsible for counting the event (we skip outcomes that the server records + // `http.StatusTooManyRequests` 429). However, relay does not always record an outcome for oversized envelopes, so + // we accept the trade-off of double counting `http.StatusRequestEntityTooLarge` (413) codes, to record all events. + // For more details https://develop.sentry.dev/sdk/expected-features/#dealing-with-network-failures ReasonSendError DiscardReason = "send_error" // ReasonInternalError indicates an internal SDK error. diff --git a/internal/clientreport/utils.go b/internal/clientreport/utils.go index 6c4d353cf..f1d7ef48a 100644 --- a/internal/clientreport/utils.go +++ b/internal/clientreport/utils.go @@ -3,8 +3,65 @@ package clientreport import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" + "github.com/getsentry/sentry-go/internal/ratelimit" ) +// RecordForEnvelope records client report outcomes for all items in the envelope. +// It inspects envelope item headers to derive categories, span counts, and log byte sizes. +func RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope) { + for _, item := range envelope.Items { + if item == nil || item.Header == nil { + continue + } + switch item.Header.Type { + case protocol.EnvelopeItemTypeEvent: + RecordOne(reason, ratelimit.CategoryError) + case protocol.EnvelopeItemTypeTransaction: + RecordOne(reason, ratelimit.CategoryTransaction) + spanCount := int64(item.Header.SpanCount) + Record(reason, ratelimit.CategorySpan, spanCount) + case protocol.EnvelopeItemTypeLog: + if item.Header.ItemCount != nil { + Record(reason, ratelimit.CategoryLog, int64(*item.Header.ItemCount)) + } + if item.Header.Length != nil { + Record(reason, ratelimit.CategoryLogByte, int64(*item.Header.Length)) + } + case protocol.EnvelopeItemTypeCheckIn: + RecordOne(reason, ratelimit.CategoryMonitor) + case protocol.EnvelopeItemTypeAttachment, protocol.EnvelopeItemTypeClientReport: + // Skip — not reportable categories + } + } +} + +// RecordItem records outcomes for a telemetry item, including supplementary +// categories (span outcomes for transactions, byte size for logs). +func RecordItem(reason DiscardReason, item protocol.TelemetryItem) { + category := item.GetCategory() + RecordOne(reason, category) + + // Span outcomes for transactions + if category == ratelimit.CategoryTransaction { + type spanCounter interface{ GetSpanCount() int } + if sc, ok := item.(spanCounter); ok { + if count := sc.GetSpanCount(); count > 0 { + Record(reason, ratelimit.CategorySpan, int64(count)) + } + } + } + + // Byte size outcomes for logs + if category == ratelimit.CategoryLog { + type sizer interface{ ApproximateSize() int } + if s, ok := item.(sizer); ok { + if size := s.ApproximateSize(); size > 0 { + Record(reason, ratelimit.CategoryLogByte, int64(size)) + } + } + } +} + // AttachToEnvelope adds a client report to the envelope if the aggregator has outcomes available. func AttachToEnvelope(envelope *protocol.Envelope) { r := TakeReport() diff --git a/internal/http/transport.go b/internal/http/transport.go index 793fa0621..7fc2b900d 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -210,14 +210,14 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p clientreport.AttachToEnvelope(envelope) category := categoryFromEnvelope(envelope) if t.disabled(category) { - clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) + clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) return nil } request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("There was an issue creating the request: %v", err) - clientreport.RecordOne(clientreport.ReasonInternalError, category) + clientreport.RecordForEnvelope(clientreport.ReasonInternalError, envelope) return err } identifier := util.EnvelopeIdentifier(envelope) @@ -231,7 +231,7 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p response, err := t.client.Do(request) if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) - clientreport.RecordOne(clientreport.ReasonNetworkError, category) + clientreport.RecordForEnvelope(clientreport.ReasonNetworkError, envelope) return err } util.HandleHTTPResponse(response, identifier) @@ -369,7 +369,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { - clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) + clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) return nil } @@ -385,7 +385,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { return nil default: atomic.AddInt64(&t.droppedCount, 1) - clientreport.RecordOne(clientreport.ReasonQueueOverflow, category) + clientreport.RecordForEnvelope(clientreport.ReasonQueueOverflow, envelope) return ErrTransportQueueFull } } @@ -474,7 +474,7 @@ func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) { func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { - clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) + clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) return false } @@ -484,14 +484,14 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("Failed to create request from envelope: %v", err) - clientreport.RecordOne(clientreport.ReasonInternalError, category) + clientreport.RecordForEnvelope(clientreport.ReasonInternalError, envelope) return false } response, err := t.client.Do(request) if err != nil { debuglog.Printf("HTTP request failed: %v", err) - clientreport.RecordOne(clientreport.ReasonNetworkError, category) + clientreport.RecordForEnvelope(clientreport.ReasonNetworkError, envelope) return false } defer response.Body.Close() @@ -499,7 +499,7 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { identifier := util.EnvelopeIdentifier(envelope) success := util.HandleHTTPResponse(response, identifier) if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { - clientreport.RecordOne(clientreport.ReasonSendError, category) + clientreport.RecordForEnvelope(clientreport.ReasonSendError, envelope) } t.mu.Lock() diff --git a/internal/protocol/envelope.go b/internal/protocol/envelope.go index fa03ed57e..b3f4fb093 100644 --- a/internal/protocol/envelope.go +++ b/internal/protocol/envelope.go @@ -69,6 +69,9 @@ type EnvelopeItemHeader struct { // ItemCount is the number of items in a batch (used for logs) ItemCount *int `json:"item_count,omitempty"` + + // SpanCount is the number of spans in a transaction (used for client reports) + SpanCount int `json:"-"` } // EnvelopeItem represents a single item or batch within an envelope. @@ -188,6 +191,19 @@ func NewEnvelopeItem(itemType EnvelopeItemType, payload []byte) *EnvelopeItem { } } +// NewTransactionItem creates a new envelope item including the span count of the transaction. +func NewTransactionItem(spanCount int, payload []byte) *EnvelopeItem { + length := len(payload) + return &EnvelopeItem{ + Header: &EnvelopeItemHeader{ + Type: EnvelopeItemTypeTransaction, + Length: &length, + SpanCount: spanCount, + }, + Payload: payload, + } +} + // NewAttachmentItem creates a new envelope item for an attachment. // Parameters: filename, contentType, payload. func NewAttachmentItem(filename, contentType string, payload []byte) *EnvelopeItem { diff --git a/internal/ratelimit/category.go b/internal/ratelimit/category.go index e9d1d187d..60dbfeccc 100644 --- a/internal/ratelimit/category.go +++ b/internal/ratelimit/category.go @@ -20,6 +20,7 @@ const ( CategoryAll Category = "" // Special category for empty categories (applies to all) CategoryError Category = "error" CategoryTransaction Category = "transaction" + CategorySpan Category = "span" CategoryLog Category = "log_item" CategoryLogByte Category = "log_byte" CategoryMonitor Category = "monitor" @@ -46,6 +47,8 @@ func (c Category) String() string { return "CategoryError" case CategoryTransaction: return "CategoryTransaction" + case CategorySpan: + return "CategorySpan" case CategoryLog: return "CategoryLog" case CategoryLogByte: diff --git a/internal/telemetry/bucketed_buffer.go b/internal/telemetry/bucketed_buffer.go index 75e621e55..b55a1ba27 100644 --- a/internal/telemetry/bucketed_buffer.go +++ b/internal/telemetry/bucketed_buffer.go @@ -5,6 +5,7 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/ratelimit" ) @@ -138,6 +139,7 @@ func (b *BucketedBuffer[T]) offerToBucket(item T, traceID string) bool { } func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool { + clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldestBucket := b.buckets[b.head] diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 49eb7b651..489e3b4b2 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -77,6 +77,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { return true } + clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldItem := b.items[b.head] @@ -85,7 +86,6 @@ func (b *RingBuffer[T]) Offer(item T) bool { b.tail = (b.tail + 1) % b.capacity atomic.AddInt64(&b.dropped, 1) - clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(oldItem, "buffer_full_drop_oldest") } @@ -93,7 +93,6 @@ func (b *RingBuffer[T]) Offer(item T) bool { case OverflowPolicyDropNewest: atomic.AddInt64(&b.dropped, 1) - clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(item, "buffer_full_drop_newest") } @@ -101,7 +100,6 @@ func (b *RingBuffer[T]) Offer(item T) bool { default: atomic.AddInt64(&b.dropped, 1) - clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) if b.onDropped != nil { b.onDropped(item, "unknown_overflow_policy") } diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index 9b7b08ea1..b7ffb8e87 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -239,11 +239,15 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category } if s.isRateLimited(category) { - clientreport.Record(clientreport.ReasonRateLimitBackoff, category, int64(len(items))) + for _, item := range items { + clientreport.RecordItem(clientreport.ReasonQueueOverflow, item) + } return } if !s.transport.HasCapacity() { - clientreport.Record(clientreport.ReasonQueueOverflow, category, int64(len(items))) + for _, item := range items { + clientreport.RecordItem(clientreport.ReasonQueueOverflow, item) + } return } diff --git a/log.go b/log.go index 2f9405423..34c68f5fe 100644 --- a/log.go +++ b/log.go @@ -155,6 +155,7 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me Body: fmt.Sprintf(message, args...), Attributes: attrs, } + log.approximateSize = computeLogSize(log) l.client.captureLog(log, scope) diff --git a/transport.go b/transport.go index aea29203a..043e5cde9 100644 --- a/transport.go +++ b/transport.go @@ -189,6 +189,12 @@ func encodeEnvelopeMetrics(enc *json.Encoder, count int, body json.RawMessage) e return err } +func recordSpanOutcome(reason clientreport.DiscardReason, event *Event) { + if event.Type == transactionType { + clientreport.Record(reason, ratelimit.CategorySpan, int64(event.GetSpanCount())) + } +} + // envelopeHeader represents the header of a Sentry envelope. type envelopeHeader struct { EventID EventID `json:"event_id,omitempty"` @@ -423,12 +429,14 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) if t.disabled(category) { clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) + recordSpanOutcome(clientreport.ReasonRateLimitBackoff, event) return } request, err := getRequestFromEvent(ctx, event, t.dsn) if err != nil { clientreport.RecordOne(clientreport.ReasonInternalError, category) + recordSpanOutcome(clientreport.ReasonInternalError, event) return } @@ -462,6 +470,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) default: debuglog.Println("Event dropped due to transport buffer being full.") clientreport.RecordOne(clientreport.ReasonQueueOverflow, category) + recordSpanOutcome(clientreport.ReasonQueueOverflow, event) } t.buffer <- b @@ -722,12 +731,14 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve category := event.toCategory() if t.disabled(category) { clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) + recordSpanOutcome(clientreport.ReasonRateLimitBackoff, event) return } request, err := getRequestFromEvent(ctx, event, t.dsn) if err != nil { clientreport.RecordOne(clientreport.ReasonInternalError, category) + recordSpanOutcome(clientreport.ReasonInternalError, event) return } @@ -743,11 +754,13 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) clientreport.RecordOne(clientreport.ReasonNetworkError, category) + recordSpanOutcome(clientreport.ReasonNetworkError, event) return } success := util.HandleHTTPResponse(response, identifier) if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { clientreport.RecordOne(clientreport.ReasonSendError, category) + recordSpanOutcome(clientreport.ReasonSendError, event) } t.mu.Lock() From bfd6e170e57ece8680b91db3965029a57cd43182 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:20:43 +0100 Subject: [PATCH 12/25] fix: ignore approximateSize field --- log_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/log_test.go b/log_test.go index 2713ce441..8866a834d 100644 --- a/log_test.go +++ b/log_test.go @@ -194,6 +194,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) { opts := cmp.Options{ cmpopts.IgnoreFields(Log{}, "Timestamp"), + cmpopts.IgnoreFields(Log{}, "approximateSize"), } gotEvents := mockTransport.Events() @@ -338,6 +339,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) { opts := cmp.Options{ cmpopts.IgnoreFields(Log{}, "Timestamp"), + cmpopts.IgnoreFields(Log{}, "approximateSize"), } gotEvents := mockTransport.Events() @@ -424,6 +426,7 @@ func Test_sentryLogger_Write(t *testing.T) { opts := cmp.Options{ cmpopts.IgnoreFields(Log{}, "Timestamp"), + cmpopts.IgnoreFields(Log{}, "approximateSize"), } if diff := cmp.Diff(wantLogs, event.Logs, opts); diff != "" { t.Errorf("Logs mismatch (-want +got):\n%s", diff) From 6021054709144f748927352e918da4e8d5474ebe Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:24:58 +0100 Subject: [PATCH 13/25] enable client reports by default --- client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index b20fb634d..1a4e57ee9 100644 --- a/client.go +++ b/client.go @@ -249,8 +249,8 @@ type ClientOptions struct { EnableLogs bool // DisableMetrics controls when metrics should be emitted. DisableMetrics bool - // SendClientReports controls when client reports should be emitted. - SendClientReports bool + // DisableClientReports controls when client reports should be emitted. + DisableClientReports bool // TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced. // Each element can be either: // - A single-element slice [code] for a specific status code @@ -408,7 +408,7 @@ func NewClient(options ClientOptions) (*Client, error) { client.batchMeter.Start() } - clientreport.SetEnabled(options.SendClientReports) + clientreport.SetEnabled(!options.DisableClientReports) client.setupIntegrations() return &client, nil From dd61bf0eabb411ec170a07ca827a54d4b190303f Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:57:21 +0100 Subject: [PATCH 14/25] record outcome for scope event processor --- scope.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scope.go b/scope.go index c8cc1ca42..84e4a9c2d 100644 --- a/scope.go +++ b/scope.go @@ -8,7 +8,9 @@ import ( "sync" "time" + "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/getsentry/sentry-go/internal/ratelimit" ) // Scope holds contextual data for the current scope. @@ -473,9 +475,15 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) for _, processor := range scope.eventProcessors { id := event.EventID + category := event.toCategory() + spanCountBefore := event.GetSpanCount() event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id) + clientreport.RecordOne(clientreport.ReasonEventProcessor, category) + if category == ratelimit.CategoryTransaction { + clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) + } return nil } } From 670a76b9b6430d7318842f95ea474cb5fc9724e8 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:32:30 +0100 Subject: [PATCH 15/25] chore: rename client report package --- client.go | 2 +- internal/http/transport.go | 2 +- .../{clientreport => report}/aggregator.go | 20 +++++++++---------- internal/{clientreport => report}/global.go | 14 ++++++------- internal/{clientreport => report}/outcome.go | 2 +- internal/{clientreport => report}/reason.go | 2 +- internal/{clientreport => report}/report.go | 2 +- internal/{clientreport => report}/utils.go | 4 ++-- internal/telemetry/bucketed_buffer.go | 2 +- internal/telemetry/ring_buffer.go | 2 +- internal/telemetry/scheduler.go | 2 +- scope.go | 2 +- transport.go | 2 +- 13 files changed, 29 insertions(+), 29 deletions(-) rename internal/{clientreport => report}/aggregator.go (78%) rename internal/{clientreport => report}/global.go (68%) rename internal/{clientreport => report}/outcome.go (95%) rename internal/{clientreport => report}/reason.go (98%) rename internal/{clientreport => report}/report.go (96%) rename internal/{clientreport => report}/utils.go (97%) diff --git a/client.go b/client.go index 1a4e57ee9..cf853f093 100644 --- a/client.go +++ b/client.go @@ -13,12 +13,12 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debug" "github.com/getsentry/sentry-go/internal/debuglog" httpInternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/telemetry" ) diff --git a/internal/http/transport.go b/internal/http/transport.go index 7fc2b900d..e2c671435 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -14,10 +14,10 @@ import ( "sync/atomic" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" ) diff --git a/internal/clientreport/aggregator.go b/internal/report/aggregator.go similarity index 78% rename from internal/clientreport/aggregator.go rename to internal/report/aggregator.go index 5f6059a32..5b970a4e0 100644 --- a/internal/clientreport/aggregator.go +++ b/internal/report/aggregator.go @@ -1,4 +1,4 @@ -package clientreport +package report import ( "sync" @@ -8,18 +8,18 @@ import ( "github.com/getsentry/sentry-go/internal/ratelimit" ) -// aggregator collects discarded event outcomes for client reports. +// Aggregator collects discarded event outcomes for client reports. // Uses atomic operations to be safe for concurrent use. -type aggregator struct { +type Aggregator struct { mu sync.Mutex outcomes map[OutcomeKey]*atomic.Int64 enabled atomic.Bool } -// newAggregator creates a new client report aggregator. -func newAggregator() *aggregator { - a := &aggregator{ +// NewAggregator creates a new client report Aggregator. +func NewAggregator() *Aggregator { + a := &Aggregator{ outcomes: make(map[OutcomeKey]*atomic.Int64), } a.enabled.Store(true) @@ -27,17 +27,17 @@ func newAggregator() *aggregator { } // SetEnabled enables or disables outcome recording. -func (a *aggregator) SetEnabled(enabled bool) { +func (a *Aggregator) SetEnabled(enabled bool) { a.enabled.Store(enabled) } // IsEnabled returns whether outcome recording is enabled. -func (a *aggregator) IsEnabled() bool { +func (a *Aggregator) IsEnabled() bool { return a.enabled.Load() } // RecordOutcome records a discarded event outcome. -func (a *aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { +func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { if !a.IsEnabled() || quantity <= 0 { return } @@ -56,7 +56,7 @@ func (a *aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Cate } // TakeReport atomically takes all accumulated outcomes and returns a ClientReport. -func (a *aggregator) TakeReport() *ClientReport { +func (a *Aggregator) TakeReport() *ClientReport { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/clientreport/global.go b/internal/report/global.go similarity index 68% rename from internal/clientreport/global.go rename to internal/report/global.go index 6627fe6d9..79c0ce7bc 100644 --- a/internal/clientreport/global.go +++ b/internal/report/global.go @@ -1,4 +1,4 @@ -package clientreport +package report import ( "sync" @@ -7,14 +7,14 @@ import ( ) var ( - globalAggregator *aggregator + globalAggregator *Aggregator globalAggregatorOnce sync.Once ) -// global returns the global client report aggregator singleton. -func global() *aggregator { +// global returns the global client report Aggregator singleton. +func global() *Aggregator { globalAggregatorOnce.Do(func() { - globalAggregator = newAggregator() + globalAggregator = NewAggregator() }) return globalAggregator } @@ -24,12 +24,12 @@ func SetEnabled(b bool) { global().enabled.Store(b) } -// Record allows recording an outcome to the global aggregator. +// Record allows recording an outcome to the global Aggregator. func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { global().RecordOutcome(reason, category, quantity) } -// RecordOne allows recording a single outcome to the global aggregator. +// RecordOne allows recording a single outcome to the global Aggregator. func RecordOne(reason DiscardReason, category ratelimit.Category) { global().RecordOutcome(reason, category, 1) } diff --git a/internal/clientreport/outcome.go b/internal/report/outcome.go similarity index 95% rename from internal/clientreport/outcome.go rename to internal/report/outcome.go index a65c6b2ee..63d6eaf26 100644 --- a/internal/clientreport/outcome.go +++ b/internal/report/outcome.go @@ -1,4 +1,4 @@ -package clientreport +package report import ( "github.com/getsentry/sentry-go/internal/ratelimit" diff --git a/internal/clientreport/reason.go b/internal/report/reason.go similarity index 98% rename from internal/clientreport/reason.go rename to internal/report/reason.go index a8929571d..5a1d21835 100644 --- a/internal/clientreport/reason.go +++ b/internal/report/reason.go @@ -1,4 +1,4 @@ -package clientreport +package report // DiscardReason represents why an item was discarded. type DiscardReason string diff --git a/internal/clientreport/report.go b/internal/report/report.go similarity index 96% rename from internal/clientreport/report.go rename to internal/report/report.go index 9d482cb4e..9d1e720c6 100644 --- a/internal/clientreport/report.go +++ b/internal/report/report.go @@ -1,4 +1,4 @@ -package clientreport +package report import ( "encoding/json" diff --git a/internal/clientreport/utils.go b/internal/report/utils.go similarity index 97% rename from internal/clientreport/utils.go rename to internal/report/utils.go index f1d7ef48a..cd8b8aa82 100644 --- a/internal/clientreport/utils.go +++ b/internal/report/utils.go @@ -1,4 +1,4 @@ -package clientreport +package report import ( "github.com/getsentry/sentry-go/internal/debuglog" @@ -62,7 +62,7 @@ func RecordItem(reason DiscardReason, item protocol.TelemetryItem) { } } -// AttachToEnvelope adds a client report to the envelope if the aggregator has outcomes available. +// AttachToEnvelope adds a client report to the envelope if the Aggregator has outcomes available. func AttachToEnvelope(envelope *protocol.Envelope) { r := TakeReport() if r != nil { diff --git a/internal/telemetry/bucketed_buffer.go b/internal/telemetry/bucketed_buffer.go index b55a1ba27..bbda6679d 100644 --- a/internal/telemetry/bucketed_buffer.go +++ b/internal/telemetry/bucketed_buffer.go @@ -5,8 +5,8 @@ import ( "sync/atomic" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" ) const ( diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 489e3b4b2..9d86bb174 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -5,8 +5,8 @@ import ( "sync/atomic" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" ) const defaultCapacity = 100 diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index b7ffb8e87..d332cd299 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" ) const ( diff --git a/scope.go b/scope.go index 84e4a9c2d..23dfbd40d 100644 --- a/scope.go +++ b/scope.go @@ -8,9 +8,9 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" ) // Scope holds contextual data for the current scope. diff --git a/transport.go b/transport.go index 043e5cde9..4a45ae516 100644 --- a/transport.go +++ b/transport.go @@ -13,11 +13,11 @@ import ( "sync" "time" - "github.com/getsentry/sentry-go/internal/clientreport" "github.com/getsentry/sentry-go/internal/debuglog" httpinternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" + clientreport "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" ) From 2fdbb67c850d4f2db30450fc7e9e24620b7d1172 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:03:16 +0100 Subject: [PATCH 16/25] feat: make client reports work per-client --- client.go | 60 +++++++------ internal/http/transport.go | 24 +++--- internal/report/aggregator.go | 98 ++++++++++++++++++---- internal/report/global.go | 40 --------- internal/report/utils.go | 76 ----------------- internal/sdk/options.go | 29 +++++++ internal/telemetry/bucketed_buffer.go | 9 +- internal/telemetry/bucketed_buffer_test.go | 24 +++--- internal/telemetry/processor.go | 4 +- internal/telemetry/ring_buffer.go | 10 ++- internal/telemetry/scheduler.go | 19 +++-- scope.go | 10 ++- transport.go | 52 ++++++------ transport_test.go | 12 +-- 14 files changed, 238 insertions(+), 229 deletions(-) delete mode 100644 internal/report/global.go delete mode 100644 internal/report/utils.go create mode 100644 internal/sdk/options.go diff --git a/client.go b/client.go index cf853f093..4c8a42784 100644 --- a/client.go +++ b/client.go @@ -18,7 +18,8 @@ import ( httpInternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/sdk" "github.com/getsentry/sentry-go/internal/telemetry" ) @@ -289,6 +290,7 @@ type Client struct { batchLogger *logBatchProcessor batchMeter *metricBatchProcessor telemetryProcessor *telemetry.Processor + reporter *report.Aggregator } // NewClient creates and returns an instance of Client configured using @@ -392,6 +394,10 @@ func NewClient(options ClientOptions) (*Client, error) { sdkVersion: SDKVersion, } + if !options.DisableClientReports { + client.reporter = report.NewAggregator() + } + client.setupTransport() // noop Telemetry Buffers and Processor fow now @@ -408,7 +414,6 @@ func NewClient(options ClientOptions) (*Client, error) { client.batchMeter.Start() } - clientreport.SetEnabled(!options.DisableClientReports) client.setupIntegrations() return &client, nil @@ -422,7 +427,9 @@ func (client *Client) setupTransport() { if opts.Dsn == "" { transport = new(noopTransport) } else { - transport = NewHTTPTransport() + t := NewHTTPTransport() + t.reporter = client.reporter + transport = t } } @@ -465,12 +472,13 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused }) client.Transport = &internalAsyncTransportAdapter{transport: transport} + reportOpt := sdk.WithReporter(client.reporter) buffers := map[ratelimit.Category]telemetry.Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second), - ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second), + ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), + ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), + ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, reportOpt), + ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), + ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, reportOpt), } sdkInfo := &protocol.SdkInfo{ @@ -478,7 +486,7 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused Version: client.sdkVersion, } - client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo) + client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo, sdk.WithReporter(client.reporter)) } func (client *Client) setupIntegrations() { @@ -568,8 +576,8 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { log = client.options.BeforeSendLog(log) if log == nil { debuglog.Println("Log dropped due to BeforeSendLog callback.") - clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryLog) - clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategoryLogByte, int64(approxSize)) + client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryLog) + client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategoryLogByte, int64(approxSize)) return false } } @@ -583,8 +591,8 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { } else if client.batchLogger != nil { if !client.batchLogger.Send(log) { debuglog.Printf("Dropping log [%s]: buffer full", log.Level) - clientreport.RecordOne(clientreport.ReasonBufferOverflow, ratelimit.CategoryLog) - clientreport.Record(clientreport.ReasonBufferOverflow, ratelimit.CategoryLogByte, int64(log.ApproximateSize())) + client.reporter.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryLog) + client.reporter.Record(report.ReasonBufferOverflow, ratelimit.CategoryLogByte, int64(log.ApproximateSize())) return false } } @@ -601,7 +609,7 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { metric = client.options.BeforeSendMetric(metric) if metric == nil { debuglog.Println("Metric dropped due to BeforeSendMetric callback.") - clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryTraceMetric) + client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTraceMetric) return false } } @@ -615,7 +623,7 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { } else if client.batchMeter != nil { if !client.batchMeter.Send(metric) { debuglog.Printf("Dropping metric %q: buffer full", metric.Name) - clientreport.RecordOne(clientreport.ReasonBufferOverflow, ratelimit.CategoryTraceMetric) + client.reporter.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryTraceMetric) return false } } @@ -824,7 +832,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod // (errors, messages) are sampled here. Does not apply to check-ins. if event.Type != transactionType && event.Type != checkInType && !sample(client.options.SampleRate) { debuglog.Println("Event dropped due to SampleRate hit.") - clientreport.RecordOne(clientreport.ReasonSampleRate, ratelimit.CategoryError) + client.reporter.RecordOne(report.ReasonSampleRate, ratelimit.CategoryError) return nil } @@ -843,13 +851,13 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod event = client.options.BeforeSendTransaction(event, hint) if event == nil { debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.") - clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryTransaction) - clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategorySpan, 1) // count the transaction root itself + client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTransaction) + client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, 1) // count the transaction root itself return nil } // Track spans removed by the callback if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { - clientreport.Record(clientreport.ReasonBeforeSend, ratelimit.CategorySpan, int64(droppedSpans)) + client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, int64(droppedSpans)) } } case checkInType: // not a default case, since we shouldn't apply BeforeSend on check-in events @@ -857,7 +865,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod if client.options.BeforeSend != nil { if event = client.options.BeforeSend(event, hint); event == nil { debuglog.Println("Event dropped due to BeforeSend callback.") - clientreport.RecordOne(clientreport.ReasonBeforeSend, ratelimit.CategoryError) + client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryError) return nil } } @@ -933,16 +941,16 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id) - clientreport.RecordOne(clientreport.ReasonEventProcessor, category) + client.reporter.RecordOne(report.ReasonEventProcessor, category) if category == ratelimit.CategoryTransaction { - clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) } return nil } // Track spans removed by the processor if category == ratelimit.CategoryTransaction { if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { - clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) } } } @@ -954,16 +962,16 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id) - clientreport.RecordOne(clientreport.ReasonEventProcessor, category) + client.reporter.RecordOne(report.ReasonEventProcessor, category) if category == ratelimit.CategoryTransaction { - clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore+1)) + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore+1)) } return nil } // Track spans removed by the processor if category == ratelimit.CategoryTransaction { if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { - clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) } } } diff --git a/internal/http/transport.go b/internal/http/transport.go index e2c671435..2e8cdf1ab 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -17,7 +17,7 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" ) @@ -148,6 +148,7 @@ type SyncTransport struct { dsn *protocol.Dsn client *http.Client transport http.RoundTripper + reporter *report.Aggregator mu sync.Mutex limits ratelimit.Map @@ -207,17 +208,17 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p } // the sync transport needs to attach client reports when available - clientreport.AttachToEnvelope(envelope) + t.reporter.AttachToEnvelope(envelope) category := categoryFromEnvelope(envelope) if t.disabled(category) { - clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) + t.reporter.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope) return nil } request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("There was an issue creating the request: %v", err) - clientreport.RecordForEnvelope(clientreport.ReasonInternalError, envelope) + t.reporter.RecordForEnvelope(report.ReasonInternalError, envelope) return err } identifier := util.EnvelopeIdentifier(envelope) @@ -231,7 +232,7 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p response, err := t.client.Do(request) if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) - clientreport.RecordForEnvelope(clientreport.ReasonNetworkError, envelope) + t.reporter.RecordForEnvelope(report.ReasonNetworkError, envelope) return err } util.HandleHTTPResponse(response, identifier) @@ -274,6 +275,7 @@ type AsyncTransport struct { dsn *protocol.Dsn client *http.Client transport http.RoundTripper + reporter *report.Aggregator queue chan *protocol.Envelope @@ -369,7 +371,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { - clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) + t.reporter.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope) return nil } @@ -385,7 +387,7 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { return nil default: atomic.AddInt64(&t.droppedCount, 1) - clientreport.RecordForEnvelope(clientreport.ReasonQueueOverflow, envelope) + t.reporter.RecordForEnvelope(report.ReasonQueueOverflow, envelope) return ErrTransportQueueFull } } @@ -474,7 +476,7 @@ func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) { func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { - clientreport.RecordForEnvelope(clientreport.ReasonRateLimitBackoff, envelope) + t.reporter.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope) return false } @@ -484,14 +486,14 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { debuglog.Printf("Failed to create request from envelope: %v", err) - clientreport.RecordForEnvelope(clientreport.ReasonInternalError, envelope) + t.reporter.RecordForEnvelope(report.ReasonInternalError, envelope) return false } response, err := t.client.Do(request) if err != nil { debuglog.Printf("HTTP request failed: %v", err) - clientreport.RecordForEnvelope(clientreport.ReasonNetworkError, envelope) + t.reporter.RecordForEnvelope(report.ReasonNetworkError, envelope) return false } defer response.Body.Close() @@ -499,7 +501,7 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { identifier := util.EnvelopeIdentifier(envelope) success := util.HandleHTTPResponse(response, identifier) if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { - clientreport.RecordForEnvelope(clientreport.ReasonSendError, envelope) + t.reporter.RecordForEnvelope(report.ReasonSendError, envelope) } t.mu.Lock() diff --git a/internal/report/aggregator.go b/internal/report/aggregator.go index 5b970a4e0..c5ea7ccab 100644 --- a/internal/report/aggregator.go +++ b/internal/report/aggregator.go @@ -5,6 +5,8 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" ) @@ -13,8 +15,6 @@ import ( type Aggregator struct { mu sync.Mutex outcomes map[OutcomeKey]*atomic.Int64 - - enabled atomic.Bool } // NewAggregator creates a new client report Aggregator. @@ -22,23 +22,12 @@ func NewAggregator() *Aggregator { a := &Aggregator{ outcomes: make(map[OutcomeKey]*atomic.Int64), } - a.enabled.Store(true) return a } -// SetEnabled enables or disables outcome recording. -func (a *Aggregator) SetEnabled(enabled bool) { - a.enabled.Store(enabled) -} - -// IsEnabled returns whether outcome recording is enabled. -func (a *Aggregator) IsEnabled() bool { - return a.enabled.Load() -} - -// RecordOutcome records a discarded event outcome. -func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Category, quantity int64) { - if !a.IsEnabled() || quantity <= 0 { +// Record records a discarded event outcome. +func (a *Aggregator) Record(reason DiscardReason, category ratelimit.Category, quantity int64) { + if a == nil || quantity <= 0 { return } @@ -55,8 +44,16 @@ func (a *Aggregator) RecordOutcome(reason DiscardReason, category ratelimit.Cate counter.Add(quantity) } +// RecordOne is a helper method to record one discarded event outcome. +func (a *Aggregator) RecordOne(reason DiscardReason, category ratelimit.Category) { + a.Record(reason, category, 1) +} + // TakeReport atomically takes all accumulated outcomes and returns a ClientReport. func (a *Aggregator) TakeReport() *ClientReport { + if a == nil { + return nil + } a.mu.Lock() defer a.mu.Unlock() @@ -92,3 +89,72 @@ func (a *Aggregator) TakeReport() *ClientReport { DiscardedEvents: events, } } + +// RecordForEnvelope records client report outcomes for all items in the envelope. +// It inspects envelope item headers to derive categories, span counts, and log byte sizes. +func (a *Aggregator) RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope) { + for _, item := range envelope.Items { + if item == nil || item.Header == nil { + continue + } + switch item.Header.Type { + case protocol.EnvelopeItemTypeEvent: + a.RecordOne(reason, ratelimit.CategoryError) + case protocol.EnvelopeItemTypeTransaction: + a.RecordOne(reason, ratelimit.CategoryTransaction) + spanCount := int64(item.Header.SpanCount) + a.Record(reason, ratelimit.CategorySpan, spanCount) + case protocol.EnvelopeItemTypeLog: + if item.Header.ItemCount != nil { + a.Record(reason, ratelimit.CategoryLog, int64(*item.Header.ItemCount)) + } + if item.Header.Length != nil { + a.Record(reason, ratelimit.CategoryLogByte, int64(*item.Header.Length)) + } + case protocol.EnvelopeItemTypeCheckIn: + a.RecordOne(reason, ratelimit.CategoryMonitor) + case protocol.EnvelopeItemTypeAttachment, protocol.EnvelopeItemTypeClientReport: + // Skip — not reportable categories + } + } +} + +// RecordItem records outcomes for a telemetry item, including supplementary +// categories (span outcomes for transactions, byte size for logs). +func (a *Aggregator) RecordItem(reason DiscardReason, item protocol.TelemetryItem) { + category := item.GetCategory() + a.RecordOne(reason, category) + + // Span outcomes for transactions + if category == ratelimit.CategoryTransaction { + type spanCounter interface{ GetSpanCount() int } + if sc, ok := item.(spanCounter); ok { + if count := sc.GetSpanCount(); count > 0 { + a.Record(reason, ratelimit.CategorySpan, int64(count)) + } + } + } + + // Byte size outcomes for logs + if category == ratelimit.CategoryLog { + type sizer interface{ ApproximateSize() int } + if s, ok := item.(sizer); ok { + if size := s.ApproximateSize(); size > 0 { + a.Record(reason, ratelimit.CategoryLogByte, int64(size)) + } + } + } +} + +// AttachToEnvelope adds a client report to the envelope if the Aggregator has outcomes available. +func (a *Aggregator) AttachToEnvelope(envelope *protocol.Envelope) { + r := a.TakeReport() + if r != nil { + rItem, err := r.ToEnvelopeItem() + if err == nil { + envelope.AddItem(rItem) + } else { + debuglog.Printf("failed to serialize client report: %v, with err: %v", r, err) + } + } +} diff --git a/internal/report/global.go b/internal/report/global.go deleted file mode 100644 index 79c0ce7bc..000000000 --- a/internal/report/global.go +++ /dev/null @@ -1,40 +0,0 @@ -package report - -import ( - "sync" - - "github.com/getsentry/sentry-go/internal/ratelimit" -) - -var ( - globalAggregator *Aggregator - globalAggregatorOnce sync.Once -) - -// global returns the global client report Aggregator singleton. -func global() *Aggregator { - globalAggregatorOnce.Do(func() { - globalAggregator = NewAggregator() - }) - return globalAggregator -} - -// SetEnabled enables or disables client report recording. -func SetEnabled(b bool) { - global().enabled.Store(b) -} - -// Record allows recording an outcome to the global Aggregator. -func Record(reason DiscardReason, category ratelimit.Category, quantity int64) { - global().RecordOutcome(reason, category, quantity) -} - -// RecordOne allows recording a single outcome to the global Aggregator. -func RecordOne(reason DiscardReason, category ratelimit.Category) { - global().RecordOutcome(reason, category, 1) -} - -// TakeReport returns the aggregated client report outcomes. -func TakeReport() *ClientReport { - return global().TakeReport() -} diff --git a/internal/report/utils.go b/internal/report/utils.go deleted file mode 100644 index cd8b8aa82..000000000 --- a/internal/report/utils.go +++ /dev/null @@ -1,76 +0,0 @@ -package report - -import ( - "github.com/getsentry/sentry-go/internal/debuglog" - "github.com/getsentry/sentry-go/internal/protocol" - "github.com/getsentry/sentry-go/internal/ratelimit" -) - -// RecordForEnvelope records client report outcomes for all items in the envelope. -// It inspects envelope item headers to derive categories, span counts, and log byte sizes. -func RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope) { - for _, item := range envelope.Items { - if item == nil || item.Header == nil { - continue - } - switch item.Header.Type { - case protocol.EnvelopeItemTypeEvent: - RecordOne(reason, ratelimit.CategoryError) - case protocol.EnvelopeItemTypeTransaction: - RecordOne(reason, ratelimit.CategoryTransaction) - spanCount := int64(item.Header.SpanCount) - Record(reason, ratelimit.CategorySpan, spanCount) - case protocol.EnvelopeItemTypeLog: - if item.Header.ItemCount != nil { - Record(reason, ratelimit.CategoryLog, int64(*item.Header.ItemCount)) - } - if item.Header.Length != nil { - Record(reason, ratelimit.CategoryLogByte, int64(*item.Header.Length)) - } - case protocol.EnvelopeItemTypeCheckIn: - RecordOne(reason, ratelimit.CategoryMonitor) - case protocol.EnvelopeItemTypeAttachment, protocol.EnvelopeItemTypeClientReport: - // Skip — not reportable categories - } - } -} - -// RecordItem records outcomes for a telemetry item, including supplementary -// categories (span outcomes for transactions, byte size for logs). -func RecordItem(reason DiscardReason, item protocol.TelemetryItem) { - category := item.GetCategory() - RecordOne(reason, category) - - // Span outcomes for transactions - if category == ratelimit.CategoryTransaction { - type spanCounter interface{ GetSpanCount() int } - if sc, ok := item.(spanCounter); ok { - if count := sc.GetSpanCount(); count > 0 { - Record(reason, ratelimit.CategorySpan, int64(count)) - } - } - } - - // Byte size outcomes for logs - if category == ratelimit.CategoryLog { - type sizer interface{ ApproximateSize() int } - if s, ok := item.(sizer); ok { - if size := s.ApproximateSize(); size > 0 { - Record(reason, ratelimit.CategoryLogByte, int64(size)) - } - } - } -} - -// AttachToEnvelope adds a client report to the envelope if the Aggregator has outcomes available. -func AttachToEnvelope(envelope *protocol.Envelope) { - r := TakeReport() - if r != nil { - rItem, err := r.ToEnvelopeItem() - if err == nil { - envelope.AddItem(rItem) - } else { - debuglog.Printf("failed to serialize client report: %v, with err: %v", r, err) - } - } -} diff --git a/internal/sdk/options.go b/internal/sdk/options.go new file mode 100644 index 000000000..c6df6c2dd --- /dev/null +++ b/internal/sdk/options.go @@ -0,0 +1,29 @@ +package sdk + +import "github.com/getsentry/sentry-go/internal/report" + +// Option configures SDK components. +type Option func(*Options) + +// Options holds optional dependencies shared across SDK components. +type Options struct { + Reporter *report.Aggregator +} + +// WithReporter sets the client report aggregator for tracking discarded events. +func WithReporter(r *report.Aggregator) Option { + return func(o *Options) { + o.Reporter = r + } +} + +// Apply resolves the given options into an Options struct. +func Apply(opts []Option) *Options { + o := &Options{} + for _, opt := range opts { + if opt != nil { + opt(o) + } + } + return o +} diff --git a/internal/telemetry/bucketed_buffer.go b/internal/telemetry/bucketed_buffer.go index bbda6679d..0ebab6de5 100644 --- a/internal/telemetry/bucketed_buffer.go +++ b/internal/telemetry/bucketed_buffer.go @@ -6,7 +6,8 @@ import ( "time" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/sdk" ) const ( @@ -40,6 +41,7 @@ type BucketedBuffer[T any] struct { category ratelimit.Category priority ratelimit.Priority overflowPolicy OverflowPolicy + reporter *report.Aggregator batchSize int timeout time.Duration lastFlushTime time.Time @@ -55,6 +57,7 @@ func NewBucketedBuffer[T any]( overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, + opts ...sdk.Option, ) *BucketedBuffer[T] { if capacity <= 0 { capacity = defaultBucketedCapacity @@ -71,6 +74,7 @@ func NewBucketedBuffer[T any]( bucketCapacity = 10 } + o := sdk.Apply(opts) return &BucketedBuffer[T]{ buckets: make([]*Bucket[T], bucketCapacity), traceIndex: make(map[string]int), @@ -79,6 +83,7 @@ func NewBucketedBuffer[T any]( category: category, priority: category.GetPriority(), overflowPolicy: overflowPolicy, + reporter: o.Reporter, batchSize: batchSize, timeout: timeout, lastFlushTime: time.Now(), @@ -139,7 +144,7 @@ func (b *BucketedBuffer[T]) offerToBucket(item T, traceID string) bool { } func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool { - clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) + b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldestBucket := b.buckets[b.head] diff --git a/internal/telemetry/bucketed_buffer_test.go b/internal/telemetry/bucketed_buffer_test.go index 4e3cc004f..93e66170c 100644 --- a/internal/telemetry/bucketed_buffer_test.go +++ b/internal/telemetry/bucketed_buffer_test.go @@ -41,7 +41,7 @@ func TestBucketedBufferPollOperation(t *testing.T) { } func TestBucketedBufferOverflowDropOldest(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0, nil) dropped := 0 b.SetDroppedCallback(func(_ tbItem, _ string) { dropped++ }) b.Offer(tbItem{id: 1, trace: "a"}) @@ -59,7 +59,7 @@ func TestBucketedBufferOverflowDropOldest(t *testing.T) { } func TestBucketedBufferPollIfReady_BatchSize(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 3, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 3, 0, nil) for i := 1; i <= 3; i++ { b.Offer(tbItem{id: i, trace: "t"}) } @@ -73,7 +73,7 @@ func TestBucketedBufferPollIfReady_BatchSize(t *testing.T) { } func TestBucketedBufferPollIfReady_Timeout(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 1*time.Millisecond) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 1*time.Millisecond, nil) b.Offer(tbItem{id: 1, trace: "t"}) time.Sleep(3 * time.Millisecond) items := b.PollIfReady() @@ -83,7 +83,7 @@ func TestBucketedBufferPollIfReady_Timeout(t *testing.T) { } func TestNewBucketedBuffer(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 0, -1) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 0, -1, nil) if b.Capacity() != 100 { t.Fatalf("default capacity want 100 got %d", b.Capacity()) } @@ -96,7 +96,7 @@ func TestNewBucketedBuffer(t *testing.T) { } func TestBucketedBufferBasicOperations(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0, nil) if !b.IsEmpty() || b.IsFull() || b.Size() != 0 { t.Fatalf("unexpected initial state: empty=%v full=%v size=%d", b.IsEmpty(), b.IsFull(), b.Size()) } @@ -115,7 +115,7 @@ func TestBucketedBufferBasicOperations(t *testing.T) { } func TestBucketedBufferPollBatchAcrossBuckets(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 10, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 10, 0, nil) // Two buckets with different traces b.Offer(tbItem{id: 1, trace: "a"}) b.Offer(tbItem{id: 2, trace: "a"}) @@ -129,7 +129,7 @@ func TestBucketedBufferPollBatchAcrossBuckets(t *testing.T) { } func TestBucketedBufferDrain(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0, nil) for i := 1; i <= 5; i++ { b.Offer(tbItem{id: i, trace: "t"}) } @@ -143,7 +143,7 @@ func TestBucketedBufferDrain(t *testing.T) { } func TestBucketedBufferMetrics(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropNewest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropNewest, 1, 0, nil) if b.OfferedCount() != 0 || b.DroppedCount() != 0 { t.Fatalf("initial metrics not zero") } @@ -162,7 +162,7 @@ func TestBucketedBufferMetrics(t *testing.T) { } func TestBucketedBufferOverflowDropNewest(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0, nil) b.Offer(tbItem{id: 1}) b.Offer(tbItem{id: 2}) if ok := b.Offer(tbItem{id: 3}); ok { @@ -171,7 +171,7 @@ func TestBucketedBufferOverflowDropNewest(t *testing.T) { } func TestBucketedBufferDroppedCallback(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0, nil) calls := 0 b.SetDroppedCallback(func(_ tbItem, reason string) { calls++ @@ -191,7 +191,7 @@ func TestBucketedBufferDroppedCallback(t *testing.T) { } func TestBucketedBufferClear(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0, nil) b.Offer(tbItem{id: 1}) b.Offer(tbItem{id: 2}) b.Clear() @@ -219,7 +219,7 @@ func TestBucketedBufferIsReadyToFlush(t *testing.T) { if tt.category == ratelimit.CategoryError { batch = 1 } - b := NewBucketedBuffer[tbItem](tt.category, 10, OverflowPolicyDropOldest, batch, tt.timeout) + b := NewBucketedBuffer[tbItem](tt.category, 10, OverflowPolicyDropOldest, batch, tt.timeout, nil) for i := 0; i < tt.items; i++ { b.Offer(tbItem{id: i, trace: "t"}) } diff --git a/internal/telemetry/processor.go b/internal/telemetry/processor.go index 187a44a68..263675920 100644 --- a/internal/telemetry/processor.go +++ b/internal/telemetry/processor.go @@ -6,6 +6,7 @@ import ( "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" + "github.com/getsentry/sentry-go/internal/sdk" ) // Processor is the top-level object that wraps the scheduler and buffers. @@ -19,8 +20,9 @@ func NewProcessor( transport protocol.TelemetryTransport, dsn *protocol.Dsn, sdkInfo *protocol.SdkInfo, + opts ...sdk.Option, ) *Processor { - scheduler := NewScheduler(buffers, transport, dsn, sdkInfo) + scheduler := NewScheduler(buffers, transport, dsn, sdkInfo, opts...) scheduler.Start() return &Processor{ diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 9d86bb174..ac8bf02d8 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -6,7 +6,8 @@ import ( "time" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/sdk" ) const defaultCapacity = 100 @@ -23,6 +24,7 @@ type RingBuffer[T any] struct { category ratelimit.Category priority ratelimit.Priority overflowPolicy OverflowPolicy + reporter *report.Aggregator batchSize int timeout time.Duration @@ -33,7 +35,7 @@ type RingBuffer[T any] struct { onDropped func(item T, reason string) } -func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration) *RingBuffer[T] { +func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, opts ...sdk.Option) *RingBuffer[T] { if capacity <= 0 { capacity = defaultCapacity } @@ -46,12 +48,14 @@ func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPol timeout = 0 } + o := sdk.Apply(opts) return &RingBuffer[T]{ items: make([]T, capacity), capacity: capacity, category: category, priority: category.GetPriority(), overflowPolicy: overflowPolicy, + reporter: o.Reporter, batchSize: batchSize, timeout: timeout, lastFlushTime: time.Now(), @@ -77,7 +81,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { return true } - clientreport.RecordOne(clientreport.ReasonBufferOverflow, b.category) + b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldItem := b.items[b.head] diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index d332cd299..b08c9be3f 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -8,7 +8,8 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/sdk" ) const ( @@ -21,6 +22,7 @@ type Scheduler struct { transport protocol.TelemetryTransport dsn *protocol.Dsn sdkInfo *protocol.SdkInfo + reporter *report.Aggregator currentCycle []ratelimit.Priority cyclePos int @@ -40,7 +42,9 @@ func NewScheduler( transport protocol.TelemetryTransport, dsn *protocol.Dsn, sdkInfo *protocol.SdkInfo, + opts ...sdk.Option, ) *Scheduler { + o := sdk.Apply(opts) ctx, cancel := context.WithCancel(context.Background()) priorityWeights := map[ratelimit.Priority]int{ @@ -73,6 +77,7 @@ func NewScheduler( transport: transport, dsn: dsn, sdkInfo: sdkInfo, + reporter: o.Reporter, currentCycle: currentCycle, ctx: ctx, cancel: cancel, @@ -152,7 +157,7 @@ func (s *Scheduler) run() { case <-ticker.C: s.cond.Broadcast() case <-clientReportsTicker.C: - report := clientreport.TakeReport() + report := s.reporter.TakeReport() if report != nil { header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Sdk: s.sdkInfo} if s.dsn != nil { @@ -240,13 +245,13 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category if s.isRateLimited(category) { for _, item := range items { - clientreport.RecordItem(clientreport.ReasonQueueOverflow, item) + s.reporter.RecordItem(report.ReasonQueueOverflow, item) } return } if !s.transport.HasCapacity() { for _, item := range items { - clientreport.RecordItem(clientreport.ReasonQueueOverflow, item) + s.reporter.RecordItem(report.ReasonQueueOverflow, item) } return } @@ -265,7 +270,7 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category return } envelope.AddItem(item) - clientreport.AttachToEnvelope(envelope) + s.reporter.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } @@ -283,7 +288,7 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category return } envelope.AddItem(item) - clientreport.AttachToEnvelope(envelope) + s.reporter.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } @@ -322,7 +327,7 @@ func (s *Scheduler) sendItem(item protocol.EnvelopeItemConvertible) { return } envelope.AddItem(envItem) - clientreport.AttachToEnvelope(envelope) + s.reporter.AttachToEnvelope(envelope) if err := s.transport.SendEnvelope(envelope); err != nil { debuglog.Printf("error sending envelope: %v", err) } diff --git a/scope.go b/scope.go index 23dfbd40d..d0f7e4a6b 100644 --- a/scope.go +++ b/scope.go @@ -10,7 +10,7 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" ) // Scope holds contextual data for the current scope. @@ -480,9 +480,11 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) event = processor(event, hint) if event == nil { debuglog.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id) - clientreport.RecordOne(clientreport.ReasonEventProcessor, category) - if category == ratelimit.CategoryTransaction { - clientreport.Record(clientreport.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) + if client != nil { + client.reporter.RecordOne(report.ReasonEventProcessor, category) + if category == ratelimit.CategoryTransaction { + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) + } } return nil } diff --git a/transport.go b/transport.go index 4a45ae516..240c769b4 100644 --- a/transport.go +++ b/transport.go @@ -17,7 +17,7 @@ import ( httpinternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - clientreport "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" ) @@ -126,7 +126,7 @@ func encodeAttachment(enc *json.Encoder, b io.Writer, attachment *Attachment) er return nil } -func encodeClientReport(enc *json.Encoder, cr *clientreport.ClientReport) error { +func encodeClientReport(enc *json.Encoder, cr *report.ClientReport) error { payload, err := json.Marshal(cr) if err != nil { return err @@ -189,9 +189,9 @@ func encodeEnvelopeMetrics(enc *json.Encoder, count int, body json.RawMessage) e return err } -func recordSpanOutcome(reason clientreport.DiscardReason, event *Event) { +func recordSpanOutcome(reporter *report.Aggregator, reason report.DiscardReason, event *Event) { if event.Type == transactionType { - clientreport.Record(reason, ratelimit.CategorySpan, int64(event.GetSpanCount())) + reporter.Record(reason, ratelimit.CategorySpan, int64(event.GetSpanCount())) } } @@ -208,7 +208,7 @@ func encodeEnvelopeHeader(enc *json.Encoder, header *envelopeHeader) error { return enc.Encode(header) } -func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) { +func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage, reporter *report.Aggregator) (*bytes.Buffer, error) { var b bytes.Buffer enc := json.NewEncoder(&b) @@ -258,7 +258,7 @@ func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMes } // attach client report if exists - r := clientreport.TakeReport() + r := reporter.TakeReport() if r != nil { if err := encodeClientReport(enc, r); err != nil { return nil, err @@ -302,13 +302,13 @@ func getRequestFromEnvelope(ctx context.Context, dsn *Dsn, envelope *bytes.Buffe return request, nil } -func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (*http.Request, error) { +func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn, reporter *report.Aggregator) (*http.Request, error) { body := getRequestBodyFromEvent(event) if body == nil { return nil, errors.New("event could not be marshaled") } - envelope, err := envelopeFromBody(event, dsn, time.Now(), body) + envelope, err := envelopeFromBody(event, dsn, time.Now(), body, reporter) if err != nil { return nil, err } @@ -342,6 +342,7 @@ type HTTPTransport struct { dsn *Dsn client *http.Client transport http.RoundTripper + reporter *report.Aggregator // buffer is a channel of batches. Calling Flush terminates work on the // current in-flight items and starts a new batch for subsequent events. @@ -428,15 +429,15 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) category := event.toCategory() if t.disabled(category) { - clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) - recordSpanOutcome(clientreport.ReasonRateLimitBackoff, event) + t.reporter.RecordOne(report.ReasonRateLimitBackoff, category) + recordSpanOutcome(t.reporter, report.ReasonRateLimitBackoff, event) return } - request, err := getRequestFromEvent(ctx, event, t.dsn) + request, err := getRequestFromEvent(ctx, event, t.dsn, t.reporter) if err != nil { - clientreport.RecordOne(clientreport.ReasonInternalError, category) - recordSpanOutcome(clientreport.ReasonInternalError, event) + t.reporter.RecordOne(report.ReasonInternalError, category) + recordSpanOutcome(t.reporter, report.ReasonInternalError, event) return } @@ -469,8 +470,8 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) ) default: debuglog.Println("Event dropped due to transport buffer being full.") - clientreport.RecordOne(clientreport.ReasonQueueOverflow, category) - recordSpanOutcome(clientreport.ReasonQueueOverflow, event) + t.reporter.RecordOne(report.ReasonQueueOverflow, category) + recordSpanOutcome(t.reporter, report.ReasonQueueOverflow, event) } t.buffer <- b @@ -573,7 +574,7 @@ func (t *HTTPTransport) worker() { case <-t.done: return case <-crTicker.C: - r := clientreport.TakeReport() + r := t.reporter.TakeReport() if r != nil { var buf bytes.Buffer enc := json.NewEncoder(&buf) @@ -669,6 +670,7 @@ type HTTPSyncTransport struct { dsn *Dsn client *http.Client transport http.RoundTripper + reporter *report.Aggregator mu sync.Mutex limits ratelimit.Map @@ -730,15 +732,15 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve category := event.toCategory() if t.disabled(category) { - clientreport.RecordOne(clientreport.ReasonRateLimitBackoff, category) - recordSpanOutcome(clientreport.ReasonRateLimitBackoff, event) + t.reporter.RecordOne(report.ReasonRateLimitBackoff, category) + recordSpanOutcome(t.reporter, report.ReasonRateLimitBackoff, event) return } - request, err := getRequestFromEvent(ctx, event, t.dsn) + request, err := getRequestFromEvent(ctx, event, t.dsn, t.reporter) if err != nil { - clientreport.RecordOne(clientreport.ReasonInternalError, category) - recordSpanOutcome(clientreport.ReasonInternalError, event) + t.reporter.RecordOne(report.ReasonInternalError, category) + recordSpanOutcome(t.reporter, report.ReasonInternalError, event) return } @@ -753,14 +755,14 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve response, err := t.client.Do(request) if err != nil { debuglog.Printf("There was an issue with sending an event: %v", err) - clientreport.RecordOne(clientreport.ReasonNetworkError, category) - recordSpanOutcome(clientreport.ReasonNetworkError, event) + t.reporter.RecordOne(report.ReasonNetworkError, category) + recordSpanOutcome(t.reporter, report.ReasonNetworkError, event) return } success := util.HandleHTTPResponse(response, identifier) if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { - clientreport.RecordOne(clientreport.ReasonSendError, category) - recordSpanOutcome(clientreport.ReasonSendError, event) + t.reporter.RecordOne(report.ReasonSendError, category) + recordSpanOutcome(t.reporter, report.ReasonSendError, event) } t.mu.Lock() diff --git a/transport_test.go b/transport_test.go index 866f6d9ba..adeae65e6 100644 --- a/transport_test.go +++ b/transport_test.go @@ -162,7 +162,7 @@ func TestEnvelopeFromErrorBody(t *testing.T) { body := json.RawMessage(`{"type":"event","fields":"omitted"}`) - b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body) + b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body, nil) if err != nil { t.Fatal(err) } @@ -182,7 +182,7 @@ func TestEnvelopeFromTransactionBody(t *testing.T) { body := json.RawMessage(`{"type":"transaction","fields":"omitted"}`) - b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body) + b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body, nil) if err != nil { t.Fatal(err) } @@ -214,7 +214,7 @@ func TestEnvelopeFromEventWithAttachments(t *testing.T) { body := json.RawMessage(`{"type":"event","fields":"omitted"}`) - b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body) + b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body, nil) if err != nil { t.Fatal(err) } @@ -248,7 +248,7 @@ func TestEnvelopeFromCheckInEvent(t *testing.T) { sentAt := time.Unix(0, 0).UTC() body := getRequestBodyFromEvent(event) - b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body) + b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body, nil) if err != nil { t.Fatal(err) } @@ -286,7 +286,7 @@ func TestEnvelopeFromLogEvent(t *testing.T) { sentAt := time.Unix(0, 0).UTC() body := getRequestBodyFromEvent(event) - b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body) + b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body, nil) if err != nil { t.Fatal(err) } @@ -328,7 +328,7 @@ func TestGetRequestFromEvent(t *testing.T) { } t.Run(test.testName, func(t *testing.T) { - req, err := getRequestFromEvent(context.TODO(), test.event, dsn) + req, err := getRequestFromEvent(context.TODO(), test.event, dsn, nil) if err != nil { t.Fatal(err) } From 464140b92115e6e786a55b4e35d3826cde5da665 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:04:21 +0100 Subject: [PATCH 17/25] chore: lint --- internal/telemetry/scheduler.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index b08c9be3f..0b69beba4 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -157,14 +157,14 @@ func (s *Scheduler) run() { case <-ticker.C: s.cond.Broadcast() case <-clientReportsTicker.C: - report := s.reporter.TakeReport() - if report != nil { + r := s.reporter.TakeReport() + if r != nil { header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Sdk: s.sdkInfo} if s.dsn != nil { header.Dsn = s.dsn.String() } envelope := protocol.NewEnvelope(header) - item, err := report.ToEnvelopeItem() + item, err := r.ToEnvelopeItem() if err != nil { debuglog.Printf("error sending client report: %v", err) continue From 928c2b724047ab4d5dcab2884cd3d3f441ca01d2 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:35:14 +0100 Subject: [PATCH 18/25] add test to validate outcomes --- client_reports_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 client_reports_test.go diff --git a/client_reports_test.go b/client_reports_test.go new file mode 100644 index 000000000..3dca7a73a --- /dev/null +++ b/client_reports_test.go @@ -0,0 +1,99 @@ +package sentry + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/getsentry/sentry-go/internal/ratelimit" + "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/internal/testutils" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +// TestClientReports_Integration tests that client reports are properly generated +// and sent when events are dropped for various reasons. +func TestClientReports_Integration(t *testing.T) { + var receivedBodies [][]byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBodies = append(receivedBodies, body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"test-event-id"}`)) + })) + defer srv.Close() + + dsn := strings.Replace(srv.URL, "//", "//test@", 1) + "/1" + hub := CurrentHub().Clone() + c, err := NewClient(ClientOptions{ + Dsn: dsn, + DisableClientReports: false, + SampleRate: 1.0, + BeforeSend: func(event *Event, hint *EventHint) *Event { + if event.Message == "drop-me" { + return nil + } + return event + }, + }) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + hub.BindClient(c) + defer hub.Flush(testutils.FlushTimeout()) + + // simulate dropped events for report outcomes + hub.CaptureMessage("drop-me") + scope := NewScope() + scope.AddEventProcessor(func(event *Event, hint *EventHint) *Event { + if event.Message == "processor-drop" { + return nil + } + return event + }) + hub.WithScope(func(s *Scope) { + s.eventProcessors = scope.eventProcessors + hub.CaptureMessage("processor-drop") + }) + + hub.CaptureMessage("hi") // send an event to capture the report along with it + if !hub.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + + var got report.ClientReport + found := false + for _, b := range receivedBodies { + for _, line := range bytes.Split(b, []byte("\n")) { + if json.Unmarshal(line, &got) == nil && len(got.DiscardedEvents) > 0 { + found = true + break + } + } + if found { + break + } + } + if !found { + t.Fatal("no client report found in envelope bodies") + } + + if got.Timestamp.IsZero() { + t.Error("client report missing timestamp") + } + + want := []report.DiscardedEvent{ + {Reason: report.ReasonBeforeSend, Category: ratelimit.CategoryError, Quantity: 1}, + {Reason: report.ReasonEventProcessor, Category: ratelimit.CategoryError, Quantity: 1}, + } + if diff := cmp.Diff(want, got.DiscardedEvents, cmpopts.SortSlices(func(a, b report.DiscardedEvent) bool { + return a.Reason < b.Reason + })); diff != "" { + t.Errorf("DiscardedEvents mismatch (-want +got):\n%s", diff) + } +} From b103feb53209369f04d02d8c5725e5cac9f54ea7 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:40:42 +0100 Subject: [PATCH 19/25] fix: lint --- client_reports_test.go | 6 +++--- scope.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client_reports_test.go b/client_reports_test.go index 3dca7a73a..844336ac4 100644 --- a/client_reports_test.go +++ b/client_reports_test.go @@ -24,7 +24,7 @@ func TestClientReports_Integration(t *testing.T) { body, _ := io.ReadAll(r.Body) receivedBodies = append(receivedBodies, body) w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"id":"test-event-id"}`)) + _, _ = w.Write([]byte(`{"id":"test-event-id"}`)) })) defer srv.Close() @@ -34,7 +34,7 @@ func TestClientReports_Integration(t *testing.T) { Dsn: dsn, DisableClientReports: false, SampleRate: 1.0, - BeforeSend: func(event *Event, hint *EventHint) *Event { + BeforeSend: func(event *Event, _ *EventHint) *Event { if event.Message == "drop-me" { return nil } @@ -50,7 +50,7 @@ func TestClientReports_Integration(t *testing.T) { // simulate dropped events for report outcomes hub.CaptureMessage("drop-me") scope := NewScope() - scope.AddEventProcessor(func(event *Event, hint *EventHint) *Event { + scope.AddEventProcessor(func(event *Event, _ *EventHint) *Event { if event.Message == "processor-drop" { return nil } diff --git a/scope.go b/scope.go index 286292aa5..2ee134cd1 100644 --- a/scope.go +++ b/scope.go @@ -368,7 +368,7 @@ func (scope *Scope) AddEventProcessor(processor EventProcessor) { } // ApplyToEvent takes the data from the current scope and attaches it to the event. -func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) *Event { +func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) *Event { //nolint:gocyclo scope.mu.RLock() defer scope.mu.RUnlock() From 594e1017b92df85ea5aa000f2020074da7fdfc59 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:43:13 +0100 Subject: [PATCH 20/25] add registry to fetch report aggregator --- client.go | 26 ++++--- client_reports_test.go | 6 ++ internal/http/transport.go | 16 ++++- internal/report/registry.go | 75 ++++++++++++++++++++ internal/report/registry_test.go | 82 ++++++++++++++++++++++ internal/sdk/options.go | 29 -------- internal/telemetry/bucketed_buffer.go | 6 +- internal/telemetry/bucketed_buffer_test.go | 28 ++++---- internal/telemetry/processor.go | 4 +- internal/telemetry/processor_test.go | 2 +- internal/telemetry/ring_buffer.go | 6 +- internal/telemetry/ring_buffer_test.go | 40 +++++------ internal/telemetry/scheduler.go | 5 +- internal/telemetry/scheduler_test.go | 26 +++---- transport.go | 12 +++- 15 files changed, 252 insertions(+), 111 deletions(-) create mode 100644 internal/report/registry.go create mode 100644 internal/report/registry_test.go delete mode 100644 internal/sdk/options.go diff --git a/client.go b/client.go index 4c8a42784..01bd22117 100644 --- a/client.go +++ b/client.go @@ -19,7 +19,6 @@ import ( "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/report" - "github.com/getsentry/sentry-go/internal/sdk" "github.com/getsentry/sentry-go/internal/telemetry" ) @@ -395,7 +394,9 @@ func NewClient(options ClientOptions) (*Client, error) { } if !options.DisableClientReports { - client.reporter = report.NewAggregator() + // Use the global registry to get or create a reporter for this DSN. + // This ensures all components using the same DSN share the same reporter. + client.reporter = report.GetOrCreateAggregator(options.Dsn) } client.setupTransport() @@ -427,9 +428,7 @@ func (client *Client) setupTransport() { if opts.Dsn == "" { transport = new(noopTransport) } else { - t := NewHTTPTransport() - t.reporter = client.reporter - transport = t + transport = NewHTTPTransport() } } @@ -472,13 +471,12 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused }) client.Transport = &internalAsyncTransportAdapter{transport: transport} - reportOpt := sdk.WithReporter(client.reporter) buffers := map[ratelimit.Category]telemetry.Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), - ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), - ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, reportOpt), - ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0, reportOpt), - ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, reportOpt), + ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](client.options.Dsn, ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](client.options.Dsn, ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](client.options.Dsn, ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second), + ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](client.options.Dsn, ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](client.options.Dsn, ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second), } sdkInfo := &protocol.SdkInfo{ @@ -486,7 +484,7 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused Version: client.sdkVersion, } - client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo, sdk.WithReporter(client.reporter)) + client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo) } func (client *Client) setupIntegrations() { @@ -852,7 +850,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod if event == nil { debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.") client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTransaction) - client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, 1) // count the transaction root itself + client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, int64(spanCountBefore)) return nil } // Track spans removed by the callback @@ -964,7 +962,7 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id) client.reporter.RecordOne(report.ReasonEventProcessor, category) if category == ratelimit.CategoryTransaction { - client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore+1)) + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) } return nil } diff --git a/client_reports_test.go b/client_reports_test.go index 844336ac4..4b45c2526 100644 --- a/client_reports_test.go +++ b/client_reports_test.go @@ -47,6 +47,12 @@ func TestClientReports_Integration(t *testing.T) { hub.BindClient(c) defer hub.Flush(testutils.FlushTimeout()) + // second client with disabled reports shouldn't affect the first + _, _ = NewClient(ClientOptions{ + Dsn: testDsn, + DisableClientReports: true, + }) + // simulate dropped events for report outcomes hub.CaptureMessage("drop-me") scope := NewScope() diff --git a/internal/http/transport.go b/internal/http/transport.go index 2e8cdf1ab..fed810b87 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -163,10 +163,15 @@ func NewSyncTransport(options TransportOptions) protocol.TelemetryTransport { return NewNoopTransport() } + // Fetch reporter from global registry (created by Client). + // Transports should not create reporters, only use existing ones. + reporter := report.GetAggregator(options.Dsn) + transport := &SyncTransport{ - Timeout: defaultTimeout, - limits: make(ratelimit.Map), - dsn: dsn, + Timeout: defaultTimeout, + limits: make(ratelimit.Map), + dsn: dsn, + reporter: reporter, } if options.HTTPTransport != nil { @@ -305,12 +310,17 @@ func NewAsyncTransport(options TransportOptions) protocol.TelemetryTransport { return NewNoopTransport() } + // Fetch reporter from global registry (created by Client). + // Transports should not create reporters, only use existing ones. + reporter := report.GetAggregator(options.Dsn) + transport := &AsyncTransport{ QueueSize: defaultQueueSize, Timeout: defaultTimeout, done: make(chan struct{}), limits: make(ratelimit.Map), dsn: dsn, + reporter: reporter, } transport.queue = make(chan *protocol.Envelope, transport.QueueSize) diff --git a/internal/report/registry.go b/internal/report/registry.go new file mode 100644 index 000000000..ea0e35260 --- /dev/null +++ b/internal/report/registry.go @@ -0,0 +1,75 @@ +package report + +import ( + "sync" +) + +// registry is a global map from DSN string to its associated Aggregator. +// The Client should be the only component creating aggregators. Other components +// (transports, telemetry buffers) should only fetch existing aggregators. +var registry struct { + mu sync.RWMutex + aggregators map[string]*Aggregator +} + +func init() { + registry.aggregators = make(map[string]*Aggregator) +} + +// GetAggregator returns the existing Aggregator for a DSN, or nil if none exists. +func GetAggregator(dsn string) *Aggregator { + if dsn == "" { + return nil + } + + registry.mu.RLock() + defer registry.mu.RUnlock() + return registry.aggregators[dsn] +} + +// GetOrCreateAggregator returns the existing Aggregator for a DSN, or creates a new one if none exists. +// +// Since the client is the source of truth for client reports, it should be the only one that calls this method. +// Other components should use GetAggregator, without registering a new one. +func GetOrCreateAggregator(dsn string) *Aggregator { + if dsn == "" { + return nil + } + + registry.mu.RLock() + if agg, exists := registry.aggregators[dsn]; exists { + registry.mu.RUnlock() + return agg + } + registry.mu.RUnlock() + + registry.mu.Lock() + defer registry.mu.Unlock() + + // Double-check after acquiring write lock + if agg, exists := registry.aggregators[dsn]; exists { + return agg + } + + agg := NewAggregator() + registry.aggregators[dsn] = agg + return agg +} + +// UnregisterAggregator removes the Aggregator for a DSN from the registry. +func UnregisterAggregator(dsn string) { + if dsn == "" { + return + } + + registry.mu.Lock() + defer registry.mu.Unlock() + delete(registry.aggregators, dsn) +} + +// ClearRegistry removes all registered aggregators. +func ClearRegistry() { + registry.mu.Lock() + defer registry.mu.Unlock() + registry.aggregators = make(map[string]*Aggregator) +} diff --git a/internal/report/registry_test.go b/internal/report/registry_test.go new file mode 100644 index 000000000..bdfc89b39 --- /dev/null +++ b/internal/report/registry_test.go @@ -0,0 +1,82 @@ +package report + +import ( + "testing" +) + +func TestRegistry_SharedAcrossComponents(t *testing.T) { + ClearRegistry() + defer ClearRegistry() + + dsn := "https://public@example.com/1" + + clientAgg := GetOrCreateAggregator(dsn) + transportAgg := GetOrCreateAggregator(dsn) + telemetryAgg := GetOrCreateAggregator(dsn) + + if clientAgg != transportAgg { + t.Errorf("client and transport should share aggregator") + } + if clientAgg != telemetryAgg { + t.Errorf("client and telemetry should share aggregator") + } + + clientAgg.RecordOne(ReasonQueueOverflow, "error") + transportAgg.RecordOne(ReasonRateLimitBackoff, "transaction") + + report := telemetryAgg.TakeReport() + if report == nil { + t.Fatal("expected report from shared aggregator") + } + + if len(report.DiscardedEvents) != 2 { + t.Errorf("expected 2 discarded events, got %d", len(report.DiscardedEvents)) + } +} + +func TestUnregisterAggregator(t *testing.T) { + ClearRegistry() + defer ClearRegistry() + + dsn := "https://public@example.com/1" + agg1 := GetOrCreateAggregator(dsn) + if agg1 == nil { + t.Fatal("expected aggregator, got nil") + } + + UnregisterAggregator(dsn) + + agg2 := GetOrCreateAggregator(dsn) + if agg2 == nil { + t.Fatal("expected new aggregator after unregister, got nil") + } + if agg1 == agg2 { + t.Errorf("expected different aggregator instance after unregister") + } + + UnregisterAggregator("") +} + +func TestClearRegistry(t *testing.T) { + ClearRegistry() + defer ClearRegistry() + + dsn1 := "https://public@example.com/1" + dsn2 := "https://public@example.com/2" + + agg1 := GetOrCreateAggregator(dsn1) + agg2 := GetOrCreateAggregator(dsn2) + + ClearRegistry() + + // After clear, should create new instances + newAgg1 := GetOrCreateAggregator(dsn1) + newAgg2 := GetOrCreateAggregator(dsn2) + + if agg1 == newAgg1 { + t.Errorf("expected different aggregator for dsn1 after clear") + } + if agg2 == newAgg2 { + t.Errorf("expected different aggregator for dsn2 after clear") + } +} diff --git a/internal/sdk/options.go b/internal/sdk/options.go deleted file mode 100644 index c6df6c2dd..000000000 --- a/internal/sdk/options.go +++ /dev/null @@ -1,29 +0,0 @@ -package sdk - -import "github.com/getsentry/sentry-go/internal/report" - -// Option configures SDK components. -type Option func(*Options) - -// Options holds optional dependencies shared across SDK components. -type Options struct { - Reporter *report.Aggregator -} - -// WithReporter sets the client report aggregator for tracking discarded events. -func WithReporter(r *report.Aggregator) Option { - return func(o *Options) { - o.Reporter = r - } -} - -// Apply resolves the given options into an Options struct. -func Apply(opts []Option) *Options { - o := &Options{} - for _, opt := range opts { - if opt != nil { - opt(o) - } - } - return o -} diff --git a/internal/telemetry/bucketed_buffer.go b/internal/telemetry/bucketed_buffer.go index 0ebab6de5..ea15c7381 100644 --- a/internal/telemetry/bucketed_buffer.go +++ b/internal/telemetry/bucketed_buffer.go @@ -7,7 +7,6 @@ import ( "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/report" - "github.com/getsentry/sentry-go/internal/sdk" ) const ( @@ -52,12 +51,12 @@ type BucketedBuffer[T any] struct { } func NewBucketedBuffer[T any]( + dsn string, category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, - opts ...sdk.Option, ) *BucketedBuffer[T] { if capacity <= 0 { capacity = defaultBucketedCapacity @@ -74,7 +73,6 @@ func NewBucketedBuffer[T any]( bucketCapacity = 10 } - o := sdk.Apply(opts) return &BucketedBuffer[T]{ buckets: make([]*Bucket[T], bucketCapacity), traceIndex: make(map[string]int), @@ -83,7 +81,7 @@ func NewBucketedBuffer[T any]( category: category, priority: category.GetPriority(), overflowPolicy: overflowPolicy, - reporter: o.Reporter, + reporter: report.GetAggregator(dsn), batchSize: batchSize, timeout: timeout, lastFlushTime: time.Now(), diff --git a/internal/telemetry/bucketed_buffer_test.go b/internal/telemetry/bucketed_buffer_test.go index 93e66170c..0724b055b 100644 --- a/internal/telemetry/bucketed_buffer_test.go +++ b/internal/telemetry/bucketed_buffer_test.go @@ -22,7 +22,7 @@ func (i tbItem) GetTraceID() (string, bool) { } func TestBucketedBufferPollOperation(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 3, 0) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 3, 0) if !b.Offer(tbItem{id: 1}) || !b.Offer(tbItem{id: 2}) { t.Fatal("offer failed") } @@ -41,7 +41,7 @@ func TestBucketedBufferPollOperation(t *testing.T) { } func TestBucketedBufferOverflowDropOldest(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) dropped := 0 b.SetDroppedCallback(func(_ tbItem, _ string) { dropped++ }) b.Offer(tbItem{id: 1, trace: "a"}) @@ -59,7 +59,7 @@ func TestBucketedBufferOverflowDropOldest(t *testing.T) { } func TestBucketedBufferPollIfReady_BatchSize(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 3, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 3, 0) for i := 1; i <= 3; i++ { b.Offer(tbItem{id: i, trace: "t"}) } @@ -73,7 +73,7 @@ func TestBucketedBufferPollIfReady_BatchSize(t *testing.T) { } func TestBucketedBufferPollIfReady_Timeout(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 1*time.Millisecond, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 1*time.Millisecond) b.Offer(tbItem{id: 1, trace: "t"}) time.Sleep(3 * time.Millisecond) items := b.PollIfReady() @@ -83,7 +83,7 @@ func TestBucketedBufferPollIfReady_Timeout(t *testing.T) { } func TestNewBucketedBuffer(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 0, -1, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 0, -1) if b.Capacity() != 100 { t.Fatalf("default capacity want 100 got %d", b.Capacity()) } @@ -96,7 +96,7 @@ func TestNewBucketedBuffer(t *testing.T) { } func TestBucketedBufferBasicOperations(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) if !b.IsEmpty() || b.IsFull() || b.Size() != 0 { t.Fatalf("unexpected initial state: empty=%v full=%v size=%d", b.IsEmpty(), b.IsFull(), b.Size()) } @@ -115,7 +115,7 @@ func TestBucketedBufferBasicOperations(t *testing.T) { } func TestBucketedBufferPollBatchAcrossBuckets(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 10, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 10, 0) // Two buckets with different traces b.Offer(tbItem{id: 1, trace: "a"}) b.Offer(tbItem{id: 2, trace: "a"}) @@ -129,7 +129,7 @@ func TestBucketedBufferPollBatchAcrossBuckets(t *testing.T) { } func TestBucketedBufferDrain(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) for i := 1; i <= 5; i++ { b.Offer(tbItem{id: i, trace: "t"}) } @@ -143,7 +143,7 @@ func TestBucketedBufferDrain(t *testing.T) { } func TestBucketedBufferMetrics(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 10, OverflowPolicyDropNewest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropNewest, 1, 0) if b.OfferedCount() != 0 || b.DroppedCount() != 0 { t.Fatalf("initial metrics not zero") } @@ -162,7 +162,7 @@ func TestBucketedBufferMetrics(t *testing.T) { } func TestBucketedBufferOverflowDropNewest(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0) b.Offer(tbItem{id: 1}) b.Offer(tbItem{id: 2}) if ok := b.Offer(tbItem{id: 3}); ok { @@ -171,7 +171,7 @@ func TestBucketedBufferOverflowDropNewest(t *testing.T) { } func TestBucketedBufferDroppedCallback(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) calls := 0 b.SetDroppedCallback(func(_ tbItem, reason string) { calls++ @@ -191,7 +191,7 @@ func TestBucketedBufferDroppedCallback(t *testing.T) { } func TestBucketedBufferClear(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0, nil) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) b.Offer(tbItem{id: 1}) b.Offer(tbItem{id: 2}) b.Clear() @@ -219,7 +219,7 @@ func TestBucketedBufferIsReadyToFlush(t *testing.T) { if tt.category == ratelimit.CategoryError { batch = 1 } - b := NewBucketedBuffer[tbItem](tt.category, 10, OverflowPolicyDropOldest, batch, tt.timeout, nil) + b := NewBucketedBuffer[tbItem]("", tt.category, 10, OverflowPolicyDropOldest, batch, tt.timeout) for i := 0; i < tt.items; i++ { b.Offer(tbItem{id: i, trace: "t"}) } @@ -235,7 +235,7 @@ func TestBucketedBufferIsReadyToFlush(t *testing.T) { } func TestBucketedBufferConcurrency(t *testing.T) { - b := NewBucketedBuffer[tbItem](ratelimit.CategoryError, 200, OverflowPolicyDropOldest, 1, 0) + b := NewBucketedBuffer[tbItem]("", ratelimit.CategoryError, 200, OverflowPolicyDropOldest, 1, 0) const producers = 5 const per = 50 var wg sync.WaitGroup diff --git a/internal/telemetry/processor.go b/internal/telemetry/processor.go index 263675920..187a44a68 100644 --- a/internal/telemetry/processor.go +++ b/internal/telemetry/processor.go @@ -6,7 +6,6 @@ import ( "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/sdk" ) // Processor is the top-level object that wraps the scheduler and buffers. @@ -20,9 +19,8 @@ func NewProcessor( transport protocol.TelemetryTransport, dsn *protocol.Dsn, sdkInfo *protocol.SdkInfo, - opts ...sdk.Option, ) *Processor { - scheduler := NewScheduler(buffers, transport, dsn, sdkInfo, opts...) + scheduler := NewScheduler(buffers, transport, dsn, sdkInfo) scheduler.Start() return &Processor{ diff --git a/internal/telemetry/processor_test.go b/internal/telemetry/processor_test.go index 243e09ad0..97c2080ca 100644 --- a/internal/telemetry/processor_test.go +++ b/internal/telemetry/processor_test.go @@ -38,7 +38,7 @@ func TestBuffer_AddAndFlush_Sends(t *testing.T) { dsn := &protocol.Dsn{} sdk := &protocol.SdkInfo{Name: "s", Version: "v"} storage := map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), } b := NewProcessor(storage, transport, dsn, sdk) if !b.Add(bwItem{id: "1"}) { diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index ac8bf02d8..4a7b66eb0 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -7,7 +7,6 @@ import ( "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/report" - "github.com/getsentry/sentry-go/internal/sdk" ) const defaultCapacity = 100 @@ -35,7 +34,7 @@ type RingBuffer[T any] struct { onDropped func(item T, reason string) } -func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration, opts ...sdk.Option) *RingBuffer[T] { +func NewRingBuffer[T any](dsn string, category ratelimit.Category, capacity int, overflowPolicy OverflowPolicy, batchSize int, timeout time.Duration) *RingBuffer[T] { if capacity <= 0 { capacity = defaultCapacity } @@ -48,14 +47,13 @@ func NewRingBuffer[T any](category ratelimit.Category, capacity int, overflowPol timeout = 0 } - o := sdk.Apply(opts) return &RingBuffer[T]{ items: make([]T, capacity), capacity: capacity, category: category, priority: category.GetPriority(), overflowPolicy: overflowPolicy, - reporter: o.Reporter, + reporter: report.GetAggregator(dsn), batchSize: batchSize, timeout: timeout, lastFlushTime: time.Now(), diff --git a/internal/telemetry/ring_buffer_test.go b/internal/telemetry/ring_buffer_test.go index c8b9af2d8..b7bbfe459 100644 --- a/internal/telemetry/ring_buffer_test.go +++ b/internal/telemetry/ring_buffer_test.go @@ -16,7 +16,7 @@ type testItem struct { func TestNewRingBuffer(t *testing.T) { t.Run("with valid capacity", func(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 50, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 50, OverflowPolicyDropOldest, 1, 0) if buffer.Capacity() != 50 { t.Errorf("Expected capacity 50, got %d", buffer.Capacity()) } @@ -29,14 +29,14 @@ func TestNewRingBuffer(t *testing.T) { }) t.Run("with zero capacity", func(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryLog, 0, OverflowPolicyDropOldest, 1, 0) if buffer.Capacity() != 100 { t.Errorf("Expected default capacity 100, got %d", buffer.Capacity()) } }) t.Run("with negative capacity", func(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryLog, -10, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryLog, -10, OverflowPolicyDropOldest, 1, 0) if buffer.Capacity() != 100 { t.Errorf("Expected default capacity 100, got %d", buffer.Capacity()) } @@ -44,7 +44,7 @@ func TestNewRingBuffer(t *testing.T) { } func TestBufferBasicOperations(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) // Test empty buffer if !buffer.IsEmpty() { @@ -83,7 +83,7 @@ func TestBufferBasicOperations(t *testing.T) { } func TestBufferPollOperation(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) // Test polling from empty buffer item, ok := buffer.Poll() @@ -126,7 +126,7 @@ func TestBufferPollOperation(t *testing.T) { } func TestBufferOverflow(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) // Fill buffer to capacity item1 := &testItem{id: 1, data: "first"} @@ -170,7 +170,7 @@ func TestBufferOverflow(t *testing.T) { } func TestBufferDrain(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) // Drain empty buffer items := buffer.Drain() @@ -206,7 +206,7 @@ func TestBufferDrain(t *testing.T) { } func TestBufferMetrics(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) // Initial metrics if buffer.OfferedCount() != 0 { @@ -230,7 +230,7 @@ func TestBufferMetrics(t *testing.T) { } func TestBufferConcurrency(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 100, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 100, OverflowPolicyDropOldest, 1, 0) const numGoroutines = 10 const itemsPerGoroutine = 50 @@ -301,7 +301,7 @@ func TestBufferDifferentCategories(t *testing.T) { for _, tc := range testCases { t.Run(string(tc.category), func(t *testing.T) { - buffer := NewRingBuffer[*testItem](tc.category, 10, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", tc.category, 10, OverflowPolicyDropOldest, 1, 0) if buffer.Category() != tc.category { t.Errorf("Expected category %s, got %s", tc.category, buffer.Category()) } @@ -317,7 +317,7 @@ func TestBufferStressTest(t *testing.T) { t.Skip("Skipping stress test in short mode") } - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 1000, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 1000, OverflowPolicyDropOldest, 1, 0) const duration = 100 * time.Millisecond const numProducers = 5 @@ -394,7 +394,7 @@ func TestBufferStressTest(t *testing.T) { } func TestOverflowPolicyDropOldest(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) // Fill buffer to capacity item1 := &testItem{id: 1, data: "first"} @@ -434,7 +434,7 @@ func TestOverflowPolicyDropOldest(t *testing.T) { } func TestOverflowPolicyDropNewest(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropNewest, 1, 0) // Fill buffer to capacity item1 := &testItem{id: 1, data: "first"} @@ -474,7 +474,7 @@ func TestOverflowPolicyDropNewest(t *testing.T) { } func TestBufferDroppedCallback(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) var droppedItems []*testItem var dropReasons []string @@ -512,7 +512,7 @@ func TestBufferDroppedCallback(t *testing.T) { } func TestBufferPollBatch(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 5, OverflowPolicyDropOldest, 1, 0) // Add some items for i := 1; i <= 5; i++ { @@ -540,7 +540,7 @@ func TestBufferPollBatch(t *testing.T) { } func TestBufferPeek(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) // Test peek on empty buffer _, ok := buffer.Peek() @@ -567,7 +567,7 @@ func TestBufferPeek(t *testing.T) { } func TestBufferAdvancedMetrics(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 2, OverflowPolicyDropOldest, 1, 0) // Test initial metrics metrics := buffer.GetMetrics() @@ -611,7 +611,7 @@ func TestBufferAdvancedMetrics(t *testing.T) { } func TestBufferClear(t *testing.T) { - buffer := NewRingBuffer[*testItem](ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[*testItem]("", ratelimit.CategoryError, 3, OverflowPolicyDropOldest, 1, 0) // Add some items buffer.Offer(&testItem{id: 1, data: "test"}) @@ -700,7 +700,7 @@ func TestBufferIsReadyToFlush(t *testing.T) { batchSize = 100 timeout = 5 * time.Second } - buffer := NewRingBuffer[*testItem](tt.category, 200, OverflowPolicyDropOldest, batchSize, timeout) + buffer := NewRingBuffer[*testItem]("", tt.category, 200, OverflowPolicyDropOldest, batchSize, timeout) for i := 0; i < tt.itemsToAdd; i++ { buffer.Offer(&testItem{id: i, data: "test"}) @@ -771,7 +771,7 @@ func TestBufferPollIfReady(t *testing.T) { batchSize = 100 timeout = 5 * time.Second } - buffer := NewRingBuffer[*testItem](tt.category, 200, OverflowPolicyDropOldest, batchSize, timeout) + buffer := NewRingBuffer[*testItem]("", tt.category, 200, OverflowPolicyDropOldest, batchSize, timeout) for i := 0; i < tt.itemsToAdd; i++ { buffer.Offer(&testItem{id: i, data: "test"}) diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index 0b69beba4..bfb59a32e 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -9,7 +9,6 @@ import ( "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/report" - "github.com/getsentry/sentry-go/internal/sdk" ) const ( @@ -42,9 +41,7 @@ func NewScheduler( transport protocol.TelemetryTransport, dsn *protocol.Dsn, sdkInfo *protocol.SdkInfo, - opts ...sdk.Option, ) *Scheduler { - o := sdk.Apply(opts) ctx, cancel := context.WithCancel(context.Background()) priorityWeights := map[ratelimit.Priority]int{ @@ -77,7 +74,7 @@ func NewScheduler( transport: transport, dsn: dsn, sdkInfo: sdkInfo, - reporter: o.Reporter, + reporter: report.GetAggregator(dsn.String()), currentCycle: currentCycle, ctx: ctx, cancel: cancel, diff --git a/internal/telemetry/scheduler_test.go b/internal/telemetry/scheduler_test.go index e392e97fe..e66718811 100644 --- a/internal/telemetry/scheduler_test.go +++ b/internal/telemetry/scheduler_test.go @@ -52,7 +52,7 @@ func TestNewTelemetryScheduler(t *testing.T) { dsn := &protocol.Dsn{} buffers := map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), } sdkInfo := &protocol.SdkInfo{ @@ -105,7 +105,7 @@ func TestTelemetrySchedulerFlush(t *testing.T) { name: "single category with multiple items", setupBuffers: func() map[ratelimit.Category]Buffer[protocol.TelemetryItem] { return map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), } }, addItems: func(buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]) { @@ -119,7 +119,7 @@ func TestTelemetrySchedulerFlush(t *testing.T) { name: "empty buffers", setupBuffers: func() map[ratelimit.Category]Buffer[protocol.TelemetryItem] { return map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), } }, addItems: func(_ map[ratelimit.Category]Buffer[protocol.TelemetryItem]) { @@ -130,9 +130,9 @@ func TestTelemetrySchedulerFlush(t *testing.T) { name: "multiple categories", setupBuffers: func() map[ratelimit.Category]Buffer[protocol.TelemetryItem] { return map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryTransaction: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 10, OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryMonitor: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryTransaction: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryTransaction, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryMonitor: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryMonitor, 10, OverflowPolicyDropOldest, 1, 0), } }, addItems: func(buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]) { @@ -148,8 +148,8 @@ func TestTelemetrySchedulerFlush(t *testing.T) { name: "priority ordering - error and log", setupBuffers: func() map[ratelimit.Category]Buffer[protocol.TelemetryItem] { return map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryLog: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 5*time.Second), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryLog: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryLog, 10, OverflowPolicyDropOldest, 100, 5*time.Second), } }, addItems: func(buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]) { @@ -163,8 +163,8 @@ func TestTelemetrySchedulerFlush(t *testing.T) { name: "priority ordering - error and metric", setupBuffers: func() map[ratelimit.Category]Buffer[protocol.TelemetryItem] { return map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ - ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), - ratelimit.CategoryTraceMetric: NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10, OverflowPolicyDropOldest, 100, 5*time.Second), + ratelimit.CategoryError: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0), + ratelimit.CategoryTraceMetric: NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryTraceMetric, 10, OverflowPolicyDropOldest, 100, 5*time.Second), } }, addItems: func(buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem]) { @@ -206,7 +206,7 @@ func TestTelemetrySchedulerRateLimiting(t *testing.T) { transport := &testutils.MockTelemetryTransport{} dsn := &protocol.Dsn{} - buffer := NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) buffers := map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ ratelimit.CategoryError: buffer, } @@ -239,7 +239,7 @@ func TestTelemetrySchedulerStartStop(t *testing.T) { transport := &testutils.MockTelemetryTransport{} dsn := &protocol.Dsn{} - buffer := NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) buffers := map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ ratelimit.CategoryError: buffer, } @@ -267,7 +267,7 @@ func TestTelemetrySchedulerContextCancellation(t *testing.T) { transport := &testutils.MockTelemetryTransport{} dsn := &protocol.Dsn{} - buffer := NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) + buffer := NewRingBuffer[protocol.TelemetryItem]("", ratelimit.CategoryError, 10, OverflowPolicyDropOldest, 1, 0) buffers := map[ratelimit.Category]Buffer[protocol.TelemetryItem]{ ratelimit.CategoryError: buffer, } diff --git a/transport.go b/transport.go index 240c769b4..b0d5675a8 100644 --- a/transport.go +++ b/transport.go @@ -373,7 +373,7 @@ func NewHTTPTransport() *HTTPTransport { return &transport } -// Configure is called by the Client itself, providing it it's own ClientOptions. +// Configure is called by the Client itself, providing its own ClientOptions. func (t *HTTPTransport) Configure(options ClientOptions) { dsn, err := NewDsn(options.Dsn) if err != nil { @@ -382,6 +382,10 @@ func (t *HTTPTransport) Configure(options ClientOptions) { } t.dsn = dsn + if t.reporter == nil { + t.reporter = report.GetAggregator(options.Dsn) + } + // A buffered channel with capacity 1 works like a mutex, ensuring only one // goroutine can access the current batch at a given time. Access is // synchronized by reading from and writing to the channel. @@ -689,7 +693,7 @@ func NewHTTPSyncTransport() *HTTPSyncTransport { return &transport } -// Configure is called by the Client itself, providing it it's own ClientOptions. +// Configure is called by the Client itself, providing its own ClientOptions. func (t *HTTPSyncTransport) Configure(options ClientOptions) { dsn, err := NewDsn(options.Dsn) if err != nil { @@ -698,6 +702,10 @@ func (t *HTTPSyncTransport) Configure(options ClientOptions) { } t.dsn = dsn + if t.reporter == nil { + t.reporter = report.GetAggregator(options.Dsn) + } + if options.HTTPTransport != nil { t.transport = options.HTTPTransport } else { From a64cd4c0f78d37e6474356a22f6d9980ce1644c7 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:51:01 +0100 Subject: [PATCH 21/25] fix lint --- internal/report/registry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/report/registry.go b/internal/report/registry.go index ea0e35260..6ab85d7fd 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -12,6 +12,7 @@ var registry struct { aggregators map[string]*Aggregator } +// nolint:gochecknoinits func init() { registry.aggregators = make(map[string]*Aggregator) } From d30c28f012f05aed42b9807b0ca53d64fc41a712 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:53:36 +0100 Subject: [PATCH 22/25] update rate limit on ticker request --- transport.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/transport.go b/transport.go index b0d5675a8..474186cd2 100644 --- a/transport.go +++ b/transport.go @@ -605,6 +605,12 @@ func (t *HTTPTransport) worker() { debuglog.Printf("There was an issue with sending an event: %v", err) continue } + t.mu.Lock() + if t.limits == nil { + t.limits = make(ratelimit.Map) + } + t.limits.Merge(ratelimit.FromResponse(response)) + t.mu.Unlock() // Drain body up to a limit and close it, allowing the // transport to reuse TCP connections. From 85a8e5ac25308c11134bc14148c14bd31e21370f Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:43:25 +0100 Subject: [PATCH 23/25] misc fixes --- client.go | 3 ++- client_reports_test.go | 2 +- internal/http/transport.go | 28 ++++++-------------- internal/http/transport_test.go | 16 ++++++----- internal/telemetry/bucketed_buffer.go | 16 +++++++++-- internal/telemetry/ring_buffer.go | 13 +++++++-- internal/telemetry/scheduler.go | 5 ++-- {internal/report => report}/aggregator.go | 13 +++++++-- {internal/report => report}/outcome.go | 0 {internal/report => report}/reason.go | 0 {internal/report => report}/registry.go | 0 {internal/report => report}/registry_test.go | 0 {internal/report => report}/report.go | 0 scope.go | 7 ++++- tracing.go | 13 +++++++++ transport.go | 11 ++++---- 16 files changed, 83 insertions(+), 44 deletions(-) rename {internal/report => report}/aggregator.go (96%) rename {internal/report => report}/outcome.go (100%) rename {internal/report => report}/reason.go (100%) rename {internal/report => report}/registry.go (100%) rename {internal/report => report}/registry_test.go (100%) rename {internal/report => report}/report.go (100%) diff --git a/client.go b/client.go index 01bd22117..a713a6d0f 100644 --- a/client.go +++ b/client.go @@ -18,8 +18,8 @@ import ( httpInternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/telemetry" + "github.com/getsentry/sentry-go/report" ) // The identifier of the SDK. @@ -739,6 +739,7 @@ func (client *Client) Close() { if client.batchMeter != nil { client.batchMeter.Shutdown() } + report.UnregisterAggregator(client.options.Dsn) client.Transport.Close() } diff --git a/client_reports_test.go b/client_reports_test.go index 4b45c2526..043c081b4 100644 --- a/client_reports_test.go +++ b/client_reports_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/testutils" + "github.com/getsentry/sentry-go/report" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) diff --git a/internal/http/transport.go b/internal/http/transport.go index fed810b87..2dc64f33c 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -11,14 +11,13 @@ import ( "net/http" "net/url" "sync" - "sync/atomic" "time" "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" + "github.com/getsentry/sentry-go/report" ) const ( @@ -212,13 +211,13 @@ func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *p return ErrEmptyEnvelope } - // the sync transport needs to attach client reports when available - t.reporter.AttachToEnvelope(envelope) category := categoryFromEnvelope(envelope) if t.disabled(category) { t.reporter.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope) return nil } + // the sync transport needs to attach client reports when available + t.reporter.AttachToEnvelope(envelope) request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) if err != nil { @@ -292,10 +291,6 @@ type AsyncTransport struct { flushRequest chan chan struct{} - sentCount int64 - droppedCount int64 - errorCount int64 - QueueSize int Timeout time.Duration @@ -396,7 +391,6 @@ func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { ) return nil default: - atomic.AddInt64(&t.droppedCount, 1) t.reporter.RecordForEnvelope(report.ReasonQueueOverflow, envelope) return ErrTransportQueueFull } @@ -450,7 +444,7 @@ func (t *AsyncTransport) worker() { if !open { return } - t.processEnvelope(envelope) + t.sendEnvelopeHTTP(envelope) case flushResponse, open := <-t.flushRequest: if !open { return @@ -468,27 +462,21 @@ func (t *AsyncTransport) drainQueue() { if !open { return } - t.processEnvelope(envelope) + t.sendEnvelopeHTTP(envelope) default: return } } } -func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) { - if t.sendEnvelopeHTTP(envelope) { - atomic.AddInt64(&t.sentCount, 1) - } else { - atomic.AddInt64(&t.errorCount, 1) - } -} - -func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { +func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { //nolint: unparam category := categoryFromEnvelope(envelope) if t.isRateLimited(category) { t.reporter.RecordForEnvelope(report.ReasonRateLimitBackoff, envelope) return false } + // attach to envelope after rate-limit check + t.reporter.AttachToEnvelope(envelope) ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() diff --git a/internal/http/transport_test.go b/internal/http/transport_test.go index 6cd400275..febdab219 100644 --- a/internal/http/transport_test.go +++ b/internal/http/transport_test.go @@ -83,7 +83,9 @@ func TestAsyncTransport_SendEnvelope(t *testing.T) { {"attachment", protocol.EnvelopeItemTypeAttachment}, } + var count int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt64(&count, 1) w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -108,14 +110,17 @@ func TestAsyncTransport_SendEnvelope(t *testing.T) { } expectedCount := int64(len(tests)) - if sent := atomic.LoadInt64(&transport.sentCount); sent != expectedCount { + if sent := atomic.LoadInt64(&count); sent != expectedCount { t.Errorf("expected %d sent, got %d", expectedCount, sent) } }) t.Run("server error", func(t *testing.T) { + var requestCount int64 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) + atomic.AddInt64(&requestCount, 1) + status := http.StatusInternalServerError + w.WriteHeader(status) })) defer server.Close() @@ -136,11 +141,8 @@ func TestAsyncTransport_SendEnvelope(t *testing.T) { t.Fatal("Flush timed out") } - if sent := atomic.LoadInt64(&transport.sentCount); sent != 0 { - t.Errorf("expected 0 sent, got %d", sent) - } - if errors := atomic.LoadInt64(&transport.errorCount); errors != 1 { - t.Errorf("expected 1 error, got %d", errors) + if sent := atomic.LoadInt64(&requestCount); sent != 1 { + t.Errorf("expected 1 request, got %d", sent) } }) diff --git a/internal/telemetry/bucketed_buffer.go b/internal/telemetry/bucketed_buffer.go index ea15c7381..064471134 100644 --- a/internal/telemetry/bucketed_buffer.go +++ b/internal/telemetry/bucketed_buffer.go @@ -5,8 +5,9 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/report" ) const ( @@ -142,11 +143,11 @@ func (b *BucketedBuffer[T]) offerToBucket(item T, traceID string) bool { } func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool { - b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldestBucket := b.buckets[b.head] if oldestBucket == nil { + b.recordDroppedItem(item) atomic.AddInt64(&b.dropped, 1) if b.onDropped != nil { b.onDropped(item, "buffer_full_invalid_state") @@ -160,6 +161,7 @@ func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool { atomic.AddInt64(&b.dropped, int64(droppedCount)) if b.onDropped != nil { for _, di := range oldestBucket.items { + b.recordDroppedItem(di) b.onDropped(di, "buffer_full_drop_oldest_bucket") } } @@ -178,12 +180,14 @@ func (b *BucketedBuffer[T]) handleOverflow(item T, traceID string) bool { return true case OverflowPolicyDropNewest: atomic.AddInt64(&b.dropped, 1) + b.recordDroppedItem(item) if b.onDropped != nil { b.onDropped(item, "buffer_full_drop_newest") } return false default: atomic.AddInt64(&b.dropped, 1) + b.recordDroppedItem(item) if b.onDropped != nil { b.onDropped(item, "unknown_overflow_policy") } @@ -401,3 +405,11 @@ func (b *BucketedBuffer[T]) MarkFlushed() { defer b.mu.Unlock() b.lastFlushTime = time.Now() } + +func (b *BucketedBuffer[T]) recordDroppedItem(item T) { + if ti, ok := any(item).(protocol.TelemetryItem); ok { + b.reporter.RecordItem(report.ReasonBufferOverflow, ti) + } else { + b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) + } +} diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 4a7b66eb0..895b60d3b 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -5,8 +5,9 @@ import ( "sync/atomic" "time" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/report" ) const defaultCapacity = 100 @@ -79,7 +80,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { return true } - b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) + b.recordDroppedItem(item) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldItem := b.items[b.head] @@ -349,6 +350,14 @@ func (b *RingBuffer[T]) PollIfReady() []T { return result } +func (b *RingBuffer[T]) recordDroppedItem(item T) { + if ti, ok := any(item).(protocol.TelemetryItem); ok { + b.reporter.RecordItem(report.ReasonBufferOverflow, ti) + } else { + b.reporter.RecordOne(report.ReasonBufferOverflow, b.category) + } +} + type BufferMetrics struct { Category ratelimit.Category `json:"category"` Priority ratelimit.Priority `json:"priority"` diff --git a/internal/telemetry/scheduler.go b/internal/telemetry/scheduler.go index bfb59a32e..8e7a03070 100644 --- a/internal/telemetry/scheduler.go +++ b/internal/telemetry/scheduler.go @@ -8,7 +8,7 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/report" ) const ( @@ -149,6 +149,7 @@ func (s *Scheduler) run() { defer ticker.Stop() clientReportsTicker := time.NewTicker(defaultClientReportsTick) + defer clientReportsTicker.Stop() for { select { case <-ticker.C: @@ -242,7 +243,7 @@ func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category if s.isRateLimited(category) { for _, item := range items { - s.reporter.RecordItem(report.ReasonQueueOverflow, item) + s.reporter.RecordItem(report.ReasonRateLimitBackoff, item) } return } diff --git a/internal/report/aggregator.go b/report/aggregator.go similarity index 96% rename from internal/report/aggregator.go rename to report/aggregator.go index c5ea7ccab..16f18d443 100644 --- a/internal/report/aggregator.go +++ b/report/aggregator.go @@ -34,13 +34,12 @@ func (a *Aggregator) Record(reason DiscardReason, category ratelimit.Category, q key := OutcomeKey{Reason: reason, Category: category} a.mu.Lock() + defer a.mu.Unlock() counter, exists := a.outcomes[key] if !exists { counter = &atomic.Int64{} a.outcomes[key] = counter } - a.mu.Unlock() - counter.Add(quantity) } @@ -93,6 +92,10 @@ func (a *Aggregator) TakeReport() *ClientReport { // RecordForEnvelope records client report outcomes for all items in the envelope. // It inspects envelope item headers to derive categories, span counts, and log byte sizes. func (a *Aggregator) RecordForEnvelope(reason DiscardReason, envelope *protocol.Envelope) { + if a == nil { + return + } + for _, item := range envelope.Items { if item == nil || item.Header == nil { continue @@ -111,6 +114,8 @@ func (a *Aggregator) RecordForEnvelope(reason DiscardReason, envelope *protocol. if item.Header.Length != nil { a.Record(reason, ratelimit.CategoryLogByte, int64(*item.Header.Length)) } + case protocol.EnvelopeItemTypeTraceMetric: + a.RecordOne(reason, ratelimit.CategoryTraceMetric) case protocol.EnvelopeItemTypeCheckIn: a.RecordOne(reason, ratelimit.CategoryMonitor) case protocol.EnvelopeItemTypeAttachment, protocol.EnvelopeItemTypeClientReport: @@ -148,6 +153,10 @@ func (a *Aggregator) RecordItem(reason DiscardReason, item protocol.TelemetryIte // AttachToEnvelope adds a client report to the envelope if the Aggregator has outcomes available. func (a *Aggregator) AttachToEnvelope(envelope *protocol.Envelope) { + if a == nil { + return + } + r := a.TakeReport() if r != nil { rItem, err := r.ToEnvelopeItem() diff --git a/internal/report/outcome.go b/report/outcome.go similarity index 100% rename from internal/report/outcome.go rename to report/outcome.go diff --git a/internal/report/reason.go b/report/reason.go similarity index 100% rename from internal/report/reason.go rename to report/reason.go diff --git a/internal/report/registry.go b/report/registry.go similarity index 100% rename from internal/report/registry.go rename to report/registry.go diff --git a/internal/report/registry_test.go b/report/registry_test.go similarity index 100% rename from internal/report/registry_test.go rename to report/registry_test.go diff --git a/internal/report/report.go b/report/report.go similarity index 100% rename from internal/report/report.go rename to report/report.go diff --git a/scope.go b/scope.go index 2ee134cd1..244703d94 100644 --- a/scope.go +++ b/scope.go @@ -10,7 +10,7 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" + "github.com/getsentry/sentry-go/report" ) // Scope holds contextual data for the current scope. @@ -488,6 +488,11 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) } return nil } + if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { + if client != nil { + client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) + } + } } return event diff --git a/tracing.go b/tracing.go index 9c9a24a43..12166336e 100644 --- a/tracing.go +++ b/tracing.go @@ -14,6 +14,8 @@ import ( "time" "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/getsentry/sentry-go/internal/ratelimit" + "github.com/getsentry/sentry-go/report" ) const ( @@ -429,6 +431,17 @@ func (s *Span) doFinish() { } if !s.Sampled.Bool() { + c := hub.Client() + if c != nil { + if !s.IsTransaction() { + // we count the sampled spans from the transaction root. it is guaranteed that the whole transaction + // would be sampled + return + } + children := s.recorder.children() + c.reporter.RecordOne(report.ReasonSampleRate, ratelimit.CategoryTransaction) + c.reporter.Record(report.ReasonSampleRate, ratelimit.CategorySpan, int64(len(children)+1)) + } return } event := s.toEvent() diff --git a/transport.go b/transport.go index 474186cd2..9d91ba3a5 100644 --- a/transport.go +++ b/transport.go @@ -17,8 +17,8 @@ import ( httpinternal "github.com/getsentry/sentry-go/internal/http" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" - "github.com/getsentry/sentry-go/internal/report" "github.com/getsentry/sentry-go/internal/util" + "github.com/getsentry/sentry-go/report" ) const ( @@ -381,8 +381,7 @@ func (t *HTTPTransport) Configure(options ClientOptions) { return } t.dsn = dsn - - if t.reporter == nil { + if !options.DisableClientReports { t.reporter = report.GetAggregator(options.Dsn) } @@ -563,6 +562,7 @@ func (t *HTTPTransport) Close() { func (t *HTTPTransport) worker() { crTicker := time.NewTicker(defaultClientReportsTick) + defer crTicker.Stop() for b := range t.buffer { // Signal that processing of the current batch has started. close(b.started) @@ -707,8 +707,7 @@ func (t *HTTPSyncTransport) Configure(options ClientOptions) { return } t.dsn = dsn - - if t.reporter == nil { + if !options.DisableClientReports { t.reporter = report.GetAggregator(options.Dsn) } @@ -774,7 +773,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve return } success := util.HandleHTTPResponse(response, identifier) - if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { + if !success && response.StatusCode != http.StatusTooManyRequests { t.reporter.RecordOne(report.ReasonSendError, category) recordSpanOutcome(t.reporter, report.ReasonSendError, event) } From 1b2d4bf3b08c5bd3e887f5b9fc95734ae08535a2 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:58:02 +0100 Subject: [PATCH 24/25] fix records --- internal/http/transport.go | 2 +- internal/telemetry/ring_buffer.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/http/transport.go b/internal/http/transport.go index 2dc64f33c..6279d14cd 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -498,7 +498,7 @@ func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { // identifier := util.EnvelopeIdentifier(envelope) success := util.HandleHTTPResponse(response, identifier) - if !success && response.StatusCode != http.StatusTooManyRequests && response.StatusCode != http.StatusRequestEntityTooLarge { + if !success && response.StatusCode != http.StatusTooManyRequests { t.reporter.RecordForEnvelope(report.ReasonSendError, envelope) } diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index 895b60d3b..b5b366b4c 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -80,7 +80,6 @@ func (b *RingBuffer[T]) Offer(item T) bool { return true } - b.recordDroppedItem(item) switch b.overflowPolicy { case OverflowPolicyDropOldest: oldItem := b.items[b.head] @@ -90,6 +89,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { atomic.AddInt64(&b.dropped, 1) if b.onDropped != nil { + b.recordDroppedItem(oldItem) b.onDropped(oldItem, "buffer_full_drop_oldest") } return true @@ -97,6 +97,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { case OverflowPolicyDropNewest: atomic.AddInt64(&b.dropped, 1) if b.onDropped != nil { + b.recordDroppedItem(item) b.onDropped(item, "buffer_full_drop_newest") } return false @@ -104,6 +105,7 @@ func (b *RingBuffer[T]) Offer(item T) bool { default: atomic.AddInt64(&b.dropped, 1) if b.onDropped != nil { + b.recordDroppedItem(item) b.onDropped(item, "unknown_overflow_policy") } return false From 9851c75f5c741251f96ef2ee042337af9c9578c1 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:47:43 +0100 Subject: [PATCH 25/25] fix: don't gate behind onDropped --- internal/telemetry/ring_buffer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/telemetry/ring_buffer.go b/internal/telemetry/ring_buffer.go index b5b366b4c..2f948efb9 100644 --- a/internal/telemetry/ring_buffer.go +++ b/internal/telemetry/ring_buffer.go @@ -88,24 +88,24 @@ func (b *RingBuffer[T]) Offer(item T) bool { b.tail = (b.tail + 1) % b.capacity atomic.AddInt64(&b.dropped, 1) + b.recordDroppedItem(oldItem) if b.onDropped != nil { - b.recordDroppedItem(oldItem) b.onDropped(oldItem, "buffer_full_drop_oldest") } return true case OverflowPolicyDropNewest: atomic.AddInt64(&b.dropped, 1) + b.recordDroppedItem(item) if b.onDropped != nil { - b.recordDroppedItem(item) b.onDropped(item, "buffer_full_drop_newest") } return false default: atomic.AddInt64(&b.dropped, 1) + b.recordDroppedItem(item) if b.onDropped != nil { - b.recordDroppedItem(item) b.onDropped(item, "unknown_overflow_policy") } return false