From b735be70133fbf577297e43f83e2e166bf8d5177 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:38:02 +0200 Subject: [PATCH 1/2] feat: add merging functionality --- client.go | 149 ++++--- client_test.go | 16 +- dynamic_sampling_context.go | 9 + interfaces.go | 7 +- internal/sentrytest/fixture.go | 22 +- .../telemetry/attachments_regression_test.go | 21 +- internal/util/map.go | 17 + log.go | 51 +-- metrics.go | 50 +-- mocks.go | 17 - scope.go | 409 ++++++++++++------ scope_concurrency_test.go | 35 ++ scope_context_test.go | 33 ++ scope_test.go | 105 ++++- tracing.go | 2 +- 15 files changed, 618 insertions(+), 325 deletions(-) diff --git a/client.go b/client.go index 7e6319e83..6b187310b 100644 --- a/client.go +++ b/client.go @@ -102,14 +102,6 @@ type EventProcessor func(event *Event, hint *EventHint) *Event // needing the otel dependency on the root package. type externalContextTraceResolver func(ctx context.Context) (traceID TraceID, spanID SpanID, ok bool) -// EventModifier is the interface that wraps the ApplyToEvent method. -// -// ApplyToEvent changes an event based on external data and/or -// an event hint. -type EventModifier interface { - ApplyToEvent(event *Event, hint *EventHint, client *Client) *Event -} - var globalEventProcessors []EventProcessor // AddGlobalEventProcessor adds processor to the global list of event @@ -645,20 +637,32 @@ func (client *Client) GetDataCollection() DataCollection { return *cloneDataCollection(client.options.DataCollection) } +// captureOptions carries the per-capture values that take part in scope +// merging precedence. It stays private; the public CaptureOption API is +// parsed into this representation by the context capture PR. +type captureOptions struct { + // hint carries metadata to event processors and before-send hooks. + hint *EventHint + // level is an explicit level from the capture call. + level Level +} + // CaptureMessage captures an arbitrary message. -func (client *Client) CaptureMessage(message string, hint *EventHint, scope EventModifier) *EventID { - event := client.EventFromMessage(message, LevelInfo) - return client.CaptureEvent(event, hint, scope) +func (client *Client) CaptureMessage(message string, hint *EventHint, scope *Scope) *EventID { + event := client.eventFromMessage(message) + event.Level = LevelInfo + return client.processEvent(event, scope, captureOptions{hint: hint}) } // CaptureException captures an error. -func (client *Client) CaptureException(exception error, hint *EventHint, scope EventModifier) *EventID { - event := client.EventFromException(exception, LevelError) - return client.CaptureEvent(event, hint, scope) +func (client *Client) CaptureException(exception error, hint *EventHint, scope *Scope) *EventID { + event := client.eventFromException(exception) + event.Level = LevelError + return client.processEvent(event, scope, captureOptions{hint: hint}) } // CaptureCheckIn captures a check in. -func (client *Client) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig, scope EventModifier) *EventID { +func (client *Client) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig, scope *Scope) *EventID { event := client.EventFromCheckIn(checkIn, monitorConfig) if event != nil && event.CheckIn != nil { client.CaptureEvent(event, nil, scope) @@ -672,18 +676,16 @@ func (client *Client) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorCon // The event must already be assembled. Typically, code would instead use // the utility methods like CaptureException. The return value is the // event ID. In case Sentry is disabled or event was dropped, the return value will be nil. -func (client *Client) CaptureEvent(event *Event, hint *EventHint, scope EventModifier) *EventID { - if !client.IsEnabled() { - return nil - } - return client.processEvent(event, hint, scope) +func (client *Client) CaptureEvent(event *Event, hint *EventHint, scope *Scope) *EventID { + return client.processEvent(event, scope, captureOptions{hint: hint}) } -func (client *Client) captureLog(log *Log, _ *Scope) bool { - if !client.IsEnabled() || log == nil { +func (client *Client) captureLog(log *Log, capture signalCaptureContext) bool { + if log == nil { return false } + prepareLog(log, client, capture) if client.options.BeforeSendLog != nil { approxSize := log.ApproximateSize() log = client.options.BeforeSendLog(log) @@ -713,11 +715,16 @@ func (client *Client) captureLog(log *Log, _ *Scope) bool { return true } -func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { - if !client.IsEnabled() || metric == nil { +func (client *Client) recordDiscard(reason report.DiscardReason, category ratelimit.Category, count int64) { + client.reportRecorder.Record(reason, category, count) +} + +func (client *Client) captureMetric(metric *Metric, capture signalCaptureContext) bool { + if metric == nil { return false } + prepareMetric(metric, client, capture) if client.options.BeforeSendMetric != nil { metric = client.options.BeforeSendMetric(metric) if metric == nil { @@ -746,10 +753,7 @@ func (client *Client) captureMetric(metric *Metric, _ *Scope) bool { // Recover captures a panic. // Returns EventID if successfully, or nil if there's no error to recover from. -func (client *Client) Recover(err any, hint *EventHint, scope EventModifier) *EventID { - if !client.IsEnabled() { - return nil - } +func (client *Client) Recover(err any, hint *EventHint, scope *Scope) *EventID { if err == nil { err = recover() } @@ -768,7 +772,7 @@ func (client *Client) RecoverWithContext( ctx context.Context, err any, hint *EventHint, - scope EventModifier, + scope *Scope, ) *EventID { if err == nil { err = recover() @@ -777,25 +781,26 @@ func (client *Client) RecoverWithContext( return nil } - if ctx != nil { - if hint == nil { - hint = &EventHint{} - } - if hint.Context == nil { - hint.Context = ctx + if ctx != nil && (hint == nil || hint.Context == nil) { + resolved := EventHint{} + if hint != nil { + resolved = *hint } + resolved.Context = ctx + hint = &resolved } var event *Event switch err := err.(type) { case error: - event = client.EventFromException(err, LevelFatal) + event = client.eventFromException(err) case string: - event = client.EventFromMessage(err, LevelFatal) + event = client.eventFromMessage(err) default: - event = client.EventFromMessage(fmt.Sprintf("%#v", err), LevelFatal) + event = client.eventFromMessage(fmt.Sprintf("%#v", err)) } - return client.CaptureEvent(event, hint, scope) + event.Level = LevelFatal + return client.processEvent(event, scope, captureOptions{hint: hint}) } // Flush waits until the underlying Transport sends any buffered events to the @@ -810,9 +815,6 @@ func (client *Client) RecoverWithContext( // the network synchronously, configure it to use the HTTPSyncTransport in the // call to Init. func (client *Client) Flush(timeout time.Duration) bool { - if !client.IsEnabled() { - return true - } if client.batchLogger != nil || client.batchMeter != nil || client.telemetryProcessor != nil { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -834,9 +836,6 @@ func (client *Client) Flush(timeout time.Duration) bool { // configure the SDK to use HTTPSyncTransport during initialization with Init. func (client *Client) FlushWithContext(ctx context.Context) bool { - if !client.IsEnabled() { - return true - } if client.batchLogger != nil { client.batchLogger.Flush(ctx.Done()) } @@ -854,9 +853,6 @@ func (client *Client) FlushWithContext(ctx context.Context) bool { // Close should be called after Flush and before terminating the program // otherwise some events may be lost. func (client *Client) Close() { - if !client.IsEnabled() { - return - } if client.telemetryProcessor != nil { client.telemetryProcessor.Close(5 * time.Second) } @@ -871,12 +867,17 @@ func (client *Client) Close() { // EventFromMessage creates an event from the given message string. func (client *Client) EventFromMessage(message string, level Level) *Event { + event := client.eventFromMessage(message) + event.Level = level + return event +} + +func (client *Client) eventFromMessage(message string) *Event { if message == "" { err := usageError{fmt.Errorf("%s called with empty message", callerFunctionName())} - return client.EventFromException(err, level) + return client.eventFromException(err) } event := NewEvent() - event.Level = level event.Message = message if client.options.AttachStacktrace { @@ -892,8 +893,13 @@ func (client *Client) EventFromMessage(message string, level Level) *Event { // EventFromException creates a new Sentry event from the given `error` instance. func (client *Client) EventFromException(exception error, level Level) *Event { - event := NewEvent() + event := client.eventFromException(exception) event.Level = level + return event +} + +func (client *Client) eventFromException(exception error) *Event { + event := NewEvent() err := exception if err == nil { @@ -946,10 +952,14 @@ func (client *Client) GetSDKIdentifier() string { return client.sdkIdentifier } -func (client *Client) processEvent(event *Event, hint *EventHint, scope EventModifier) *EventID { +func (client *Client) GetSDKVersion() string { + return client.sdkVersion +} + +func (client *Client) processEvent(event *Event, scope *Scope, opts captureOptions) *EventID { if event == nil { err := usageError{fmt.Errorf("%s called with nil event", callerFunctionName())} - return client.CaptureException(err, hint, scope) + return client.CaptureException(err, opts.hint, scope) } // Transactions are sampled by options.TracesSampleRate or @@ -961,11 +971,12 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod return nil } - if event = client.prepareEvent(event, hint, scope); event == nil { + if event = client.prepareEvent(event, scope, opts); event == nil { return nil } // Apply beforeSend* processors + hint := opts.hint if hint == nil { hint = &EventHint{} } @@ -1007,7 +1018,22 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod return &event.EventID } -func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventModifier) *Event { +// applyScopeChain merges the passed scope and global scope into event, resolves the trace +// context, and runs the scope event processors. It returns nil when a processor drops the event. +func applyScopeChain(event *Event, client *Client, scope *Scope, opts captureOptions) *Event { + client = normalizeClient(client) + state := resolveCaptureState(event, scope) + + var ctx context.Context + if opts.hint != nil { + ctx = opts.hint.Context + } + applyTraceToEvent(event, resolveTrace(scope, client, ctx)) + + return state.applyToEvent(event, opts.hint, opts, client) +} + +func (client *Client) prepareEvent(event *Event, scope *Scope, opts captureOptions) *Event { if event.EventID == "" { // TODO set EventID when the event is created, same as in other SDKs. It's necessary for profileTransaction.ID. event.EventID = EventID(uuid()) @@ -1017,10 +1043,6 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod event.Timestamp = time.Now() } - if event.Level == "" { - event.Level = LevelInfo - } - if event.ServerName == "" { event.ServerName = client.options.ServerName @@ -1052,13 +1074,12 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod }}, } - if scope != nil { - event = scope.ApplyToEvent(event, hint, client) - if event == nil { - return nil - } + event = applyScopeChain(event, client, scope, opts) + if event == nil { + return nil } + hint := opts.hint for _, processor := range client.eventProcessors { id := event.EventID category := event.toCategory() diff --git a/client_test.go b/client_test.go index d83c15a2c..acdc38d79 100644 --- a/client_test.go +++ b/client_test.go @@ -35,7 +35,7 @@ func TestNewClientAllowsEmptyDSN(t *testing.T) { t.Fatalf("expected no error when creating client without a DNS but got %v", err) } - client.CaptureException(errors.New("custom error"), nil, &MockScope{}) + client.CaptureException(errors.New("custom error"), nil, NewScope()) assertEqual(t, transport.lastEvent.Exception[0].Value, "custom error") } @@ -51,8 +51,8 @@ func (e customComplexError) AnswerToLife() string { return "42" } -func setupClientTest() (*Client, *MockScope, *MockTransport) { - scope := &MockScope{} +func setupClientTest() (*Client, *Scope, *MockTransport) { + scope := NewScope() transport := &MockTransport{} client, _ := NewClient(ClientOptions{ Dsn: "http://whatever@example.com/1337", @@ -511,7 +511,9 @@ func TestSampleRateCanDropEvent(t *testing.T) { func TestApplyToScopeCanDropEvent(t *testing.T) { client, scope, transport := setupClientTest() - scope.shouldDropEvent = true + scope.AddEventProcessor(func(_ *Event, _ *EventHint) *Event { + return nil + }) client.AddEventProcessor(func(event *Event, _ *EventHint) *Event { if event == nil { @@ -644,7 +646,7 @@ func TestIgnoreErrors(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - scope := &MockScope{} + scope := NewScope() transport := &MockTransport{} client, err := NewClient(ClientOptions{ Transport: transport, @@ -946,7 +948,7 @@ func BenchmarkProcessEvent(b *testing.B) { b.Fatal(err) } for i := 0; i < b.N; i++ { - c.processEvent(&Event{}, nil, nil) + c.processEvent(&Event{}, nil, captureOptions{}) } } @@ -1105,7 +1107,7 @@ func TestTelemetryEnvelopeCarriesIntegrations(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { client.Close() }) - client.CaptureMessage("ping", nil, &MockScope{}) + client.CaptureMessage("ping", nil, NewScope()) require.True(t, client.Flush(testutils.FlushTimeout()), "flush timed out") select { diff --git a/dynamic_sampling_context.go b/dynamic_sampling_context.go index 96e3f54c9..0441b7a45 100644 --- a/dynamic_sampling_context.go +++ b/dynamic_sampling_context.go @@ -116,6 +116,15 @@ func (d DynamicSamplingContext) String() string { return baggage.String() } +func dynamicSamplingContextFromScope(scope *Scope, client *Client) DynamicSamplingContext { + if scope == nil { + return DynamicSamplingContextFromScope(nil, client) + } + scope.mu.RLock() + defer scope.mu.RUnlock() + return DynamicSamplingContextFromScope(scope, client) +} + // DynamicSamplingContextFromScope Constructs a new DynamicSamplingContext using a scope and client. Accessing // fields on the scope are not thread safe, and this function should only be // called within scope methods. diff --git a/interfaces.go b/interfaces.go index 4d14df65b..da89d1766 100644 --- a/interfaces.go +++ b/interfaces.go @@ -156,7 +156,7 @@ type MeterOption func(*meterOptions) type meterOptions struct { unit string scope *Scope - attributes map[string]attribute.Value + attributes []attribute.Builder } // WithUnit sets the unit for the metric (e.g., "millisecond", "byte"). @@ -176,15 +176,12 @@ func WithScopeOverride(scope *Scope) MeterOption { // WithAttributes sets attributes for the metric. func WithAttributes(attrs ...attribute.Builder) MeterOption { return func(o *meterOptions) { - if o.attributes == nil { - o.attributes = make(map[string]attribute.Value, len(attrs)) - } for _, a := range attrs { if a.Value.Type() == attribute.INVALID { debuglog.Printf("invalid attribute: %v", a) continue } - o.attributes[a.Key] = a.Value + o.attributes = append(o.attributes, a) } } } diff --git a/internal/sentrytest/fixture.go b/internal/sentrytest/fixture.go index 478aac283..c7cd6fea3 100644 --- a/internal/sentrytest/fixture.go +++ b/internal/sentrytest/fixture.go @@ -10,6 +10,7 @@ package sentrytest import ( "context" + "errors" "testing" "testing/synctest" @@ -241,14 +242,25 @@ func (f *Fixture) AssertHubIsolation(requestHub *sentry.Hub) { f.Hub.Scope().SetTag(sentinel, "leaked") defer f.Hub.Scope().RemoveTag(sentinel) - // Apply the request scope to a probe event to read its tags. - probe := &sentry.Event{} - applied := requestHub.Scope().ApplyToEvent(probe, nil, nil) - if applied == nil { + transport := &sentry.MockTransport{} + probeClient, err := sentry.NewClient(sentry.ClientOptions{Dsn: testDsn, Transport: transport}) + if err != nil { + f.T.Errorf("probe client: %v", err) + return + } + if eventID := probeClient.CaptureException(errIsolationProbe, nil, requestHub.Scope()); eventID == nil { f.T.Error("event dropped by event processor") return } - if _, ok := applied.Tags[sentinel]; ok { + + events := transport.Events() + if len(events) == 0 { + f.T.Error("probe event was not delivered") + return + } + if _, ok := events[len(events)-1].Tags[sentinel]; ok { f.T.Error("scope mutation leaked into request hub; scopes are not independent") } } + +var errIsolationProbe = errors.New("sentrytest isolation probe") diff --git a/internal/telemetry/attachments_regression_test.go b/internal/telemetry/attachments_regression_test.go index 048003062..2957a3651 100644 --- a/internal/telemetry/attachments_regression_test.go +++ b/internal/telemetry/attachments_regression_test.go @@ -13,10 +13,6 @@ import ( ) func TestProcessorFlush_EnvelopeCarriesScopeAttachments(t *testing.T) { - event := sentry.NewEvent() - event.Message = "test with attachment" - event.Level = sentry.LevelInfo - scope := sentry.NewScope() scope.AddAttachment(&sentry.Attachment{ Filename: "test.txt", @@ -24,7 +20,22 @@ func TestProcessorFlush_EnvelopeCarriesScopeAttachments(t *testing.T) { Payload: []byte("hello world"), }) - event = scope.ApplyToEvent(event, nil, nil) + mockTransport := &sentry.MockTransport{} + client, err := sentry.NewClient(sentry.ClientOptions{ + Dsn: "https://whatever@sentry.io/1337", + Transport: mockTransport, + }) + require.NoError(t, err) + event := sentry.NewEvent() + event.Message = "test with attachment" + event.Level = sentry.LevelInfo + require.NotNil(t, client.CaptureEvent(event, nil, scope)) + require.True(t, client.Flush(testutils.FlushTimeout()), "flush timed out") + client.Close() + + events := mockTransport.Events() + require.Len(t, events, 1, "expected a single captured event") + event = events[0] transport := &testutils.MockTelemetryTransport{} processor := telemetry.NewProcessor( diff --git a/internal/util/map.go b/internal/util/map.go index 7ca9d17a8..07f9a5749 100644 --- a/internal/util/map.go +++ b/internal/util/map.go @@ -2,6 +2,23 @@ package util import "sync" +// FillMap copies keys from src that are absent in dst. It allocates dst when +// needed and never aliases src. +func FillMap[M ~map[K]V, K comparable, V any](dst M, src M) M { + if len(src) == 0 { + return dst + } + if dst == nil { + dst = make(M, len(src)) + } + for k, v := range src { + if _, ok := dst[k]; !ok { + dst[k] = v + } + } + return dst +} + type SyncMap[K comparable, V any] struct { m sync.Map } diff --git a/log.go b/log.go index d60620bda..92b982ba3 100644 --- a/log.go +++ b/log.go @@ -60,19 +60,20 @@ func NewLogger(ctx context.Context) Logger { // nolint: dupl } client := hub.Client() - if client.IsEnabled() && !client.options.DisableLogs { + options := client.options + if !options.DisableLogs { // Build default attrs - serverAddr := client.options.ServerName + serverAddr := options.ServerName if serverAddr == "" { serverAddr, _ = os.Hostname() } defaults := map[string]string{ - "sentry.release": client.options.Release, - "sentry.environment": client.options.Environment, + "sentry.release": options.Release, + "sentry.environment": options.Environment, "sentry.server.address": serverAddr, - "sentry.sdk.name": client.sdkIdentifier, - "sentry.sdk.version": client.sdkVersion, + "sentry.sdk.name": client.GetSDKIdentifier(), + "sentry.sdk.version": client.GetSDKVersion(), } defaultAttrs := make(map[string]attribute.Value, len(defaults)) @@ -111,33 +112,11 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me hub = l.hub } client := hub.Client() - if !client.IsEnabled() { - return - } - scope := hub.Scope() - traceID, spanID := resolveTrace(scope, client, ctx, l.ctx) - - // Pre-allocate with capacity hint to avoid map growth reallocations - estimatedCap := len(l.defaultAttributes) + len(entryAttrs) + len(args) + 8 // scope ~3 + instance ~5 - attrs := make(map[string]attribute.Value, estimatedCap) - - // attribute precedence: default -> scope -> instance (from SetAttrs) -> entry-specific - for k, v := range l.defaultAttributes { - attrs[k] = v - } - scope.populateAttrs(attrs) - l.mu.RLock() - for k, v := range l.attributes { - attrs[k] = v - } + attrs := copySignalAttributes(entryAttrs, l.attributes, l.defaultAttributes) l.mu.RUnlock() - for k, v := range entryAttrs { - attrs[k] = v - } - body := message if format { body = fmt.Sprintf(message, args...) @@ -152,21 +131,25 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me log := &Log{ Timestamp: time.Now(), - TraceID: traceID, - SpanID: spanID, Level: level, Severity: severity, Body: body, Attributes: attrs, } - log.approximateSize = computeLogSize(log) - - client.captureLog(log, scope) + client.captureLog(log, signalCaptureContext{scope: scope, ctx: ctx, fallback: l.ctx}) if client.options.Debug { debuglog.Print(body) } } +func prepareLog(log *Log, client *Client, capture signalCaptureContext) { + trace := resolveTrace(capture.scope, client, capture.ctx, capture.fallback) + log.TraceID = trace.traceID + log.SpanID = trace.telemetrySpanID + log.Attributes = mergeScopeAttributes(log.Attributes, capture.scope) + log.approximateSize = computeLogSize(log) +} + func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) { l.mu.Lock() defer l.mu.Unlock() diff --git a/metrics.go b/metrics.go index bd36247c0..88a8ec644 100644 --- a/metrics.go +++ b/metrics.go @@ -55,19 +55,20 @@ func NewMeter(ctx context.Context) Meter { hub = CurrentHub() } client := hub.Client() - if client.IsEnabled() && !client.options.DisableMetrics { + options := client.options + if !options.DisableMetrics { // build default attrs - serverAddr := client.options.ServerName + serverAddr := options.ServerName if serverAddr == "" { serverAddr, _ = os.Hostname() } defaults := map[string]string{ - "sentry.release": client.options.Release, - "sentry.environment": client.options.Environment, + "sentry.release": options.Release, + "sentry.environment": options.Environment, "sentry.server.address": serverAddr, - "sentry.sdk.name": client.sdkIdentifier, - "sentry.sdk.version": client.sdkVersion, + "sentry.sdk.name": client.GetSDKIdentifier(), + "sentry.sdk.version": client.GetSDKVersion(), } defaultAttrs := make(map[string]attribute.Value) @@ -98,7 +99,7 @@ type sentryMeter struct { mu sync.RWMutex } -func (m *sentryMeter) emit(ctx context.Context, metricType MetricType, name string, value MetricValue, unit string, attributes map[string]attribute.Value, customScope *Scope) { +func (m *sentryMeter) emit(ctx context.Context, metricType MetricType, name string, value MetricValue, unit string, attributes []attribute.Builder, customScope *Scope) { if name == "" { debuglog.Println("empty name provided, dropping metric") return @@ -110,40 +111,16 @@ func (m *sentryMeter) emit(ctx context.Context, metricType MetricType, name stri } client := hub.Client() - if !client.IsEnabled() { - return - } - scope := hub.Scope() if customScope != nil { scope = customScope } - traceID, spanID := resolveTrace(scope, client, ctx, m.ctx) - - // Pre-allocate with capacity hint to avoid map growth reallocations - estimatedCap := len(m.defaultAttributes) + len(attributes) + 8 // scope ~3 + call-specific ~5 - attrs := make(map[string]attribute.Value, estimatedCap) - - // attribute precedence: default -> scope -> instance (from SetAttrs) -> entry-specific - for k, v := range m.defaultAttributes { - attrs[k] = v - } - scope.populateAttrs(attrs) - m.mu.RLock() - for k, v := range m.attributes { - attrs[k] = v - } + attrs := buildMetricAttributes(attributes, m.attributes, m.defaultAttributes) m.mu.RUnlock() - for k, v := range attributes { - attrs[k] = v - } - metric := &Metric{ Timestamp: time.Now(), - TraceID: traceID, - SpanID: spanID, Type: metricType, Name: name, Value: value, @@ -151,11 +128,18 @@ func (m *sentryMeter) emit(ctx context.Context, metricType MetricType, name stri Attributes: attrs, } - if client.captureMetric(metric, scope) && client.options.Debug { + if client.captureMetric(metric, signalCaptureContext{scope: scope, ctx: ctx, fallback: m.ctx}) && client.options.Debug { debuglog.Printf("Metric %s [%s]: %v %s", metricType, name, value.AsInterface(), unit) } } +func prepareMetric(metric *Metric, client *Client, capture signalCaptureContext) { + trace := resolveTrace(capture.scope, client, capture.ctx, capture.fallback) + metric.TraceID = trace.traceID + metric.SpanID = trace.telemetrySpanID + metric.Attributes = mergeScopeAttributes(metric.Attributes, capture.scope) +} + // WithCtx returns a new Meter that uses the given context for trace/span association. func (m *sentryMeter) WithCtx(ctx context.Context) Meter { m.mu.RLock() diff --git a/mocks.go b/mocks.go index 9492b1c24..e234ec083 100644 --- a/mocks.go +++ b/mocks.go @@ -6,23 +6,6 @@ import ( "time" ) -// MockScope implements [Scope] for use in tests. -type MockScope struct { - breadcrumb *Breadcrumb - shouldDropEvent bool -} - -func (scope *MockScope) AddBreadcrumb(breadcrumb *Breadcrumb, _ int) { - scope.breadcrumb = breadcrumb -} - -func (scope *MockScope) ApplyToEvent(event *Event, _ *EventHint, _ *Client) *Event { - if scope.shouldDropEvent { - return nil - } - return event -} - // MockTransport implements [Transport] for use in tests. type MockTransport struct { mu sync.Mutex diff --git a/scope.go b/scope.go index 1caa4e9b3..ec3e9e7e6 100644 --- a/scope.go +++ b/scope.go @@ -12,6 +12,7 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/httputils" "github.com/getsentry/sentry-go/internal/ratelimit" + "github.com/getsentry/sentry-go/internal/util" "github.com/getsentry/sentry-go/report" ) @@ -106,7 +107,7 @@ func (scope *Scope) SetClient(client *Client) { scope.mu.Lock() defer scope.mu.Unlock() - scope.clientOverride = normalizeClient(client) + scope.clientOverride = client } func (scope *Scope) clientOverrideSnapshot() *Client { @@ -134,6 +135,9 @@ func (scope *Scope) Client() *Client { } func (scope *Scope) setLastEventID(id EventID) { + if scope == nil { + return + } scope.mu.Lock() defer scope.mu.Unlock() @@ -372,7 +376,11 @@ func (scope *Scope) Clear() { scope.mu.Lock() defer scope.mu.Unlock() + propagationContext := scope.propagationContext + span := scope.span scope.scopeData = newScopeData() + scope.propagationContext = propagationContext + scope.span = span } // AddEventProcessor adds an event processor to the current scope. @@ -383,105 +391,142 @@ func (scope *Scope) AddEventProcessor(processor EventProcessor) { scope.eventProcessors = append(scope.eventProcessors, processor) } -// 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 { //nolint:gocyclo - scope.mu.RLock() - defer scope.mu.RUnlock() - - if len(scope.breadcrumbs) > 0 { - event.Breadcrumbs = append(event.Breadcrumbs, scope.breadcrumbs...) +// captureState retains only data that must be used after Scope locks are +// released. Maps and replacement fields are applied directly to the event. +type captureState struct { + level Level + request *http.Request + requestBody interface { + Bytes() []byte + Overflow() bool } + breadcrumbs []*Breadcrumb + attachments []*Attachment + processors []EventProcessor +} - if len(scope.attachments) > 0 { - event.Attachments = append(event.Attachments, scope.attachments...) +// resolveCaptureState applies the operation Scope first so it wins, then fills +// gaps from GlobalScope. Each Scope is read under one short lock. +func resolveCaptureState(event *Event, scope *Scope) captureState { + var state captureState + global := GlobalScope() + if scope != nil && scope != global { + state.mergeScope(event, scope, false) } + state.mergeScope(event, global, true) + return state +} - if len(scope.tags) > 0 { - if event.Tags == nil { - event.Tags = make(map[string]string, len(scope.tags)) - } +func (state *captureState) mergeScope(event *Event, scope *Scope, prepend bool) { + scope.mu.RLock() + defer scope.mu.RUnlock() - for key, value := range scope.tags { - event.Tags[key] = value - } + if event.User.IsEmpty() && !scope.user.IsEmpty() { + event.User = scope.user } - - if len(scope.contexts) > 0 { + event.Tags = util.FillMap(event.Tags, scope.tags) + for key, value := range scope.contexts { + if key == "trace" { + continue + } if event.Contexts == nil { - event.Contexts = make(map[string]Context) + event.Contexts = make(map[string]Context, len(scope.contexts)) } - - for key, value := range scope.contexts { - if key == "trace" && event.Type == transactionType { - // Do not override trace context of - // transactions, otherwise it breaks the - // transaction event representation. - // For error events, the trace context is used - // to link errors and traces/spans in Sentry. - continue - } - - // Ensure we are not overwriting event fields - if _, ok := event.Contexts[key]; !ok { - event.Contexts[key] = cloneContext(value) - } + if _, ok := event.Contexts[key]; !ok { + event.Contexts[key] = cloneContext(value) } } - - if event.Contexts == nil { - event.Contexts = make(map[string]Context) + if len(event.Fingerprint) == 0 && len(scope.fingerprint) > 0 { + event.Fingerprint = append(event.Fingerprint, scope.fingerprint...) + } + if state.level == "" { + state.level = scope.level + } + if state.request == nil && scope.request != nil { + state.request = scope.request + state.requestBody = scope.requestBody } - if scope.span != nil { - if _, ok := event.Contexts["trace"]; !ok { - event.Contexts["trace"] = scope.span.traceContext().Map() - } - - transaction := scope.span.GetTransaction() - if transaction != nil { - event.sdkMetaData.dsc = DynamicSamplingContextFromTransaction(transaction) + if len(scope.breadcrumbs) > 0 { + if len(state.breadcrumbs) == 0 { + state.breadcrumbs = append([]*Breadcrumb(nil), scope.breadcrumbs...) + } else { + state.breadcrumbs = mergeTwoBreadcrumbLayers(scope.breadcrumbs, state.breadcrumbs) } + } + if prepend { + state.attachments = prependSlice(scope.attachments, state.attachments) + state.processors = prependSlice(scope.eventProcessors, state.processors) } else { - event.Contexts["trace"] = scope.propagationContext.Map() + state.attachments = append(state.attachments, scope.attachments...) + state.processors = append(state.processors, scope.eventProcessors...) + } +} - dsc := scope.propagationContext.DynamicSamplingContext - if !dsc.HasEntries() && client.IsEnabled() { - dsc = DynamicSamplingContextFromScope(scope, client) - } - event.sdkMetaData.dsc = dsc +func prependSlice[T any](prefix, values []T) []T { + if len(prefix) == 0 { + return values } + result := make([]T, 0, len(prefix)+len(values)) + result = append(result, prefix...) + return append(result, values...) +} - // If an external trace resolver is registered (e.g. OTel), override - // trace/span IDs from the hint context or the scope's request context. - if client.IsEnabled() { - var ctx context.Context - if hint != nil { - ctx = hint.Context - } - if ctx == nil && scope.request != nil { - ctx = scope.request.Context() - } - if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); event.Type != transactionType && ok { - traceCtx := event.Contexts["trace"] - traceCtx["trace_id"] = traceID.String() - traceCtx["span_id"] = spanID.String() - } +func setAttributeIfAbsent(attrs map[string]attribute.Value, key, value string) { + if value == "" { + return + } + if _, ok := attrs[key]; !ok { + attrs[key] = attribute.StringValue(value) } +} - if event.User.IsEmpty() { - event.User = scope.user +func copySignalAttributes(specific, instance, defaults map[string]attribute.Value) map[string]attribute.Value { + attrs := make(map[string]attribute.Value, len(specific)+len(instance)+len(defaults)+8) + attrs = util.FillMap(attrs, specific) + attrs = util.FillMap(attrs, instance) + return util.FillMap(attrs, defaults) +} + +func buildMetricAttributes(specific []attribute.Builder, instance, defaults map[string]attribute.Value) map[string]attribute.Value { + attrs := make(map[string]attribute.Value, len(specific)+len(instance)+len(defaults)+8) + for _, attr := range specific { + attrs[attr.Key] = attr.Value } + attrs = util.FillMap(attrs, instance) + return util.FillMap(attrs, defaults) +} - if len(event.Fingerprint) == 0 { - event.Fingerprint = append(event.Fingerprint, scope.fingerprint...) +func mergeScopeAttributes(attrs map[string]attribute.Value, scope *Scope) map[string]attribute.Value { + global := GlobalScope() + var user User + if scope != nil && scope != global { + scope.mu.RLock() + attrs = util.FillMap(attrs, scope.attributes) + user = scope.user + scope.mu.RUnlock() } - if scope.level != "" { - event.Level = scope.level + global.mu.RLock() + attrs = util.FillMap(attrs, global.attributes) + if user.IsEmpty() { + user = global.user } + global.mu.RUnlock() + + setAttributeIfAbsent(attrs, "user.id", user.ID) + setAttributeIfAbsent(attrs, "user.name", user.Name) + setAttributeIfAbsent(attrs, "user.email", user.Email) + return attrs +} - if event.Request == nil && scope.request != nil { - event.Request = newRequest(scope.request, client) +// applyToEvent handles capture work that must run after Scope locks are +// released, including callbacks and request conversion. +func (state captureState) applyToEvent(event *Event, hint *EventHint, opts captureOptions, client *Client) *Event { + event.Attachments = append(event.Attachments, state.attachments...) + + if event.Request == nil && state.request != nil { + event.Request = newRequest(state.request, client) // NOTE: The SDK does not attempt to send partial request body data. // // The reason being that Sentry's ingest pipeline and UI are optimized @@ -492,36 +537,90 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client) // Users can still send more data along their events if they want to, // for example using Event.Contexts. dc := client.GetDataCollection() - if scope.requestBody != nil && !scope.requestBody.Overflow() && dc.CollectHTTPBody(BodyIncomingRequest) { - event.Request.Data = dc.FilterHTTPBody(scope.requestBody.Bytes(), scope.request.Header.Get("Content-Type")) + body := state.requestBody + if body != nil && !body.Overflow() && dc.CollectHTTPBody(BodyIncomingRequest) { + event.Request.Data = dc.FilterHTTPBody(body.Bytes(), state.request.Header.Get("Content-Type")) } } - for _, processor := range scope.eventProcessors { + event.Breadcrumbs = mergeBreadcrumbs(client.options.MaxBreadcrumbs, event.Breadcrumbs, [][]*Breadcrumb{state.breadcrumbs}) + event.Level = resolveLevel(event, opts, state.level) + + for _, processor := range state.processors { 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) - if client.IsEnabled() { - client.reportRecorder.RecordOne(report.ReasonEventProcessor, category) - if category == ratelimit.CategoryTransaction { - client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) - } + client.recordDiscard(report.ReasonEventProcessor, category, 1) + if category == ratelimit.CategoryTransaction { + client.recordDiscard(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(spanCountBefore)) } return nil } if droppedSpans := spanCountBefore - event.GetSpanCount(); droppedSpans > 0 { - if client.IsEnabled() { - client.reportRecorder.Record(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) - } + client.recordDiscard(report.ReasonEventProcessor, ratelimit.CategorySpan, int64(droppedSpans)) } } return event } +func resolveLevel(event *Event, opts captureOptions, scopeLevel Level) Level { + switch { + case event.Level != "": + return event.Level + case opts.level != "": + return opts.level + case scopeLevel != "" && event.Type != transactionType: + return scopeLevel + default: + return LevelInfo + } +} + +func mergeBreadcrumbs(limit int, own []*Breadcrumb, layers [][]*Breadcrumb) []*Breadcrumb { + if limit < 0 { + return nil + } + if limit == 0 { + limit = defaultMaxBreadcrumbs + } + + merged := own + for _, layer := range layers { + merged = mergeTwoBreadcrumbLayers(merged, layer) + } + if len(merged) > limit { + merged = merged[len(merged)-limit:] + } + return merged +} + +func mergeTwoBreadcrumbLayers(a, b []*Breadcrumb) []*Breadcrumb { + if len(a) == 0 { + return b + } + if len(b) == 0 { + return a + } + out := make([]*Breadcrumb, 0, len(a)+len(b)) + i, j := 0, 0 + for i < len(a) && j < len(b) { + if b[j].Timestamp.Before(a[i].Timestamp) { + out = append(out, b[j]) + j++ + } else { + out = append(out, a[i]) + i++ + } + } + out = append(out, a[i:]...) + out = append(out, b[j:]...) + return out +} + // cloneContext returns a new context with keys and values copied from the passed one. // // Note: a new Context (map) is returned, but the function does NOT do @@ -535,30 +634,10 @@ func cloneContext(c Context) Context { return res } -func (scope *Scope) populateAttrs(attrs map[string]attribute.Value) { - if scope == nil { - return - } - - scope.mu.RLock() - defer scope.mu.RUnlock() - - // Add user-related attributes - if !scope.user.IsEmpty() { - if scope.user.ID != "" { - attrs["user.id"] = attribute.StringValue(scope.user.ID) - } - if scope.user.Name != "" { - attrs["user.name"] = attribute.StringValue(scope.user.Name) - } - if scope.user.Email != "" { - attrs["user.email"] = attribute.StringValue(scope.user.Email) - } - } - - for k, v := range scope.attributes { - attrs[k] = v - } +type signalCaptureContext struct { + scope *Scope + ctx context.Context + fallback context.Context } // hubFromContexts is a helper to return the first hub found in the given contexts. @@ -574,49 +653,105 @@ func hubFromContexts(ctxs ...context.Context) *Hub { return nil } -// resolveTrace resolves trace ID and span ID from the given scope and contexts. +// resolveTrace resolves trace IDs and dynamic sampling context from the given +// contexts and scope. It is the single trace-policy function used by every +// signal. The precedence is external resolver, span in a supplied context, +// scope span, then scope propagation context. // -// The resolution order follows a most-specific-to-least-specific pattern: -// 1. If an external trace resolver was registered (eg. OTel), we prioritise trace context -// information from that -// 2. Check for span directly in contexts (SpanFromContext) - this is the most specific -// source as it represents a span explicitly attached to the current operation's context -// 3. Check scope's span - provides access to span set on the hub's scope -// 4. Fall back to scope's propagation context trace ID -// -// This ordering ensures we always use the most contextually relevant tracing information. -// For example, if a specific span is active for an operation, we use that span's trace/span IDs -// rather than accidentally using a different span that might be set on the hub's scope. -func resolveTrace(scope *Scope, client *Client, ctxs ...context.Context) (traceID TraceID, spanID SpanID) { - var span *Span +// TODO: this should be removed when the span API is introduced. Currently kept for compatibility. The span +// and trace should only be resolved through context. +func resolveTrace(scope *Scope, client *Client, ctxs ...context.Context) traceResolution { + client = normalizeClient(client) + var ( + resolved traceResolution + span *Span + external bool + ) for _, ctx := range ctxs { if ctx == nil { continue } - if client.IsEnabled() { - if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); ok { - return traceID, spanID - } + if traceID, spanID, ok := client.externalTraceContextFromContext(ctx); ok { + resolved.traceID, resolved.spanID, resolved.telemetrySpanID = traceID, spanID, spanID + resolved.valid, resolved.external, external = true, true, true + break } if span = SpanFromContext(ctx); span != nil { break } } - if scope != nil { - scope.mu.RLock() - if span == nil { - span = scope.span + if scope == nil { + return resolved + } + + scope.mu.RLock() + scopeSpan := scope.span + propagation := scope.propagationContext + request := scope.request + scope.mu.RUnlock() + + // The request context is a fallback caller context. + if !external && span == nil && request != nil { + if traceID, spanID, ok := client.externalTraceContextFromContext(request.Context()); ok { + resolved.traceID, resolved.spanID, resolved.telemetrySpanID = traceID, spanID, spanID + resolved.valid, resolved.external, external = true, true, true } + } + if span == nil && !external { + span = scopeSpan + } + + if !external { if span != nil { - traceID = span.TraceID - spanID = span.SpanID + resolved.traceID, resolved.spanID, resolved.telemetrySpanID = span.TraceID, span.SpanID, span.SpanID + resolved.valid = true } else { - traceID = scope.propagationContext.TraceID + resolved.traceID, resolved.spanID = propagation.TraceID, propagation.SpanID + resolved.valid = propagation.TraceID != (TraceID{}) } - scope.mu.RUnlock() } - return traceID, spanID + if span != nil { + if transaction := span.GetTransaction(); transaction != nil { + resolved.dsc = DynamicSamplingContextFromTransaction(transaction) + return resolved + } + } + resolved.dsc = propagation.DynamicSamplingContext + if !resolved.dsc.HasEntries() { + // DynamicSamplingContextFromScope only reads propagationContext. Re-read + // under its documented locking contract rather than duplicating its + // client-option/DSN logic here. + resolved.dsc = dynamicSamplingContextFromScope(scope, client) + } + + return resolved +} + +type traceResolution struct { + traceID TraceID + spanID SpanID // event projection, including propagation SpanID + telemetrySpanID SpanID // only an active/external span belongs on logs/metrics + dsc DynamicSamplingContext + valid bool + external bool +} + +func applyTraceToEvent(event *Event, trace traceResolution) { + if event.Type == transactionType || !trace.valid { + return + } + if event.Contexts == nil { + event.Contexts = make(map[string]Context) + } + if _, ok := event.Contexts["trace"]; !ok { + traceID, spanID := any(trace.traceID), any(trace.spanID) + if trace.external { + traceID, spanID = trace.traceID.String(), trace.spanID.String() + } + event.Contexts["trace"] = Context{"trace_id": traceID, "span_id": spanID} + } + event.sdkMetaData.dsc = trace.dsc } diff --git a/scope_concurrency_test.go b/scope_concurrency_test.go index 26d8f63f3..a71038460 100644 --- a/scope_concurrency_test.go +++ b/scope_concurrency_test.go @@ -110,3 +110,38 @@ func touchScope(scope *sentry.Scope, x int) { scope.ClearBreadcrumbs() scope.Clone() } + +func TestConcurrentCaptureAndMutate(_ *testing.T) { + global := sentry.GlobalScope() + leaf := sentry.NewScope() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(2) + go func(x int) { + defer wg.Done() + for j := 0; j < 50; j++ { + event := sentry.NewEvent() + event.Message = fmt.Sprintf("capture-%d-%d", x, j) + sentry.NewNoopClient().CaptureEvent(event, nil, leaf) + } + }(i) + go func(x int) { + defer wg.Done() + for j := 0; j < 50; j++ { + leaf.SetTag(fmt.Sprintf("tag-%d", x), fmt.Sprint(j)) + leaf.AddBreadcrumb(&sentry.Breadcrumb{Message: fmt.Sprint(j)}, 100) + leaf.SetContext(fmt.Sprintf("ctx-%d", x), sentry.Context{"v": j}) + leaf.SetAttributes(attribute.Int("v", j)) + global.SetTag(fmt.Sprintf("gtag-%d", x), fmt.Sprint(j)) + global.AddBreadcrumb(&sentry.Breadcrumb{Message: fmt.Sprint(j)}, 100) + } + }(i) + } + wg.Wait() + + global.RemoveTag("gtag-0") // best-effort cleanup of keys set above + for i := 0; i < 8; i++ { + global.RemoveTag(fmt.Sprintf("gtag-%d", i)) + } +} diff --git a/scope_context_test.go b/scope_context_test.go index dd1d96bfd..e13dac5bc 100644 --- a/scope_context_test.go +++ b/scope_context_test.go @@ -158,6 +158,39 @@ func TestScopeClientResolution(t *testing.T) { } } +func TestWithScopeContextLastEventIDIsLocal(t *testing.T) { + ctx, operation := WithIsolation(context.Background()) + id := EventID("0123456789abcdef0123456789abcdef") + + WithScopeContext(ctx, func(_ context.Context, fork *Scope) { + fork.setLastEventID(id) + if got := fork.LastEventID(); got != id { + t.Fatalf("fork LastEventID = %q, want %q", got, id) + } + }) + + if got := operation.LastEventID(); got != "" { + t.Fatalf("temporary fork update leaked to operation scope: %q", got) + } +} + +func TestLastEventIDIsIndependentPerScope(t *testing.T) { + ctx, operation := WithIsolation(context.Background()) + id := EventID("0123456789abcdef0123456789abcdef") + + clone := operation.Clone() + clone.setLastEventID(id) + if got := operation.LastEventID(); got != "" { + t.Fatalf("clone update leaked to operation scope: %q", got) + } + + _, child := WithIsolation(ctx) + child.setLastEventID(id) + if got := operation.LastEventID(); got != "" { + t.Fatalf("isolation update leaked to operation scope: %q", got) + } +} + func TestScopeLastEventIDSurvivesCloneAndClear(t *testing.T) { scope := NewScope() id := EventID("0123456789abcdef0123456789abcdef") diff --git a/scope_test.go b/scope_test.go index 72de4056e..a2a472054 100644 --- a/scope_test.go +++ b/scope_test.go @@ -1,6 +1,7 @@ package sentry import ( + "maps" "net/http" "net/http/httptest" "testing" @@ -165,8 +166,7 @@ func TestScopeRemoveAttributeNotInPopulateAttrs(t *testing.T) { ) scope.RemoveAttribute("key.two") - attrs := make(map[string]attribute.Value) - scope.populateAttrs(attrs) + attrs := mergeScopeAttributes(nil, scope) if _, ok := attrs["key.two"]; ok { t.Error("removed attribute should not appear in populateAttrs output") @@ -174,6 +174,68 @@ func TestScopeRemoveAttributeNotInPopulateAttrs(t *testing.T) { assertEqual(t, attribute.StringValue("val1"), attrs["key.one"]) } +func TestResolveCaptureStatePrecedence(t *testing.T) { + global := GlobalScope() + global.mu.Lock() + savedTags := global.tags + savedUser := global.user + global.tags = maps.Clone(savedTags) + global.mu.Unlock() + defer func() { + global.mu.Lock() + global.tags = savedTags + global.user = savedUser + global.mu.Unlock() + }() + + global.SetTag("key", "global") + global.SetTag("global-only", "g") + global.SetUser(User{ID: "global-user"}) + + scope := NewScope() + scope.SetTag("key", "operation") + scope.SetTag("op-only", "o") + + event := NewEvent() + resolveCaptureState(event, scope) + assertEqual(t, "operation", event.Tags["key"]) + assertEqual(t, "g", event.Tags["global-only"]) + assertEqual(t, "o", event.Tags["op-only"]) + assertEqual(t, "global-user", event.User.ID) + + scope.SetTag("key", "changed") + global.SetTag("global-only", "changed") + assertEqual(t, "operation", event.Tags["key"]) + assertEqual(t, "g", event.Tags["global-only"]) +} + +func TestApplyScopeChainProcessorOrder(t *testing.T) { + global := GlobalScope() + global.mu.Lock() + saved := global.eventProcessors + global.eventProcessors = saved[:len(saved):len(saved)] + global.mu.Unlock() + defer func() { + global.mu.Lock() + global.eventProcessors = saved + global.mu.Unlock() + }() + + var order []string + global.AddEventProcessor(func(event *Event, _ *EventHint) *Event { + order = append(order, "global") + return event + }) + scope := NewScope() + scope.AddEventProcessor(func(event *Event, _ *EventHint) *Event { + order = append(order, "operation") + return event + }) + + applyScopeChain(NewEvent(), NewNoopClient(), scope, captureOptions{}) + assertEqual(t, []string{"global", "operation"}, order) +} + func TestScopeRemoveTag(t *testing.T) { scope := NewScope() scope.SetTag("a", "foo") @@ -451,15 +513,15 @@ func TestScopeCloneEventProcessorsIsolation(t *testing.T) { clone2.AddEventProcessor(mark("y")) ran = nil - clone1.ApplyToEvent(NewEvent(), nil, nil) + applyScopeChain(NewEvent(), NewNoopClient(), clone1, captureOptions{}) assertEqual(t, []string{"a", "b", "c", "x"}, ran) ran = nil - clone2.ApplyToEvent(NewEvent(), nil, nil) + applyScopeChain(NewEvent(), NewNoopClient(), clone2, captureOptions{}) assertEqual(t, []string{"a", "b", "c", "y"}, ran) ran = nil - scope.ApplyToEvent(NewEvent(), nil, nil) + applyScopeChain(NewEvent(), NewNoopClient(), scope, captureOptions{}) assertEqual(t, []string{"a", "b", "c"}, ran) } @@ -541,6 +603,12 @@ func TestClear(t *testing.T) { scope := fillScopeWithData(NewScope()) processor := func(event *Event, _ *EventHint) *Event { return event } scope.AddEventProcessor(processor) + client := NewNoopClient() + scope.SetClient(client) + span := &Span{TraceID: TraceIDFromHex("bc6d53f15eb88f4320054569b8c553d4")} + scope.SetSpan(span) + propagation := scope.propagationContextSnapshot() + scope.setLastEventID(EventID("0123456789abcdef0123456789abcdef")) scope.Clear() assertEqual(t, []*Breadcrumb{}, scope.breadcrumbs) @@ -552,8 +620,11 @@ func TestClear(t *testing.T) { assertEqual(t, []string{}, scope.fingerprint) assertEqual(t, Level(""), scope.level) assertEqual(t, (*http.Request)(nil), scope.request) - assertEqual(t, (*Span)(nil), scope.GetSpan()) assertEqual(t, 1, len(scope.eventProcessors)) + assertEqual(t, client, scope.clientOverrideSnapshot()) + assertEqual(t, EventID("0123456789abcdef0123456789abcdef"), scope.LastEventID()) + assertEqual(t, propagation, scope.propagationContextSnapshot()) + assertEqual(t, span, scope.GetSpan()) } func TestClearAndReconfigure(t *testing.T) { @@ -606,14 +677,14 @@ func TestApplyToEventWithCorrectScopeAndEvent(t *testing.T) { scope := fillScopeWithData(NewScope()) event := fillEventWithData(NewEvent()) - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) assertEqual(t, 2, len(processedEvent.Breadcrumbs), "should merge breadcrumbs") assertEqual(t, 2, len(processedEvent.Attachments), "should merge attachments") assertEqual(t, 2, len(processedEvent.Tags), "should merge tags") assertEqual(t, 4, len(processedEvent.Contexts), "should merge contexts") assertEqual(t, event.Contexts[sharedContextsKey], processedEvent.Contexts[sharedContextsKey], "should not override event trace context") - assertEqual(t, LevelDebug, processedEvent.Level, "should use event level if set") + assertEqual(t, LevelInfo, processedEvent.Level, "should use event level if set") assertEqual(t, event.User, processedEvent.User, "should use event user if one exists") assertEqual(t, event.Request, processedEvent.Request, "should use event request if one exists") assertEqual(t, event.Fingerprint, processedEvent.Fingerprint, "should use event fingerprints if they exist") @@ -626,7 +697,7 @@ func TestApplyToEventUsingEmptyScope(t *testing.T) { scope := NewScope() event := fillEventWithData(NewEvent()) - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) assertEqual(t, len(processedEvent.Breadcrumbs), 1, "should use event breadcrumbs") assertEqual(t, len(processedEvent.Attachments), 1, "should use event attachments") assertEqual(t, len(processedEvent.Tags), 1, "should use event tags") @@ -641,7 +712,7 @@ func TestApplyToEventUsingEmptyEvent(t *testing.T) { scope := fillScopeWithData(NewScope()) event := NewEvent() - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) assertEqual(t, len(processedEvent.Breadcrumbs), 1, "should use scope breadcrumbs") assertEqual(t, len(processedEvent.Attachments), 1, "should use scope attachments") assertEqual(t, len(processedEvent.Tags), 1, "should use scope tags") @@ -678,7 +749,7 @@ func TestApplyToEventUsesClientPIISettingsForRequest(t *testing.T) { t.Fatal(err) } - event := scope.ApplyToEvent(NewEvent(), nil, requestClient) + event := applyScopeChain(NewEvent(), requestClient, scope, captureOptions{}) if event.Request == nil { t.Fatal("expected request to be attached") @@ -719,7 +790,7 @@ func TestApplyToEventUsesExplicitDataCollectionForRequest(t *testing.T) { request.Header.Set("Some-Header", "some-header value") scope.SetRequest(request) - event := scope.ApplyToEvent(NewEvent(), nil, client) + event := applyScopeChain(NewEvent(), client, scope, captureOptions{}) if event.Request == nil { t.Fatal("expected request to be attached") } @@ -771,7 +842,7 @@ func TestApplyToEventHTTPBodyCollection(t *testing.T) { scope.SetRequest(request) scope.SetRequestBody(tt.body) - event := scope.ApplyToEvent(NewEvent(), nil, client) + event := applyScopeChain(NewEvent(), client, scope, captureOptions{}) if event.Request == nil { t.Fatal("expected request to be attached") } @@ -795,7 +866,7 @@ func TestEventProcessorsModifiesEvent(t *testing.T) { return event }, } - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) if processedEvent == nil { t.Fatal("event should not be dropped") @@ -812,7 +883,7 @@ func TestEventProcessorsCanDropEvent(t *testing.T) { return nil }, } - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) if processedEvent != nil { t.Error("event should be dropped") @@ -822,7 +893,7 @@ func TestEventProcessorsCanDropEvent(t *testing.T) { func TestEventProcessorsAddEventProcessor(t *testing.T) { scope := NewScope() event := NewEvent() - processedEvent := scope.ApplyToEvent(event, nil, nil) + processedEvent := applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) if processedEvent == nil { t.Error("event should not be dropped") @@ -831,7 +902,7 @@ func TestEventProcessorsAddEventProcessor(t *testing.T) { scope.AddEventProcessor(func(_ *Event, _ *EventHint) *Event { return nil }) - processedEvent = scope.ApplyToEvent(event, nil, nil) + processedEvent = applyScopeChain(event, NewNoopClient(), scope, captureOptions{}) if processedEvent != nil { t.Error("event should be dropped") diff --git a/tracing.go b/tracing.go index ee4084f38..173b23c6a 100644 --- a/tracing.go +++ b/tracing.go @@ -723,7 +723,7 @@ func (s *Span) toEvent() *Event { // traceContext returns a TraceContext snapshot for an active span. // // It needs to clone span Data to avoid holding any user mutable state. -func (s *Span) traceContext() *TraceContext { +func (s *Span) traceContext() *TraceContext { // nolint: unused s.mu.RLock() defer s.mu.RUnlock() return &TraceContext{ From bf2693581f9a5459f501f988842a4b99bbfa2345 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:43:08 +0200 Subject: [PATCH 2/2] chore: update method signatures --- client.go | 2 +- scope.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client.go b/client.go index 6b187310b..5eaf8552b 100644 --- a/client.go +++ b/client.go @@ -1030,7 +1030,7 @@ func applyScopeChain(event *Event, client *Client, scope *Scope, opts captureOpt } applyTraceToEvent(event, resolveTrace(scope, client, ctx)) - return state.applyToEvent(event, opts.hint, opts, client) + return state.applyToEvent(event, opts.hint, client, opts) } func (client *Client) prepareEvent(event *Event, scope *Scope, opts captureOptions) *Event { diff --git a/scope.go b/scope.go index ec3e9e7e6..b998400b4 100644 --- a/scope.go +++ b/scope.go @@ -522,7 +522,7 @@ func mergeScopeAttributes(attrs map[string]attribute.Value, scope *Scope) map[st // applyToEvent handles capture work that must run after Scope locks are // released, including callbacks and request conversion. -func (state captureState) applyToEvent(event *Event, hint *EventHint, opts captureOptions, client *Client) *Event { +func (state captureState) applyToEvent(event *Event, hint *EventHint, client *Client, opts captureOptions) *Event { event.Attachments = append(event.Attachments, state.attachments...) if event.Request == nil && state.request != nil { @@ -544,7 +544,7 @@ func (state captureState) applyToEvent(event *Event, hint *EventHint, opts captu } event.Breadcrumbs = mergeBreadcrumbs(client.options.MaxBreadcrumbs, event.Breadcrumbs, [][]*Breadcrumb{state.breadcrumbs}) - event.Level = resolveLevel(event, opts, state.level) + event.Level = resolveLevel(event, state.level, opts) for _, processor := range state.processors { id := event.EventID @@ -567,7 +567,7 @@ func (state captureState) applyToEvent(event *Event, hint *EventHint, opts captu return event } -func resolveLevel(event *Event, opts captureOptions, scopeLevel Level) Level { +func resolveLevel(event *Event, scopeLevel Level, opts captureOptions) Level { switch { case event.Level != "": return event.Level