From 24cbd3264b7798e9d20124c990aaad3a711d9e3e Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:02:47 +0200 Subject: [PATCH 1/2] add scope context API --- scope.go | 59 +++++++++++++----- scope_concurrency_test.go | 43 +++++++++++++ scope_context.go | 71 ++++++++++++++++++++++ scope_context_test.go | 125 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 scope_context.go create mode 100644 scope_context_test.go diff --git a/scope.go b/scope.go index 66ed322a1..5e05ed2d0 100644 --- a/scope.go +++ b/scope.go @@ -15,22 +15,20 @@ import ( "github.com/getsentry/sentry-go/report" ) -// Scope holds contextual data for the current scope. +// Scope holds contextual data for an operation. // -// The scope is an object that can cloned efficiently and stores data that is -// locally relevant to an event. For instance the scope will hold recorded -// breadcrumbs and similar information. +// The scope is an object that can be cloned efficiently and stores data that is +// locally relevant to an event. It also holds the client and event processor in +// which the scope data should be applied to. // -// The scope can be interacted with in two ways. First, the scope is routinely -// updated with information by functions such as AddBreadcrumb which will modify -// the current scope. Second, the current scope can be configured through the -// ConfigureScope function or Hub method of the same name. -// -// The scope is meant to be modified but not inspected directly. When preparing -// an event for reporting, the current client adds information from the current -// scope into the event. +// Clearing or cloning the scope only affects the underlying data. To set a new +// client or event processor, SetClient or AddEventProcessor should be used. type Scope struct { - mu sync.RWMutex + mu sync.RWMutex + // boundClient is the client reference bound to this Scope. Clone copies the + // client reference but not the client object. Clear keeps the binding. + boundClient Client + // eventProcessors are retained by Clear and inherited by Clone. eventProcessors []EventProcessor // scopeData keeps track of all scope specific data @@ -58,12 +56,20 @@ type scopeData struct { } propagationContext PropagationContext - span *Span + span *Span // TODO: this should be removed when the span API is introduced. Currently kept for compatibility. } -// NewScope creates a new Scope. +// NewScope creates a new Scope bound to a no-op client. func NewScope() *Scope { - return &Scope{scopeData: newScopeData()} + return newScopeWithClient(NewNoopClient()) +} + +// newScopeWithClient creates a Scope with an explicit, normalized client. +func newScopeWithClient(client Client) *Scope { + return &Scope{ + boundClient: normalizeClient(client), + scopeData: newScopeData(), + } } func newScopeData() scopeData { @@ -94,6 +100,22 @@ func (scope *Scope) AddBreadcrumb(breadcrumb *Breadcrumb, limit int) { } } +// SetClient explicitly binds a client to the scope. +func (scope *Scope) SetClient(client Client) { + scope.mu.Lock() + defer scope.mu.Unlock() + + scope.boundClient = normalizeClient(client) +} + +// client returns the non-nil client bound to this Scope under a lock. +func (scope *Scope) client() Client { + scope.mu.RLock() + defer scope.mu.RUnlock() + + return scope.boundClient +} + // ClearBreadcrumbs clears all breadcrumbs from the current scope. func (scope *Scope) ClearBreadcrumbs() { scope.mu.Lock() @@ -286,6 +308,7 @@ func (scope *Scope) SetSpan(span *Span) { } // Clone returns a copy of the current scope with all data copied over. +// The client binding is inherited by reference without cloning the object. func (scope *Scope) Clone() *Scope { scope.mu.RLock() defer scope.mu.RUnlock() @@ -294,6 +317,7 @@ func (scope *Scope) Clone() *Scope { return &Scope{ scopeData: data.clone(), eventProcessors: scope.eventProcessors[:len(scope.eventProcessors):len(scope.eventProcessors)], + boundClient: scope.boundClient, } } @@ -527,6 +551,9 @@ func hubFromContexts(ctxs ...context.Context) *Hub { // 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. +// +// 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) (traceID TraceID, spanID SpanID) { client = normalizeClient(client) var span *Span diff --git a/scope_concurrency_test.go b/scope_concurrency_test.go index 4bec89068..457773b19 100644 --- a/scope_concurrency_test.go +++ b/scope_concurrency_test.go @@ -1,6 +1,7 @@ package sentry_test import ( + "context" "fmt" "net/http/httptest" "sync" @@ -48,6 +49,48 @@ func TestConcurrentScopeUsage(_ *testing.T) { wg.Wait() } +func TestConcurrentSharedIsolation(_ *testing.T) { + ctx, scope := sentry.ScopeFromContext(context.Background()) + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(x int) { + defer wg.Done() + scope.SetTag(fmt.Sprintf("tag-%d", x), "value") + scope.SetUser(sentry.User{ID: fmt.Sprint(x)}) + scope.SetContext(fmt.Sprintf("context-%d", x), sentry.Context{"value": x}) + scope.SetAttributes(attribute.Int("value", x)) + scope.AddBreadcrumb(&sentry.Breadcrumb{Message: fmt.Sprint(x)}, 100) + _, shared := sentry.ScopeFromContext(ctx) + shared.Clone() + }(i) + } + + wg.Wait() +} + +func TestConcurrentSharedIsolationClearAndClone(_ *testing.T) { + _, scope := sentry.ScopeFromContext(context.Background()) + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(x int) { + defer wg.Done() + for j := 0; j < 20; j++ { + scope.SetTag(fmt.Sprintf("tag-%d", x), fmt.Sprint(j)) + scope.Clone() + if j%5 == 0 { + scope.Clear() + } + } + }(i) + } + + wg.Wait() +} + func touchScope(scope *sentry.Scope, x int) { scope.SetTag("foo", "bar") scope.SetContext("foo", sentry.Context{"foo": "bar"}) diff --git a/scope_context.go b/scope_context.go new file mode 100644 index 000000000..ef18bd73f --- /dev/null +++ b/scope_context.go @@ -0,0 +1,71 @@ +package sentry + +import "context" + +type scopeContextKey struct{} + +// globalScope is the process-wide global scope. +var globalScope = newScopeWithClient(NewNoopClient()) + +// GlobalScope returns the process-wide global scope. +func GlobalScope() *Scope { + return globalScope +} + +func scopeFromContext(ctx context.Context) *Scope { + if ctx == nil { + return nil + } + scope, _ := ctx.Value(scopeContextKey{}).(*Scope) + return scope +} + +// ScopeFromContext returns the isolation scope carried by ctx. If ctx does not carry one, +// it creates an isolation scope and returns a derived context carrying that scope. +// +// If ctx already carries an isolation scope, ScopeFromContext returns the exact +// input context and the existing scope. +func ScopeFromContext(ctx context.Context) (context.Context, *Scope) { + if scope := scopeFromContext(ctx); scope != nil { + return ctx, scope + } + + scope := newIsolationScope() + return context.WithValue(ctx, scopeContextKey{}, scope), scope +} + +// WithIsolation returns an independent isolation context. It clones a scope already +// carried by ctx or creates a fresh one if none exist. +func WithIsolation(ctx context.Context) context.Context { + scope := scopeFromContext(ctx) + if scope == nil { + scope = newIsolationScope() + } else { + scope = scope.Clone() + } + return context.WithValue(ctx, scopeContextKey{}, scope) +} + +// newIsolationScope creates a new operation scope bound to the client currently +// set on GlobalScope. The client interface value is copied; the Client object is +// shared and the Scope itself is not. This guarantees that every context-carried +// operation scope has a non-nil client without treating a no-op client as an +// “unbound” sentinel. +// +// Use this only when there is no parent Scope to clone. A nested isolation +// boundary must clone its parent Scope instead so an explicit client binding is +// preserved. +func newIsolationScope() *Scope { + return newScopeWithClient(GlobalScope().client()) +} + +// GetClient returns the effective non-nil client for ctx. A carried operation +// Scope always has a client binding; when ctx has no Scope, the current global +// client is used. Rebinding GlobalScope affects only contexts without an +// already-created operation Scope. +func GetClient(ctx context.Context) Client { + if scope := scopeFromContext(ctx); scope != nil { + return normalizeClient(scope.client()) + } + return normalizeClient(GlobalScope().client()) +} diff --git a/scope_context_test.go b/scope_context_test.go new file mode 100644 index 000000000..098182e3d --- /dev/null +++ b/scope_context_test.go @@ -0,0 +1,125 @@ +package sentry + +import ( + "context" + "testing" +) + +func TestScopeFromContext(t *testing.T) { + parent := context.Background() + ctx, scope := ScopeFromContext(parent) + if ctx == parent || scope == GlobalScope() { + t.Fatal("scope miss did not create an independent scoped context") + } + + sameCtx, sameScope := ScopeFromContext(ctx) + if sameCtx != ctx || sameScope != scope { + t.Fatal("scope hit did not return the exact context and scope") + } + + otherCtx, otherScope := ScopeFromContext(parent) + if otherCtx == ctx || otherScope == scope { + t.Fatal("independent misses from an unscoped context aliased") + } +} + +func TestIsolationScopeSharesDownstreamMutations(t *testing.T) { + type contextKey struct{} + + ctx, scope := ScopeFromContext(context.Background()) + child := context.WithValue(ctx, contextKey{}, "value") + _, childScope := ScopeFromContext(child) + childScope.SetUser(User{ID: "123"}) + + if scope.user.ID != "123" { + t.Fatal("downstream mutation was not visible to the boundary owner") + } +} + +func TestWithIsolationCreatesIndependentBoundaries(t *testing.T) { + parentCtx, parent := ScopeFromContext(context.Background()) + parent.SetTag("inherited", "yes") + + firstCtx := WithIsolation(parentCtx) + secondCtx := WithIsolation(parentCtx) + _, first := ScopeFromContext(firstCtx) + _, second := ScopeFromContext(secondCtx) + first.SetTag("worker", "first") + second.SetTag("worker", "second") + + if first == parent || second == parent || first == second { + t.Fatal("isolation boundaries alias") + } + if first.tags["inherited"] != "yes" || second.tags["inherited"] != "yes" { + t.Fatal("isolation boundaries did not inherit parent data") + } + if _, ok := parent.tags["worker"]; ok { + t.Fatal("child mutation leaked into parent") + } + if first.tags["worker"] != "first" || second.tags["worker"] != "second" { + t.Fatal("sibling isolation mutations leaked") + } +} + +func TestWithIsolationDoesNotCloneGlobalScope(t *testing.T) { + global := GlobalScope() + global.SetTag("global-only", "yes") + t.Cleanup(func() { global.RemoveTag("global-only") }) + + ctx := WithIsolation(context.Background()) + _, scope := ScopeFromContext(ctx) + if scope == global { + t.Fatal("isolation scope aliases global scope") + } + if _, ok := scope.tags["global-only"]; ok { + t.Fatal("isolation scope cloned global data") + } +} + +func TestScopeClientResolution(t *testing.T) { + globalClient, err := NewClient(ClientOptions{}) + if err != nil { + t.Fatal(err) + } + operationClient, err := NewClient(ClientOptions{}) + if err != nil { + t.Fatal(err) + } + childClient, err := NewClient(ClientOptions{}) + if err != nil { + t.Fatal(err) + } + + global := GlobalScope() + previousGlobal := global.client() + global.SetClient(globalClient) + t.Cleanup(func() { global.SetClient(previousGlobal) }) + + standalone := NewScope() + if standalone.client().IsEnabled() { + t.Fatal("NewScope was not bound to a no-op client") + } + + ctx, scope := ScopeFromContext(context.Background()) + if GetClient(ctx) != globalClient || scope.client() != globalClient { + t.Fatal("new isolation did not snapshot the global client") + } + + global.SetClient(childClient) + if GetClient(ctx) != globalClient || standalone.client().IsEnabled() { + t.Fatal("existing scopes followed a later global client change") + } + global.SetClient(globalClient) + + scope.SetClient(operationClient) + + childCtx := WithIsolation(ctx) + _, child := ScopeFromContext(childCtx) + if GetClient(childCtx) != operationClient { + t.Fatal("child scope did not inherit client reference") + } + child.SetClient(childClient) + if GetClient(childCtx) != childClient || GetClient(ctx) != operationClient { + t.Fatal("child client binding was not independent") + } +} From 1ba25e18931ba76bbe054aa03da081d2364d2788 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:41:20 +0200 Subject: [PATCH 2/2] bind client to global scope --- sentry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry.go b/sentry.go index b315c3711..94782d2c7 100644 --- a/sentry.go +++ b/sentry.go @@ -24,6 +24,7 @@ func Init(options ClientOptions) error { return err } hub.BindClient(client) + GlobalScope().SetClient(client) return nil }