Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 70 additions & 14 deletions scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,22 @@ 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.
Comment on lines +24 to +25

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: I find this paragraph to be slightly unclear. What is meant by the "underlying data" in this context?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's everything under scopeData, which is essentially all the data set by the user. Not really sure how to call this.

type Scope struct {
mu sync.RWMutex
mu sync.RWMutex
// clientOverride is an explicit client binding set with SetClient. Having
// no override defaults to the global scope client.
clientOverride *Client
// eventProcessors are retained by Clear and inherited by Clone.
eventProcessors []EventProcessor
lastEventID EventID

// scopeData keeps track of all scope specific data
scopeData
Expand All @@ -58,14 +57,21 @@ 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.
func NewScope() *Scope {
return &Scope{scopeData: newScopeData()}
}

// newScopeWithClient creates a Scope with an explicit client override.
func newScopeWithClient(client *Client) *Scope {
scope := NewScope()
scope.SetClient(client)
return scope
}

func newScopeData() scopeData {
return scopeData{
attributes: make(map[string]attribute.Value),
Expand Down Expand Up @@ -94,6 +100,54 @@ func (scope *Scope) AddBreadcrumb(breadcrumb *Breadcrumb, limit int) {
}
}

// SetClient sets an explicit client override on the scope. Passing nil clears
// the override so client resolution falls back to GlobalScope.
func (scope *Scope) SetClient(client *Client) {
scope.mu.Lock()
defer scope.mu.Unlock()

scope.clientOverride = normalizeClient(client)
}
Comment on lines +105 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The SetClient documentation says passing nil clears the override, but the implementation sets a non-nil NoopClient, violating the API contract.
Severity: LOW

Suggested Fix

Either update the documentation to reflect the actual behavior, change the implementation to store nil when nil is passed, or create an explicit ClearClient() method instead of using SetClient(nil).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: scope.go#L105-L110

Potential issue: The documentation for the `SetClient` function at `scope.go:103-104`
states that passing `nil` will clear the client override. However, the implementation at
line 109 calls `normalizeClient(client)`, which returns a non-nil `NoopClient` when the
input is `nil`. This results in `scope.clientOverride` never being set to `nil`. While
the runtime behavior correctly falls back to the global scope due to subsequent
`IsEnabled()` checks, this discrepancy violates the documented API contract and the
principle of least surprise for developers expecting the override to be `nil`.


func (scope *Scope) clientOverrideSnapshot() *Client {
scope.mu.RLock()
defer scope.mu.RUnlock()

return scope.clientOverride
}

// Client returns the first enabled client in the scope chain.
func (scope *Scope) Client() *Client {
if scope != nil {
if client := normalizeClient(scope.clientOverrideSnapshot()); client.IsEnabled() {
return client
}
}

global := GlobalScope()
if scope != global {
if client := normalizeClient(global.clientOverrideSnapshot()); client.IsEnabled() {
return client
}
}
return NewNoopClient()
}

func (scope *Scope) setLastEventID(id EventID) {
scope.mu.Lock()
defer scope.mu.Unlock()

scope.lastEventID = id
}

// LastEventID returns the last event ID associated with this scope.
func (scope *Scope) LastEventID() EventID {
scope.mu.RLock()
defer scope.mu.RUnlock()

return scope.lastEventID
}

// ClearBreadcrumbs clears all breadcrumbs from the current scope.
func (scope *Scope) ClearBreadcrumbs() {
scope.mu.Lock()
Expand Down Expand Up @@ -294,6 +348,8 @@ func (scope *Scope) Clone() *Scope {
return &Scope{
scopeData: data.clone(),
eventProcessors: scope.eventProcessors[:len(scope.eventProcessors):len(scope.eventProcessors)],
clientOverride: scope.clientOverride,
lastEventID: scope.lastEventID,
}
}

Expand Down
43 changes: 43 additions & 0 deletions scope_concurrency_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sentry_test

import (
"context"
"fmt"
"net/http/httptest"
"sync"
Expand Down Expand Up @@ -48,6 +49,48 @@ func TestConcurrentScopeUsage(_ *testing.T) {
wg.Wait()
}

func TestConcurrentSharedIsolation(_ *testing.T) {
ctx, scope := sentry.WithIsolation(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.WithIsolation(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"})
Expand Down
65 changes: 65 additions & 0 deletions scope_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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
Comment thread
sentry[bot] marked this conversation as resolved.
}

// ScopeFromContext returns the scope carried by ctx, or nil when ctx
// does not carry one.
func ScopeFromContext(ctx context.Context) *Scope {
if ctx == nil {
return nil
}
scope, _ := ctx.Value(scopeContextKey{}).(*Scope)
return scope
}

func contextWithScope(ctx context.Context, scope *Scope) context.Context {
return context.WithValue(ctx, scopeContextKey{}, scope)
}

// WithIsolation returns a derived context and an independent scope.
// It clones a carried scope or creates an empty scope when ctx does
// not carry one.
func WithIsolation(ctx context.Context) (context.Context, *Scope) {
parent := ScopeFromContext(ctx)
var scope *Scope
if parent == nil {
scope = NewScope()
} else {
scope = parent.Clone()
scope.SetPropagationContext(NewPropagationContext())
scope.SetSpan(nil)
Comment thread
cursor[bot] marked this conversation as resolved.
}
return contextWithScope(ctx, scope), scope
}

// WithScopeContext invokes fn with a derived context carrying a cloned scope.
func WithScopeContext(ctx context.Context, fn func(context.Context, *Scope)) { // TODO: should remove WithScope when hub is removed.
if fn == nil {
return
}

parent := ScopeFromContext(ctx)
if parent == nil {
parent = NewScope()
}
scope := parent.Clone()
fn(contextWithScope(ctx, scope), scope)
}
Comment thread
giortzisg marked this conversation as resolved.

// GetClient returns the first enabled client for ctx.
func GetClient(ctx context.Context) *Client {
if scope := ScopeFromContext(ctx); scope != nil {
return scope.Client()
}
return GlobalScope().Client()
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading