Skip to content

Commit d875511

Browse files
authored
feat: add support for client reports (#1192)
1 parent 1b52229 commit d875511

27 files changed

Lines changed: 1119 additions & 205 deletions

client.go

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/getsentry/sentry-go/internal/protocol"
2020
"github.com/getsentry/sentry-go/internal/ratelimit"
2121
"github.com/getsentry/sentry-go/internal/telemetry"
22+
"github.com/getsentry/sentry-go/report"
2223
)
2324

2425
// The identifier of the SDK.
@@ -258,6 +259,8 @@ type ClientOptions struct {
258259
EnableLogs bool
259260
// DisableMetrics controls when metrics should be emitted.
260261
DisableMetrics bool
262+
// DisableClientReports controls when client reports should be emitted.
263+
DisableClientReports bool
261264
// TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced.
262265
// Each element can be either:
263266
// - A single-element slice [code] for a specific status code
@@ -296,6 +299,8 @@ type Client struct {
296299
batchLogger *logBatchProcessor
297300
batchMeter *metricBatchProcessor
298301
telemetryProcessor *telemetry.Processor
302+
reportRecorder report.ClientReportRecorder
303+
reportProvider report.ClientReportProvider
299304
}
300305

301306
// NewClient creates and returns an instance of Client configured using
@@ -393,10 +398,18 @@ func NewClient(options ClientOptions) (*Client, error) {
393398
}
394399

395400
client := Client{
396-
options: options,
397-
dsn: dsn,
398-
sdkIdentifier: sdkIdentifier,
399-
sdkVersion: SDKVersion,
401+
options: options,
402+
dsn: dsn,
403+
sdkIdentifier: sdkIdentifier,
404+
sdkVersion: SDKVersion,
405+
reportRecorder: report.NoopRecorder(),
406+
reportProvider: report.NoopProvider(),
407+
}
408+
409+
if !options.DisableClientReports {
410+
a := report.NewAggregator()
411+
client.reportRecorder = a
412+
client.reportProvider = a
400413
}
401414

402415
client.setupTransport()
@@ -430,7 +443,23 @@ func (client *Client) setupTransport() {
430443
if opts.Dsn == "" {
431444
transport = new(noopTransport)
432445
} else {
433-
transport = NewHTTPTransport()
446+
httpTransport := NewHTTPTransport()
447+
httpTransport.recorder = client.reportRecorder
448+
httpTransport.provider = client.reportProvider
449+
transport = httpTransport
450+
}
451+
} else {
452+
// For known transport types, inject the client report interfaces.
453+
switch tr := transport.(type) {
454+
case *HTTPTransport:
455+
tr.recorder = client.reportRecorder
456+
tr.provider = client.reportProvider
457+
case *HTTPSyncTransport:
458+
tr.recorder = client.reportRecorder
459+
tr.provider = client.reportProvider
460+
case *internalAsyncTransportAdapter:
461+
tr.recorder = client.reportRecorder
462+
tr.provider = client.reportProvider
434463
}
435464
}
436465

@@ -470,23 +499,25 @@ func (client *Client) setupTelemetryProcessor() { // nolint: unused
470499
HTTPProxy: client.options.HTTPProxy,
471500
HTTPSProxy: client.options.HTTPSProxy,
472501
CaCerts: client.options.CaCerts,
502+
Recorder: client.reportRecorder,
503+
Provider: client.reportProvider,
473504
})
474505
client.Transport = &internalAsyncTransportAdapter{transport: transport}
475506

476507
buffers := map[ratelimit.Category]telemetry.Buffer[protocol.TelemetryItem]{
477-
ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0),
478-
ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0),
479-
ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second),
480-
ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0),
481-
ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second),
508+
ratelimit.CategoryError: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryError, 100, telemetry.OverflowPolicyDropOldest, 1, 0, client.reportRecorder),
509+
ratelimit.CategoryTransaction: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTransaction, 1000, telemetry.OverflowPolicyDropOldest, 1, 0, client.reportRecorder),
510+
ratelimit.CategoryLog: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryLog, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, client.reportRecorder),
511+
ratelimit.CategoryMonitor: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryMonitor, 100, telemetry.OverflowPolicyDropOldest, 1, 0, client.reportRecorder),
512+
ratelimit.CategoryTraceMetric: telemetry.NewRingBuffer[protocol.TelemetryItem](ratelimit.CategoryTraceMetric, 10*100, telemetry.OverflowPolicyDropOldest, 100, 5*time.Second, client.reportRecorder),
482513
}
483514

