Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0eaa867
feat: add clientreport package
giortzisg Feb 4, 2026
9693573
feat: record client report outcomes
giortzisg Feb 4, 2026
6ee16a2
feat: send client report envelopes
giortzisg Feb 5, 2026
20571fa
feat: hide aggregator API
giortzisg Feb 5, 2026
8165f4e
feat: add EnableClientReports option
giortzisg Feb 5, 2026
5e70ac7
feat: handle client report enabling
giortzisg Feb 5, 2026
5716d73
chore: modify option to match the spec
giortzisg Feb 5, 2026
c944ef9
fix: invert error check
giortzisg Feb 5, 2026
078f772
fix: misc fixes
giortzisg Feb 5, 2026
7ca61ce
fix: do not record server-side rejections
giortzisg Feb 5, 2026
bbbdfd3
feat: record log bytes and span counts
giortzisg Feb 6, 2026
bfd6e17
fix: ignore approximateSize field
giortzisg Feb 6, 2026
6021054
enable client reports by default
giortzisg Feb 6, 2026
dd61bf0
record outcome for scope event processor
giortzisg Feb 9, 2026
670a76b
chore: rename client report package
giortzisg Feb 9, 2026
2fdbb67
feat: make client reports work per-client
giortzisg Feb 10, 2026
464140b
chore: lint
giortzisg Feb 10, 2026
7cb12c4
Merge remote-tracking branch 'origin/master' into feat/client-reports
giortzisg Feb 10, 2026
928c2b7
add test to validate outcomes
giortzisg Feb 11, 2026
b103feb
fix: lint
giortzisg Feb 11, 2026
594e101
add registry to fetch report aggregator
giortzisg Feb 12, 2026
a64cd4c
fix lint
giortzisg Feb 12, 2026
d30c28f
update rate limit on ticker request
giortzisg Feb 12, 2026
6b65696
Merge branch 'master' into feat/client-reports
giortzisg Feb 12, 2026
85a8e5a
misc fixes
giortzisg Feb 13, 2026
1b2d4bf
fix records
giortzisg Feb 13, 2026
9851c75
fix: don't gate behind onDropped
giortzisg Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 60 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/getsentry/sentry-go/internal/protocol"
"github.com/getsentry/sentry-go/internal/ratelimit"
"github.com/getsentry/sentry-go/internal/telemetry"
"github.com/getsentry/sentry-go/report"
)

// The identifier of the SDK.
Expand Down Expand Up @@ -248,6 +249,8 @@ type ClientOptions struct {
EnableLogs bool
// DisableMetrics controls when metrics should be emitted.
DisableMetrics 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
Expand Down Expand Up @@ -286,6 +289,7 @@ type Client struct {
batchLogger *logBatchProcessor
batchMeter *metricBatchProcessor
telemetryProcessor *telemetry.Processor
reporter *report.Aggregator
}

// NewClient creates and returns an instance of Client configured using
Expand Down Expand Up @@ -389,6 +393,12 @@ func NewClient(options ClientOptions) (*Client, error) {
sdkVersion: SDKVersion,
}

if !options.DisableClientReports {
// 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()

// noop Telemetry Buffers and Processor fow now
Expand Down Expand Up @@ -462,11 +472,11 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused
client.Transport = &internalAsyncTransportAdapter{transport: transport}

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](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{
Expand Down Expand Up @@ -560,21 +570,27 @@ 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.")
client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryLog)
client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategoryLogByte, int64(approxSize))
return false
}
}

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)
client.reporter.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryLog)
client.reporter.Record(report.ReasonBufferOverflow, ratelimit.CategoryLogByte, int64(log.ApproximateSize()))
return false
}
}
Expand All @@ -591,18 +607,21 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool {
metric = client.options.BeforeSendMetric(metric)
if metric == nil {
debuglog.Println("Metric dropped due to BeforeSendMetric callback.")
client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTraceMetric)
return false
}
}

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)
client.reporter.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryTraceMetric)
return false
}
}
Expand Down Expand Up @@ -720,6 +739,7 @@ func (client *Client) Close() {
if client.batchMeter != nil {
client.batchMeter.Shutdown()
}
report.UnregisterAggregator(client.options.Dsn)
client.Transport.Close()
}

Expand Down Expand Up @@ -811,6 +831,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.")
client.reporter.RecordOne(report.ReasonSampleRate, ratelimit.CategoryError)
return nil
}

Expand All @@ -825,16 +846,25 @@ 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.")
client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTransaction)
client.reporter.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, int64(spanCountBefore))
return nil
}
// Track spans removed by the callback
if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 {
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
default:
if client.options.BeforeSend != nil {
if event = client.options.BeforeSend(event, hint); event == nil {
debuglog.Println("Event dropped due to BeforeSend callback.")
client.reporter.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryError)
return nil
}
}
Expand Down Expand Up @@ -905,20 +935,44 @@ 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)
client.reporter.RecordOne(report.ReasonEventProcessor, category)
if category == ratelimit.CategoryTransaction {
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 {
client.reporter.Record(report.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)
client.reporter.RecordOne(report.ReasonEventProcessor, category)
if category == ratelimit.CategoryTransaction {
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 {
client.reporter.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans))
}
}
}

return event
Expand Down
105 changes: 105 additions & 0 deletions client_reports_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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/testutils"
"github.com/getsentry/sentry-go/report"
"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, _ *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())

// 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()
scope.AddEventProcessor(func(event *Event, _ *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)
}
}
33 changes: 32 additions & 1 deletion interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading