diff --git a/capture_options.go b/capture_options.go new file mode 100644 index 000000000..e9ca6a504 --- /dev/null +++ b/capture_options.go @@ -0,0 +1,119 @@ +package sentry + +import "context" + +// CaptureOption configures a single event capture. +type CaptureOption interface { + applyCaptureOption(*captureOptions) +} + +type captureOptionFunc func(*captureOptions) + +func (f captureOptionFunc) applyCaptureOption(options *captureOptions) { f(options) } + +type captureOptions struct { + hint *EventHint + modifiers []EventModifier + legacyScope EventModifier + legacyScopeSet bool +} + +// WithEventHint supplies metadata to event processors and before-send hooks. +// When supplied more than once, the last hint wins. +func WithEventHint(hint *EventHint) CaptureOption { + return captureOptionFunc(func(options *captureOptions) { options.hint = hint }) +} + +// WithEventModifier applies modifier to the captured event. It may be repeated. +func WithEventModifier(modifier EventModifier) CaptureOption { + return captureOptionFunc(func(options *captureOptions) { + if modifier != nil { + options.modifiers = append(options.modifiers, modifier) + } + }) +} + +// withLegacyScope preserves backward-compatible Hub scope processing and should be removed with the Hub. +func withLegacyScope(scope EventModifier) CaptureOption { + return captureOptionFunc(func(options *captureOptions) { + options.legacyScope = scope + options.legacyScopeSet = true + }) +} + +// withDefaultOriginalException preserves a caller-supplied hint value while filling the capture default. +func withDefaultOriginalException(exception error) CaptureOption { + return captureOptionFunc(func(options *captureOptions) { + if options.hint == nil { + options.hint = &EventHint{} + } + if options.hint.OriginalException == nil { + hint := *options.hint + hint.OriginalException = exception + options.hint = &hint + } + }) +} + +// withDefaultRecoveredException preserves a caller-supplied hint value while filling the capture default. +func withDefaultRecoveredException(recovered any) CaptureOption { + return captureOptionFunc(func(options *captureOptions) { + if options.hint == nil { + options.hint = &EventHint{} + } + if options.hint.RecoveredException == nil { + hint := *options.hint + hint.RecoveredException = recovered + options.hint = &hint + } + }) +} + +func resolveCaptureOptions(ctx context.Context, client Client, options []CaptureOption) (*EventHint, EventModifier, *Scope) { + var resolved captureOptions + for _, option := range options { + if option != nil { + option.applyCaptureOption(&resolved) + } + } + hint := &EventHint{} + if resolved.hint != nil { + *hint = *resolved.hint + } + // Explicit capture context overrides any supplied hint.Context. A nil ctx + // leaves any supplied hint context in place so legacy Hub APIs and scope + // request-context fallback continue to work. + if ctx != nil { + hint.Context = ctx + } + if resolved.legacyScopeSet { + var modifiers []EventModifier + if resolved.legacyScope != nil { + modifiers = append(modifiers, resolved.legacyScope) + } + modifiers = append(modifiers, resolved.modifiers...) + return hint, captureModifierChain(modifiers), nil + } + global := GlobalScope() + isolation := scopeFromContext(ctx) + owner := isolation + if owner == nil { + owner = global + } + modifiers := append([]EventModifier{snapshotScopes(client, global, isolation)}, resolved.modifiers...) + return hint, captureModifierChain(modifiers), owner +} + +type captureModifierChain []EventModifier + +func (modifiers captureModifierChain) ApplyToEvent(event *Event, hint *EventHint, client Client) *Event { + for _, modifier := range modifiers { + if modifier != nil { + event = modifier.ApplyToEvent(event, hint, client) + } + if event == nil { + return nil + } + } + return event +} diff --git a/capture_options_test.go b/capture_options_test.go new file mode 100644 index 000000000..616e9d8aa --- /dev/null +++ b/capture_options_test.go @@ -0,0 +1,135 @@ +package sentry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithEventHint(t *testing.T) { + t.Parallel() + + type contextKey struct{} + captureCtx := context.WithValue(context.Background(), contextKey{}, "capture") + + tests := []struct { + name string + options []CaptureOption + want any + }{ + { + name: "passes hint to capture pipeline", + options: []CaptureOption{WithEventHint(&EventHint{Data: "hint"})}, + want: "hint", + }, + { + name: "last hint wins", + options: []CaptureOption{ + WithEventHint(&EventHint{Data: "first"}), + WithEventHint(&EventHint{Data: "second"}), + }, + want: "second", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var got *EventHint + client, err := newClient(ClientOptions{ + Transport: &MockTransport{}, + Integrations: func([]Integration) []Integration { + return nil + }, + BeforeSend: func(event *Event, hint *EventHint) *Event { + got = hint + return event + }, + }) + require.NoError(t, err) + + id := client.CaptureMessage(captureCtx, "message", test.options...) + require.NotNil(t, id) + require.NotNil(t, got) + assert.Equal(t, test.want, got.Data) + assert.Equal(t, captureCtx, got.Context) + }) + } +} + +func TestWithEventModifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + modifiers []EventModifier + wantID bool + wantMessage string + }{ + { + name: "applies modifiers in order", + modifiers: []EventModifier{ + testEventModifier(func(event *Event, _ *EventHint, _ Client) *Event { + event.Message += "-first" + return event + }), + nil, + testEventModifier(func(event *Event, _ *EventHint, _ Client) *Event { + event.Message += "-second" + return event + }), + }, + wantID: true, + wantMessage: "message-first-second", + }, + { + name: "nil event drops capture and stops the chain", + modifiers: []EventModifier{ + testEventModifier(func(_ *Event, _ *EventHint, _ Client) *Event { return nil }), + testEventModifier(func(event *Event, _ *EventHint, _ Client) *Event { + event.Message = "not reached" + return event + }), + }, + wantID: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + transport := &MockTransport{} + client, err := newClient(ClientOptions{ + Transport: transport, + Integrations: func([]Integration) []Integration { + return nil + }, + }) + require.NoError(t, err) + + options := make([]CaptureOption, 0, len(test.modifiers)) + for _, modifier := range test.modifiers { + options = append(options, WithEventModifier(modifier)) + } + id := client.CaptureMessage(context.Background(), "message", options...) + if !test.wantID { + assert.Nil(t, id) + assert.Nil(t, transport.lastEvent) + return + } + require.NotNil(t, id) + require.NotNil(t, transport.lastEvent) + assert.Equal(t, test.wantMessage, transport.lastEvent.Message) + }) + } +} + +type testEventModifier func(*Event, *EventHint, Client) *Event + +func (f testEventModifier) ApplyToEvent(event *Event, hint *EventHint, client Client) *Event { + return f(event, hint, client) +} diff --git a/client.go b/client.go index 828fad85c..815758178 100644 --- a/client.go +++ b/client.go @@ -613,34 +613,36 @@ func (client *defaultClient) GetDataCollection() DataCollection { } // CaptureMessage captures an arbitrary message. -func (client *defaultClient) CaptureMessage(message string, hint *EventHint, scope EventModifier) *EventID { - event := client.EventFromMessage(message, LevelInfo) - return client.CaptureEvent(event, hint, scope) +func (client *defaultClient) CaptureMessage(ctx context.Context, message string, options ...CaptureOption) *EventID { + return client.CaptureEvent(ctx, client.EventFromMessage(message, LevelInfo), options...) } // CaptureException captures an error. -func (client *defaultClient) CaptureException(exception error, hint *EventHint, scope EventModifier) *EventID { - event := client.EventFromException(exception, LevelError) - return client.CaptureEvent(event, hint, scope) +func (client *defaultClient) CaptureException(ctx context.Context, exception error, options ...CaptureOption) *EventID { + return client.CaptureEvent(ctx, client.EventFromException(exception, LevelError), append(options, withDefaultOriginalException(exception))...) } // CaptureCheckIn captures a check in. -func (client *defaultClient) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig, scope EventModifier) *EventID { +func (client *defaultClient) CaptureCheckIn(ctx context.Context, checkIn *CheckIn, monitorConfig *MonitorConfig, options ...CaptureOption) *EventID { event := client.EventFromCheckIn(checkIn, monitorConfig) - if event != nil && event.CheckIn != nil { - client.CaptureEvent(event, nil, scope) - return &event.CheckIn.ID + if event == nil || event.CheckIn == nil { + return nil + } + id := event.CheckIn.ID + if client.CaptureEvent(ctx, event, options...) == nil { + return nil } - return nil + return &id } -// CaptureEvent captures an event on the currently active client if any. -// -// 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 *defaultClient) CaptureEvent(event *Event, hint *EventHint, scope EventModifier) *EventID { - return client.processEvent(event, hint, scope) +// CaptureEvent captures an event on this client. +func (client *defaultClient) CaptureEvent(ctx context.Context, event *Event, options ...CaptureOption) *EventID { + hint, scope, owner := resolveCaptureOptions(ctx, client, options) + id := client.processEvent(event, hint, scope) + if id != nil && event != nil && event.Type != transactionType && owner != nil { + owner.setLastEventID(*id) + } + return id } func (client *defaultClient) captureLog(log *Log, _ *Scope) bool { @@ -716,45 +718,15 @@ func (client *defaultClient) captureMetric(metric *Metric, _ *Scope) bool { return true } -// Recover captures a panic. -// Returns EventID if successfully, or nil if there's no error to recover from. -func (client *defaultClient) Recover(err any, hint *EventHint, scope EventModifier) *EventID { - if err == nil { - err = recover() - } - - // Normally we would not pass a nil Context, but RecoverWithContext doesn't - // use the Context for communicating deadline nor cancelation. All it does - // is store the Context in the EventHint and there nil means the Context is - // not available. - // nolint: staticcheck - return client.RecoverWithContext(nil, err, hint, scope) -} - -// RecoverWithContext captures a panic and passes relevant context object. -// Returns EventID if successfully, or nil if there's no error to recover from. -func (client *defaultClient) RecoverWithContext( - ctx context.Context, - err any, - hint *EventHint, - scope EventModifier, -) *EventID { +// Recover captures a panic or an explicitly supplied recovered value. +func (client *defaultClient) Recover(ctx context.Context, err any, options ...CaptureOption) *EventID { if err == nil { err = recover() } if err == nil { return nil } - - if ctx != nil { - if hint == nil { - hint = &EventHint{} - } - if hint.Context == nil { - hint.Context = ctx - } - } - + options = append(options, withDefaultRecoveredException(err)) var event *Event switch err := err.(type) { case error: @@ -764,7 +736,7 @@ func (client *defaultClient) RecoverWithContext( default: event = client.EventFromMessage(fmt.Sprintf("%#v", err), LevelFatal) } - return client.CaptureEvent(event, hint, scope) + return client.CaptureEvent(ctx, event, options...) } // Flush waits until the underlying Transport sends any buffered events to the @@ -913,7 +885,7 @@ func (client *defaultClient) GetSDKVersion() string { func (client *defaultClient) processEvent(event *Event, hint *EventHint, scope EventModifier) *EventID { if event == nil { err := usageError{fmt.Errorf("%s called with nil event", callerFunctionName())} - return client.CaptureException(err, hint, scope) + return client.processEvent(client.EventFromException(err, LevelError), hint, scope) } // Transactions are sampled by options.TracesSampleRate or diff --git a/client_api.go b/client_api.go index aa866d83c..98f355a25 100644 --- a/client_api.go +++ b/client_api.go @@ -18,12 +18,11 @@ type Client interface { Options() ClientOptions clientOptions() *ClientOptions GetDataCollection() DataCollection - CaptureMessage(string, *EventHint, EventModifier) *EventID - CaptureException(error, *EventHint, EventModifier) *EventID - CaptureCheckIn(*CheckIn, *MonitorConfig, EventModifier) *EventID - CaptureEvent(*Event, *EventHint, EventModifier) *EventID - Recover(any, *EventHint, EventModifier) *EventID - RecoverWithContext(context.Context, any, *EventHint, EventModifier) *EventID + CaptureMessage(context.Context, string, ...CaptureOption) *EventID + CaptureException(context.Context, error, ...CaptureOption) *EventID + CaptureCheckIn(context.Context, *CheckIn, *MonitorConfig, ...CaptureOption) *EventID + CaptureEvent(context.Context, *Event, ...CaptureOption) *EventID + Recover(context.Context, any, ...CaptureOption) *EventID Flush(time.Duration) bool FlushWithContext(context.Context) bool Close() @@ -70,28 +69,25 @@ func (noopClient) IsEnabled() bool { return false } func (noopClient) AddEventProcessor(EventProcessor) {} func (noopClient) SetExternalContextTraceResolver(func(context.Context) (TraceID, SpanID, bool)) { } -func (noopClient) Options() ClientOptions { return noopClientOptions } -func (noopClient) clientOptions() *ClientOptions { return &noopClientOptions } -func (noopClient) GetDataCollection() DataCollection { return DataCollection{} } -func (noopClient) CaptureMessage(string, *EventHint, EventModifier) *EventID { return nil } -func (noopClient) CaptureException(error, *EventHint, EventModifier) *EventID { return nil } -func (noopClient) CaptureCheckIn(*CheckIn, *MonitorConfig, EventModifier) *EventID { +func (noopClient) Options() ClientOptions { return noopClientOptions } +func (noopClient) clientOptions() *ClientOptions { return &noopClientOptions } +func (noopClient) GetDataCollection() DataCollection { return DataCollection{} } +func (noopClient) CaptureMessage(context.Context, string, ...CaptureOption) *EventID { return nil } +func (noopClient) CaptureException(context.Context, error, ...CaptureOption) *EventID { return nil } +func (noopClient) CaptureCheckIn(context.Context, *CheckIn, *MonitorConfig, ...CaptureOption) *EventID { return nil } -func (noopClient) CaptureEvent(*Event, *EventHint, EventModifier) *EventID { return nil } -func (noopClient) Recover(any, *EventHint, EventModifier) *EventID { return nil } -func (noopClient) RecoverWithContext(context.Context, any, *EventHint, EventModifier) *EventID { - return nil -} -func (noopClient) Flush(time.Duration) bool { return true } -func (noopClient) FlushWithContext(context.Context) bool { return true } -func (noopClient) Close() {} -func (noopClient) EventFromMessage(string, Level) *Event { return nil } -func (noopClient) EventFromException(error, Level) *Event { return nil } -func (noopClient) EventFromCheckIn(*CheckIn, *MonitorConfig) *Event { return nil } -func (noopClient) SetSDKIdentifier(string) {} -func (noopClient) GetSDKIdentifier() string { return sdkIdentifier } -func (noopClient) GetSDKVersion() string { return SDKVersion } +func (noopClient) CaptureEvent(context.Context, *Event, ...CaptureOption) *EventID { return nil } +func (noopClient) Recover(context.Context, any, ...CaptureOption) *EventID { return nil } +func (noopClient) Flush(time.Duration) bool { return true } +func (noopClient) FlushWithContext(context.Context) bool { return true } +func (noopClient) Close() {} +func (noopClient) EventFromMessage(string, Level) *Event { return nil } +func (noopClient) EventFromException(error, Level) *Event { return nil } +func (noopClient) EventFromCheckIn(*CheckIn, *MonitorConfig) *Event { return nil } +func (noopClient) SetSDKIdentifier(string) {} +func (noopClient) GetSDKIdentifier() string { return sdkIdentifier } +func (noopClient) GetSDKVersion() string { return SDKVersion } func (noopClient) externalTraceContextFromContext(context.Context) (TraceID, SpanID, bool) { return TraceID{}, SpanID{}, false } diff --git a/client_api_test.go b/client_api_test.go index 529f4f072..207dd5019 100644 --- a/client_api_test.go +++ b/client_api_test.go @@ -33,22 +33,22 @@ func TestNoopClient(t *testing.T) { }) client.SetSDKIdentifier("custom") - if got := client.CaptureMessage("message", nil, nil); got != nil { + if got := client.CaptureMessage(context.Background(), "message", withLegacyScope(nil)); got != nil { t.Errorf("CaptureMessage returned %v, want nil", got) } - if got := client.CaptureException(errors.New("boom"), nil, nil); got != nil { + if got := client.CaptureException(context.Background(), errors.New("boom"), withLegacyScope(nil)); got != nil { t.Errorf("CaptureException returned %v, want nil", got) } - if got := client.CaptureCheckIn(&CheckIn{}, nil, nil); got != nil { + if got := client.CaptureCheckIn(t.Context(), &CheckIn{}, nil); got != nil { t.Errorf("CaptureCheckIn returned %v, want nil", got) } - if got := client.CaptureEvent(NewEvent(), nil, nil); got != nil { + if got := client.CaptureEvent(context.Background(), NewEvent(), withLegacyScope(nil)); got != nil { t.Errorf("CaptureEvent returned %v, want nil", got) } - if got := client.Recover(errors.New("boom"), nil, nil); got != nil { + if got := client.Recover(t.Context(), errors.New("boom")); got != nil { t.Errorf("Recover returned %v, want nil", got) } - if got := client.RecoverWithContext(t.Context(), errors.New("boom"), nil, nil); got != nil { + if got := client.Recover(t.Context(), errors.New("boom")); got != nil { t.Errorf("RecoverWithContext returned %v, want nil", got) } diff --git a/client_test.go b/client_test.go index f00a6ebe3..58d0725fb 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(context.Background(), errors.New("custom error"), withLegacyScope(&MockScope{})) assertEqual(t, transport.lastEvent.Exception[0].Value, "custom error") } @@ -67,19 +67,19 @@ func setupClientTest() (*defaultClient, *MockScope, *MockTransport) { } func TestCaptureMessageShouldSendEventWithProvidedMessage(t *testing.T) { client, scope, transport := setupClientTest() - client.CaptureMessage("foo", nil, scope) + client.CaptureMessage(context.Background(), "foo", withLegacyScope(scope)) assertEqual(t, transport.lastEvent.Message, "foo") } func TestCaptureMessageShouldSucceedWithoutNilScope(t *testing.T) { client, _, transport := setupClientTest() - client.CaptureMessage("foo", nil, nil) + client.CaptureMessage(context.Background(), "foo", withLegacyScope(nil)) assertEqual(t, transport.lastEvent.Message, "foo") } func TestCaptureMessageEmptyString(t *testing.T) { client, scope, transport := setupClientTest() - client.CaptureMessage("", nil, scope) + client.CaptureMessage(context.Background(), "", withLegacyScope(scope)) want := &Event{ Exception: []Exception{ { @@ -287,7 +287,7 @@ func TestCaptureException(t *testing.T) { tt := tt t.Run(grp.name+"/"+tt.name, func(t *testing.T) { client, _, transport := setupClientTest() - client.CaptureException(tt.err, nil, nil) + client.CaptureException(context.Background(), tt.err, withLegacyScope(nil)) if transport.lastEvent == nil { t.Fatal("missing event") } @@ -307,11 +307,11 @@ func TestCaptureEvent(t *testing.T) { timestamp := time.Now().UTC() serverName := "testServer" - client.CaptureEvent(&Event{ + client.CaptureEvent(context.Background(), &Event{ EventID: eventID, Timestamp: timestamp, ServerName: serverName, - }, nil, nil) + }) if transport.lastEvent == nil { t.Fatal("missing event") @@ -340,7 +340,7 @@ func TestCaptureEvent(t *testing.T) { }, } got := transport.lastEvent - opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release"), cmpopts.IgnoreFields(Event{}, "sdkMetaData", "serializedTags", "serializedContexts", "serializedBreadcrumbs", "serializedException", "serializedUser", "serializationSafe")} + opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release", "Contexts"), cmpopts.IgnoreFields(Event{}, "sdkMetaData", "serializedTags", "serializedContexts", "serializedBreadcrumbs", "serializedException", "serializedUser", "serializationSafe")} if diff := cmp.Diff(want, got, opts); diff != "" { t.Errorf("Event mismatch (-want +got):\n%s", diff) } @@ -350,13 +350,13 @@ func TestCaptureEventShouldSendEventWithMessage(t *testing.T) { client, scope, transport := setupClientTest() event := NewEvent() event.Message = "event message" - client.CaptureEvent(event, nil, scope) + client.CaptureEvent(context.Background(), event, withLegacyScope(scope)) assertEqual(t, transport.lastEvent.Message, "event message") } func TestCaptureEventNil(t *testing.T) { client, scope, transport := setupClientTest() - client.CaptureEvent(nil, nil, scope) + client.CaptureEvent(context.Background(), nil, withLegacyScope(scope)) want := &Event{ Exception: []Exception{ { @@ -443,7 +443,7 @@ func TestCaptureCheckIn(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { client, _, transport := setupClientTest() - client.CaptureCheckIn(tt.checkIn, tt.monitorConfig, nil) + client.CaptureCheckIn(context.Background(), tt.checkIn, tt.monitorConfig) capturedEvent := transport.lastEvent if tt.expectNilEvent && capturedEvent == nil { @@ -480,18 +480,18 @@ func TestCaptureCheckInExistingID(t *testing.T) { Timezone: "UTC", } - checkInID := client.CaptureCheckIn(&CheckIn{ + checkInID := client.CaptureCheckIn(context.Background(), &CheckIn{ MonitorSlug: "cron", Status: CheckInStatusInProgress, Duration: time.Second, - }, monitorConfig, nil) + }, monitorConfig) - checkInID2 := client.CaptureCheckIn(&CheckIn{ + checkInID2 := client.CaptureCheckIn(context.Background(), &CheckIn{ ID: *checkInID, MonitorSlug: "cron", Status: CheckInStatusOK, Duration: time.Minute, - }, monitorConfig, nil) + }, monitorConfig) if *checkInID != *checkInID2 { t.Errorf("Expecting equivalent CheckInID: %s and %s", *checkInID, *checkInID2) @@ -502,7 +502,7 @@ func TestSampleRateCanDropEvent(t *testing.T) { client, scope, transport := setupClientTest() client.options.SampleRate = 0.000000000000001 - client.CaptureMessage("Foo", nil, scope) + client.CaptureMessage(context.Background(), "Foo", withLegacyScope(scope)) if transport.lastEvent != nil { t.Error("expected event to be dropped") @@ -520,7 +520,7 @@ func TestApplyToScopeCanDropEvent(t *testing.T) { return event }) - client.CaptureMessage("Foo", nil, scope) + client.CaptureMessage(context.Background(), "Foo", withLegacyScope(scope)) if transport.lastEvent != nil { t.Error("expected event to be dropped") @@ -533,7 +533,7 @@ func TestBeforeSendCanDropEvent(t *testing.T) { return nil } - client.CaptureMessage("Foo", nil, scope) + client.CaptureMessage(context.Background(), "Foo", withLegacyScope(scope)) if transport.lastEvent != nil { t.Error("expected event to be dropped") @@ -550,7 +550,7 @@ func TestBeforeSendGetAccessToEventHint(t *testing.T) { } ex := customComplexError{Message: "Foo"} - client.CaptureException(ex, &EventHint{OriginalException: ex}, scope) + client.CaptureException(context.Background(), ex, WithEventHint(&EventHint{OriginalException: ex}), withLegacyScope(scope)) assertEqual(t, transport.lastEvent.Message, "customComplexError: Foo 42") } @@ -654,7 +654,7 @@ func TestIgnoreErrors(t *testing.T) { t.Fatal(err) } - client.CaptureMessage(tt.message, nil, scope) + client.CaptureMessage(context.Background(), tt.message, withLegacyScope(scope)) dropped := transport.lastEvent == nil if tt.expectDrop != dropped { @@ -1003,7 +1003,7 @@ func TestRecover(t *testing.T) { t.Run(fmt.Sprintf("Recover/%v", tt.v), func(t *testing.T) { client, scope, transport := setupClientTest() func() { - defer client.Recover(nil, nil, scope) + defer func() { client.Recover(context.Background(), recover(), withLegacyScope(scope)) }() panic(tt.v) }() tt.want.Level = LevelFatal @@ -1020,7 +1020,7 @@ func TestRecover(t *testing.T) { return event }) func() { - defer client.RecoverWithContext(context.TODO(), nil, nil, scope) + defer func() { client.Recover(context.TODO(), recover(), withLegacyScope(scope)) }() panic(tt.v) }() tt.want.Level = LevelFatal @@ -1105,7 +1105,7 @@ func TestTelemetryEnvelopeCarriesIntegrations(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { client.Close() }) - client.CaptureMessage("ping", nil, &MockScope{}) + client.CaptureMessage(context.Background(), "ping", withLegacyScope(&MockScope{})) require.True(t, client.Flush(testutils.FlushTimeout()), "flush timed out") select { diff --git a/example_transportwithhooks_test.go b/example_transportwithhooks_test.go index 9965b9962..f5342e192 100644 --- a/example_transportwithhooks_test.go +++ b/example_transportwithhooks_test.go @@ -1,6 +1,7 @@ package sentry_test import ( + "context" "fmt" "net/http" "net/http/httputil" @@ -59,5 +60,5 @@ func Example_transportWithHooks() { } defer sentry.Flush(2 * time.Second) - sentry.CaptureMessage("test") + sentry.CaptureMessage(context.Background(), "test") } diff --git a/hub.go b/hub.go index bd376b651..a8e344e1b 100644 --- a/hub.go +++ b/hub.go @@ -221,7 +221,11 @@ func (hub *Hub) CaptureEventWithHint(event *Event, hint *EventHint) *EventID { if scope == nil { return nil } - eventID := client.CaptureEvent(event, hint, scope) + ctx := contextFromScope(scope) + if hint != nil && hint.Context != nil { + ctx = hint.Context + } + eventID := client.CaptureEvent(ctx, event, WithEventHint(hint), withLegacyScope(scope)) if event.Type != transactionType && eventID != nil { hub.mu.Lock() @@ -239,7 +243,7 @@ func (hub *Hub) CaptureMessage(message string) *EventID { if scope == nil { return nil } - eventID := client.CaptureMessage(message, nil, scope) + eventID := client.CaptureMessage(contextFromScope(scope), message, withLegacyScope(scope)) if eventID != nil { hub.mu.Lock() @@ -257,7 +261,7 @@ func (hub *Hub) CaptureException(exception error) *EventID { if scope == nil { return nil } - eventID := client.CaptureException(exception, &EventHint{OriginalException: exception}, scope) + eventID := client.CaptureException(contextFromScope(scope), exception, WithEventHint(&EventHint{OriginalException: exception}), withLegacyScope(scope)) if eventID != nil { hub.mu.Lock() @@ -271,7 +275,8 @@ func (hub *Hub) CaptureException(exception error) *EventID { // passing it a top-level Scope. // Returns CheckInID if the check-in was captured successfully, or nil otherwise. func (hub *Hub) CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID { - return hub.Client().CaptureCheckIn(checkIn, monitorConfig, hub.Scope()) + scope := hub.Scope() + return hub.Client().CaptureCheckIn(contextFromScope(scope), checkIn, monitorConfig, withLegacyScope(scope)) } // AddBreadcrumb records a new breadcrumb. @@ -314,7 +319,7 @@ func (hub *Hub) Recover(err interface{}) *EventID { if scope == nil { return nil } - return client.Recover(err, &EventHint{RecoveredException: err}, scope) + return client.Recover(contextFromScope(scope), err, WithEventHint(&EventHint{RecoveredException: err}), withLegacyScope(scope)) } // RecoverWithContext calls the method of a same name on currently bound Client instance @@ -328,7 +333,28 @@ func (hub *Hub) RecoverWithContext(ctx context.Context, err interface{}) *EventI if scope == nil { return nil } - return client.RecoverWithContext(ctx, err, &EventHint{RecoveredException: err}, scope) + if ctx == nil { + ctx = contextFromScope(scope) + } + return client.Recover(ctx, err, WithEventHint(&EventHint{RecoveredException: err}), withLegacyScope(scope)) +} + +// contextFromScope returns the scope request's context when available. +// +// This is just a backwards compatibility layer for hub based captures. Should +// be removed when we remove the hub. +func contextFromScope(scope *Scope) context.Context { + if scope == nil { + return context.Background() + } + + scope.mu.RLock() + request := scope.request + scope.mu.RUnlock() + if request != nil { + return request.Context() + } + return context.Background() } // Flush waits until the underlying Transport sends any buffered events to the diff --git a/integrations_test.go b/integrations_test.go index f062b7d56..b3ea023d4 100644 --- a/integrations_test.go +++ b/integrations_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "encoding/json" "os" "regexp" @@ -400,7 +401,7 @@ func TestGlobalTagsIntegration(t *testing.T) { event := NewEvent() event.Message = "event message" - client.CaptureEvent(event, nil, scope) + client.CaptureEvent(context.Background(), event, withLegacyScope(scope)) assertEqual(t, transport.lastEvent.Tags["foo"], diff --git a/recover_external_test.go b/recover_external_test.go index d4c45a695..bff538883 100644 --- a/recover_external_test.go +++ b/recover_external_test.go @@ -1,6 +1,7 @@ package sentry_test import ( + "context" "errors" "testing" @@ -46,7 +47,7 @@ func panicWithArbitraryValue() { func recoverPanic(client sentry.Client, panicFunc func()) { defer func() { if err := recover(); err != nil { - client.Recover(err, nil, nil) + client.Recover(context.Background(), err) } }() @@ -55,7 +56,7 @@ func recoverPanic(client sentry.Client, panicFunc func()) { //go:noinline func recoverHandledError(client sentry.Client) { - client.Recover(errors.New("boom"), nil, nil) + client.Recover(context.Background(), errors.New("boom")) } func newRecoverTestClient(t *testing.T) (sentry.Client, *sentry.MockTransport) { diff --git a/scope.go b/scope.go index 0c7978e3b..13da9e371 100644 --- a/scope.go +++ b/scope.go @@ -30,6 +30,9 @@ type Scope struct { boundClient Client // eventProcessors are retained by Clear and inherited by Clone. eventProcessors []EventProcessor + // lastEventID is the ID of the last non-transaction, non-dropped event + // captured through this scope. + lastEventID EventID // scopeData contains clearable event/request/trace enrichment data. scopeData @@ -326,7 +329,24 @@ func (data scopeData) clone() scopeData { return clone } -// Clear removes data from the scope while retaining event processors. +// LastEventID returns the ID of the last non-transaction event captured +// through this scope. Returns an empty EventID when no event has been captured +// or the last capture was dropped. +func (scope *Scope) LastEventID() EventID { + scope.mu.RLock() + defer scope.mu.RUnlock() + return scope.lastEventID +} + +// setLastEventID updates the scope's last event ID. +func (scope *Scope) setLastEventID(id EventID) { + scope.mu.Lock() + defer scope.mu.Unlock() + scope.lastEventID = id +} + +// Clear removes data from the scope while retaining event processors, client +// binding, and last event ID. func (scope *Scope) Clear() { scope.mu.Lock() defer scope.mu.Unlock() @@ -424,6 +444,11 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client Client) * return snapshotScopes(client, scope).applyToEvent(event, hint, client) } +// ApplyToEvent implements [EventModifier] for a pre-built snapshot. +func (snapshot scopeSnapshot) ApplyToEvent(event *Event, hint *EventHint, client Client) *Event { + return snapshot.applyToEvent(event, hint, client) +} + func (snapshot scopeSnapshot) applyToEvent(event *Event, hint *EventHint, client Client) *Event { //nolint:gocyclo client = normalizeClient(client) diff --git a/scope_concurrency_test.go b/scope_concurrency_test.go index 786ddb925..cafc61ae8 100644 --- a/scope_concurrency_test.go +++ b/scope_concurrency_test.go @@ -105,7 +105,7 @@ func touchScope(scope *sentry.Scope, x int) { scope.SetPropagationContext(sentry.NewPropagationContext()) scope.SetSpan(&sentry.Span{TraceID: sentry.TraceIDFromHex("d49d9bf66f13450b81f65bc51cf49c03")}) - sentry.CaptureException(fmt.Errorf("error %d", x)) + sentry.CaptureException(context.Background(), fmt.Errorf("error %d", x)) scope.ClearBreadcrumbs() scope.Clone() diff --git a/sentry.go b/sentry.go index 1d5885d35..f105f59d9 100644 --- a/sentry.go +++ b/sentry.go @@ -29,55 +29,28 @@ func Init(options ClientOptions) error { } // CaptureMessage captures an arbitrary message. -func CaptureMessage(message string) *EventID { - hub := CurrentHub() - return hub.CaptureMessage(message) +func CaptureMessage(ctx context.Context, message string, options ...CaptureOption) *EventID { + return GetClient(ctx).CaptureMessage(ctx, message, options...) } // CaptureException captures an error. -func CaptureException(exception error) *EventID { - hub := CurrentHub() - return hub.CaptureException(exception) +func CaptureException(ctx context.Context, err error, options ...CaptureOption) *EventID { + return GetClient(ctx).CaptureException(ctx, err, options...) } // CaptureCheckIn captures a (cron) monitor check-in. -func CaptureCheckIn(checkIn *CheckIn, monitorConfig *MonitorConfig) *EventID { - hub := CurrentHub() - return hub.CaptureCheckIn(checkIn, monitorConfig) +func CaptureCheckIn(ctx context.Context, checkIn *CheckIn, config *MonitorConfig, options ...CaptureOption) *EventID { + return GetClient(ctx).CaptureCheckIn(ctx, checkIn, config, options...) } -// CaptureEvent captures an event on the currently active client if any. -// -// 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 CaptureEvent(event *Event) *EventID { - hub := CurrentHub() - return hub.CaptureEvent(event) +// CaptureEvent captures a sentry Event. +func CaptureEvent(ctx context.Context, event *Event, options ...CaptureOption) *EventID { + return GetClient(ctx).CaptureEvent(ctx, event, options...) } -// Recover captures a panic. -func Recover() *EventID { - if err := recover(); err != nil { - hub := CurrentHub() - return hub.Recover(err) - } - return nil -} - -// RecoverWithContext captures a panic and passes relevant context object. -func RecoverWithContext(ctx context.Context) *EventID { - err := recover() - if err == nil { - return nil - } - - hub := GetHubFromContext(ctx) - if hub == nil { - hub = CurrentHub() - } - - return hub.RecoverWithContext(ctx, err) +// Recover captures a panic from the current goroutine. It must be deferred. +func Recover(ctx context.Context, options ...CaptureOption) *EventID { + return GetClient(ctx).Recover(ctx, recover(), options...) } // ConfigureScope is a shorthand for CurrentHub().ConfigureScope. @@ -131,8 +104,12 @@ func FlushWithContext(ctx context.Context) bool { return hub.FlushWithContext(ctx) } -// LastEventID returns an ID of last captured event. -func LastEventID() EventID { - hub := CurrentHub() - return hub.LastEventID() +// LastEventID returns the ID of the last non-transaction event captured through +// the isolation scope carried by ctx. When ctx carries no isolation scope, +// LastEventID falls back to the global scope. +func LastEventID(ctx context.Context) EventID { + if scope := scopeFromContext(ctx); scope != nil { + return scope.LastEventID() + } + return GlobalScope().LastEventID() }