484515
sdkInfo := &protocol.SdkInfo{
485516
Name: client.sdkIdentifier,
486517
Version: client.sdkVersion,
487518
}
488519

489-
client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo)
520+
client.telemetryProcessor = telemetry.NewProcessor(buffers, transport, &client.dsn.Dsn, sdkInfo, client.reportRecorder)
490521
}
491522

492523
func (client *Client) setupIntegrations() {
@@ -572,21 +603,27 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool {
572603
}
573604

574605
if client.options.BeforeSendLog != nil {
606+
approxSize := log.ApproximateSize()
575607
log = client.options.BeforeSendLog(log)
576608
if log == nil {
577609
debuglog.Println("Log dropped due to BeforeSendLog callback.")
610+
client.reportRecorder.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryLog)
611+
client.reportRecorder.Record(report.ReasonBeforeSend, ratelimit.CategoryLogByte, int64(approxSize))
578612
return false
579613
}
580614
}
581615

582616
if client.telemetryProcessor != nil {
583617
if !client.telemetryProcessor.Add(log) {
584618
debuglog.Print("Dropping log: telemetry buffer full or category missing")
619+
// Note: processor tracks client report
585620
return false
586621
}
587622
} else if client.batchLogger != nil {
588623
if !client.batchLogger.Send(log) {
589624
debuglog.Printf("Dropping log [%s]: buffer full", log.Level)
625+
client.reportRecorder.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryLog)
626+
client.reportRecorder.Record(report.ReasonBufferOverflow, ratelimit.CategoryLogByte, int64(log.ApproximateSize()))
590627
return false
591628
}
592629
}
@@ -603,18 +640,21 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool {
603640
metric = client.options.BeforeSendMetric(metric)
604641
if metric == nil {
605642
debuglog.Println("Metric dropped due to BeforeSendMetric callback.")
643+
client.reportRecorder.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTraceMetric)
606644
return false
607645
}
608646
}
609647

610648
if client.telemetryProcessor != nil {
611649
if !client.telemetryProcessor.Add(metric) {
612650
debuglog.Printf("Dropping metric: telemetry buffer full or category missing")
651+
// Note: processor tracks client report
613652
return false
614653
}
615654
} else if client.batchMeter != nil {
616655
if !client.batchMeter.Send(metric) {
617656
debuglog.Printf("Dropping metric %q: buffer full", metric.Name)
657+
client.reportRecorder.RecordOne(report.ReasonBufferOverflow, ratelimit.CategoryTraceMetric)
618658
return false
619659
}
620660
}
@@ -823,6 +863,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
823863
// (errors, messages) are sampled here. Does not apply to check-ins.
824864
if event.Type != transactionType && event.Type != checkInType && !sample(client.options.SampleRate) {
825865
debuglog.Println("Event dropped due to SampleRate hit.")
866+
client.reportRecorder.RecordOne(report.ReasonSampleRate, event.toCategory())
826867
return nil
827868
}
828869

@@ -837,16 +878,25 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
837878
switch event.Type {
838879
case transactionType:
839880
if client.options.BeforeSendTransaction != nil {
840-
if event = client.options.BeforeSendTransaction(event, hint); event == nil {
881+
spanCountBefore := event.GetSpanCount()
882+
event = client.options.BeforeSendTransaction(event, hint)
883+
if event == nil {
841884
debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.")
885+
client.reportRecorder.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryTransaction)
886+
client.reportRecorder.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, int64(spanCountBefore))
842887
return nil
843888
}
889+
// Track spans removed by the callback
890+
if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 {
891+
client.reportRecorder.Record(report.ReasonBeforeSend, ratelimit.CategorySpan, int64(droppedSpans))
892+
}
844893
}
845894
case checkInType: // not a default case, since we shouldn't apply BeforeSend on check-in events
846895
default:
847896
if client.options.BeforeSend != nil {
848897
if event = client.options.BeforeSend(event, hint); event == nil {
849898
debuglog.Println("Event dropped due to BeforeSend callback.")
899+
client.reportRecorder.RecordOne(report.ReasonBeforeSend, ratelimit.CategoryError)
850900
return nil
851901
}
852902
}
@@ -917,20 +967,44 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod
917967

918968
for _, processor := range client.eventProcessors {
919969
id := event.EventID
970+
category := event.toCategory()
971+
spanCountBefore := event.GetSpanCount()
920972
event = processor(event, hint)
921973
if event == nil {
922974
debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id)
975+
client.reportRecorder.RecordOne(report.ReasonEventProcessor, category)
976+
if category == ratelimit.CategoryTransaction {
977+
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore))
978+
}
923979
return nil
924980
}
981+
// Track spans removed by the processor
982+
if category == ratelimit.CategoryTransaction {
983+
if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 {
984+
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans))
985+
}
986+
}
925987
}
926988

927989
for _, processor := range globalEventProcessors {
928990
id := event.EventID
991+
category := event.toCategory()
992+
spanCountBefore := event.GetSpanCount()
929993
event = processor(event, hint)
930994
if event == nil {
931995
debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id)
996+
client.reportRecorder.RecordOne(report.ReasonEventProcessor, category)
997+
if category == ratelimit.CategoryTransaction {
998+
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore))
999+
}
9321000
return nil
9331001
}
1002+
// Track spans removed by the processor
1003+
if category == ratelimit.CategoryTransaction {
1004+
if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 {
1005+
client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans))
1006+
}
1007+
}
9341008
}
9351009

9361010
return event

client_reports_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package sentry
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/getsentry/sentry-go/internal/ratelimit"
13+
"github.com/getsentry/sentry-go/internal/testutils"
14+
"github.com/getsentry/sentry-go/report"
15+
"github.com/google/go-cmp/cmp"
16+
"github.com/google/go-cmp/cmp/cmpopts"
17+
)
18+
19+
// TestClientReports_Integration tests that client reports are properly generated
20+
// and sent when events are dropped for various reasons.
21+
func TestClientReports_Integration(t *testing.T) {
22+
var receivedBodies [][]byte
23+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+
body, _ := io.ReadAll(r.Body)
25+
receivedBodies = append(receivedBodies, body)
26+
w.WriteHeader(http.StatusOK)
27+
_, _ = w.Write([]byte(`{"id":"test-event-id"}`))
28+
}))
29+
defer srv.Close()
30+
31+
dsn := strings.Replace(srv.URL, "//", "//test@", 1) + "/1"
32+
hub := CurrentHub().Clone()
33+
c, err := NewClient(ClientOptions{
34+
Dsn: dsn,
35+
DisableClientReports: false,
36+
SampleRate: 1.0,
37+
BeforeSend: func(event *Event, _ *EventHint) *Event {
38+
if event.Message == "drop-me" {
39+
return nil
40+
}
41+
return event
42+
},
43+
})
44+
if err != nil {
45+
t.Fatalf("Init failed: %v", err)
46+
}
47+
hub.BindClient(c)
48+
defer hub.Flush(testutils.FlushTimeout())
49+
50+
// second client with disabled reports shouldn't affect the first
51+
_, _ = NewClient(ClientOptions{
52+
Dsn: testDsn,
53+
DisableClientReports: true,
54+
})
55+
56+
// simulate dropped events for report outcomes
57+
hub.CaptureMessage("drop-me")
58+
scope := NewScope()
59+
scope.AddEventProcessor(func(event *Event, _ *EventHint) *Event {
60+
if event.Message == "processor-drop" {
61+
return nil
62+
}
63+
return event
64+
})
65+
hub.WithScope(func(s *Scope) {
66+
s.eventProcessors = scope.eventProcessors
67+
hub.CaptureMessage("processor-drop")
68+
})
69+
70+
hub.CaptureMessage("hi") // send an event to capture the report along with it
71+
if !hub.Flush(testutils.FlushTimeout()) {
72+
t.Fatal("Flush timed out")
73+
}
74+
75+
var got report.ClientReport
76+
found := false
77+
for _, b := range receivedBodies {
78+
for _, line := range bytes.Split(b, []byte("\n")) {
79+
if json.Unmarshal(line, &got) == nil && len(got.DiscardedEvents) > 0 {
80+
found = true
81+
break
82+
}
83+
}
84+
if found {
85+
break
86+
}
87+
}
88+
if !found {
89+
t.Fatal("no client report found in envelope bodies")
90+
}
91+
92+
if got.Timestamp.IsZero() {
93+
t.Error("client report missing timestamp")
94+
}
95+
96+
want := []report.DiscardedEvent{
97+
{Reason: report.ReasonBeforeSend, Category: ratelimit.CategoryError, Quantity: 1},
98+
{Reason: report.ReasonEventProcessor, Category: ratelimit.CategoryError, Quantity: 1},
99+
}
100+
if diff := cmp.Diff(want, got.DiscardedEvents, cmpopts.SortSlices(func(a, b report.DiscardedEvent) bool {
101+
return a.Reason < b.Reason
102+
})); diff != "" {
103+
t.Errorf("DiscardedEvents mismatch (-want +got):\n%s", diff)
104+
}
105+
}

0 commit comments

Comments
 (0)