Skip to content

Commit 7880acc

Browse files
committed
rfc(decision): scope model RFC for Go
1 parent 1292d6b commit 7880acc

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

text/0156-scope-model-for-go.md

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
2+
Start Date: 2026-03-19
3+
RFC Type: decision
4+
RFC PR:
5+
RFC Status: draft
6+
7+
## Summary
8+
9+
This RFC evaluates how `sentry-go` should model scope state so it can align with Sentry’s three-scope model while remaining idiomatic in Go. The document compares a mutable scope-in-`ctx` design with an immutable copy-on-write `context.Context` design and recommends the latter as the better long-term fit for Go concurrency, isolation, and OpenTelemetry alignment.
10+
11+
## Motivation
12+
13+
The upstream [scopes spec](https://develop.sentry.dev/sdk/foundations/state-management/scopes/) is designed around three scope types: a global scope, an isolation scope, and a current scope.
14+
15+
The intent of the upstream scopes spec is:
16+
17+
- users should not need to think about isolation-scope forking
18+
- integrations should fork isolation automatically
19+
- current scope is for local span changes or `withScope` manual instrumentation changes
20+
- the model should align with Open Telemetry’s immutable context propagation
21+
22+
The problem is that the three scope model, does not map directly to how Go handles async code isolation.
23+
24+
### Why Go is different
25+
26+
- No built-in thread-local or async-local state comparable to other SDKs.
27+
- It already has an async propagation mechanism through `context.Context` .
28+
- Concurrency is explicit and users can start goroutines freely. With the current upstream scopes spec there’s no runtime guarantees. The intent of the upstream scopes spec should be maintained in `users should not need to think about isolation-scope forking` and having an API that guarantees isolation.
29+
- `context.Context` is immutable. The current sentry-go implementation used a mutable (`*Scope`). The mutable approach already created multiple problems in the current implementation of the SDK (every scope method is locked with mutexes).
30+
31+
In Go, `context.Context` is the standard library’s immutable request-scoped propagation mechanism. It is commonly used to carry cancellation, deadlines, tracing state, and other operation-local values across API boundaries. Deriving a new `context.Context` returns a new value without mutating the original.
32+
33+
The expected outcome of this RFC is to choose a scope propagation model that gives correct request isolation semantics, maps cleanly to Go’s concurrency model, and provides a clear integration contract for automatic isolation handling.
34+
35+
## Background
36+
37+
### Current architecture
38+
39+
Today the SDK is built around:
40+
41+
- a process-global ambient `Hub` via `CurrentHub()`
42+
- a mutable `Scope` attached to the top layer of a `Hub`
43+
- optional per-request or per-operation propagation by storing a cloned `Hub` in `context.Context`
44+
45+
and the user facing API:
46+
47+
- `CurrentHub()` returns the process-global hub.
48+
- `Hub.Clone()` clones the top scope and reuses the client.
49+
- `SetHubOnContext(ctx, hub)` stores a `Hub` in a `context.Context`.
50+
- `GetHubFromContext(ctx)` retrieves the `Hub` from a `context.Context`.
51+
- `Scope` is mutable and protected by a mutex.
52+
- `ConfigureScope` mutates the current hub's top scope in place.
53+
- `WithScope` clones the current scope, pushes it temporarily onto the hub stack, then pops it after the callback.
54+
55+
### Consequences of the current design
56+
57+
- Request isolation is integration-driven. Middleware typically clones the current hub at request entry and store the clone in `context.Context`.
58+
- `context.Context` currently carries a `Hub`, not a scope value.
59+
- The `Hub` stored in `context.Context` owns a mutable top `Scope`.
60+
- Two goroutines using the same `context.Context` can still mutate the same scope.
61+
- Locks make concurrent access safer, but they do not provide semantic isolation.
62+
- Tracing already uses `context.Context` independently for active span propagation, while `sentry-go` also mirrors span state onto the scope. This means the SDK currently has two partially overlapping [propagation systems](https://github.com/getsentry/sentry-go/blob/340c142cf974aaba7dcb6545101fe125a7d8ad7c/scope.go#L577).
63+
64+
### Background information for how `sentry-go` already works
65+
66+
The current `Hub`/`Scope` model of the SDK uses `context.Context` to store a mutable `*Hub`:
67+
68+
```go
69+
ctx := context.Background()
70+
ctx = sentry.SetHubOnContext(ctx, sentry.CurrentHub().Clone())
71+
hub = sentry.GetHubFromContext(ctx)
72+
// goroutines with the same ctx can concurrently mutate the same Hub reference.
73+
// the SDK partially solves this with locks.
74+
```
75+
76+
The important note here is that `context.Context` itself is immutable, but the stored `Hub` and `Scope` are mutable.
77+
78+
## Options Considered
79+
80+
### Mapping the three scope types to `sentry-go`
81+
82+
Based on the three-scope model from the upstream scopes spec, the closest mapping for Go would be:
83+
84+
- global scope -> process-level singleton state, today effectively `CurrentHub()` when no request-local `ctx` is involved
85+
- isolation scope -> request-local or task-local state stored on `context.Context` by integrations at request/task entry
86+
- current scope -> span-local derived state, for example from `WithScope` or when starting a new span
87+
88+
In terms of the current SDK:
89+
90+
- global scope is closest to `CurrentHub()` used without `ctx`
91+
- isolation scope is closest to `SetHubOnContext(ctx, sentry.CurrentHub().Clone())`
92+
- current scope is closest to `WithScope(...)` / `PushScope()` on the active hub
93+
94+
In terms of the proposed scope-oriented API:
95+
96+
- global scope would remain process-global state outside request-local `ctx`
97+
- isolation scope would be the main scope value carried by `ctx`
98+
- current scope would be a derived fork of the scope in `ctx`
99+
100+
This mapping should drive API semantics explicitly:
101+
102+
- top-level setup without a request-local `ctx` should continue to operate on global scope
103+
- integrations should create isolation scope at request/task entry
104+
- span start and `WithScope`-style local overrides should derive current scope from the isolation scope already present on `ctx`
105+
106+
### Storing scope on `context.Context`
107+
108+
The API should store just the `Scope` on `context.Context`, deprecating the old `Hub` design like this:
109+
110+
```go
111+
func SetScopeOnContext(ctx context.Context, scope Scope) context.Context {
112+
return context.WithValue(ctx, Key, scope)
113+
}
114+
115+
func GetScopeFromContext(ctx context.Context) Scope {
116+
if scope, ok := ctx.Value(Key).(Scope); ok {
117+
return scope
118+
}
119+
return nil
120+
}
121+
```
122+
123+
but the major change with this proposal is to not store a mutable `*Scope` inside the `ctx`.
124+
125+
### Option 1: Mutable Scope, familiar Sentry design (This is the easiest migration path, but it preserves the core semantic problem: shared mutable scope state)
126+
127+
This option makes `ctx` the main scope carrier:
128+
129+
- active scope is fetched from `context.Context` .
130+
- `ctx` stores a mutable `*Scope` .
131+
- users need to continue using `scope.SetTag(...)` , `scope.SetAttributes(...)` .
132+
- scope mutations still need locks.
133+
- Capture APIs need `ctx` to be passed.
134+
135+
The way this option works is for integrations to have a request-local scope at request entry, where scope mutations happen (in a ”thread-local” way), and a derived `context.Context` carries that mutable scope there.
136+
137+
### API example:
138+
139+
```go
140+
ctx := sentry.NewContext(context.Background())
141+
sentry.ConfigureScope(ctx, func(scope *sentry.Scope) {
142+
scope.SetTag("release", "1.2.3")
143+
scope.SetUser(sentry.User{ID: "123"})
144+
})
145+
146+
http.Handle("/hello", sentryhttp.New(sentryhttp.Options{}).HandleFunc(func(w http.ResponseWriter, r *http.Request) {
147+
ctx := r.Context()
148+
sentry.ConfigureScope(ctx, func(scope *sentry.Scope) {
149+
scope.SetTag("route", "/hello")
150+
scope.SetRequest(r)
151+
})
152+
153+
sentry.WithScope(ctx, func(scope *sentry.Scope) {
154+
scope.SetLevel(sentry.LevelWarning)
155+
sentry.CaptureMessage(ctx, "hello warning")
156+
})
157+
}))
158+
```
159+
160+
This remains problematic even if integrations clone a scope at request entry. If two goroutines reuse the same `ctx`, they still share the same mutable `*Scope` and therefore the same logical isolation state.
161+
162+
### Pros
163+
164+
- Familiar mutable scope (sentry like).
165+
- Smaller migration burden for users.
166+
- Preserves scope mutation patterns.
167+
- A mutable scope means less allocations.
168+
169+
### Cons
170+
171+
- `context.Context` still stores a pointer to a mutable state and we still have shared mutable state.
172+
- Users still need to think about mutation when starting goroutines or when re-using contexts. There is a need to know to fork scope on concurrent environments.
173+
- We need to keep locks (anti-pattern).
174+
- hard to map to Open Telemetry.
175+
176+
### Option 2: Immutable Copy-on-Write Context API (Recommended Approach)
177+
178+
This option makes scope update return a new `context.Context` rather than mutating shared scope state in place.
179+
180+
- Scope data is treated as immutable from the API perspective.
181+
- APIs such as `scope.SetAttributes(...)` would just manipulate `context.Context`.
182+
- A mutation returns a new `ctx` effectively carrying a new scope.
183+
- Copy-on-write replaces all lock-based mutations.
184+
- Capture APIs need `ctx` to be passed.
185+
186+
### API example:
187+
188+
```go
189+
// ctx should always be ovewritten on a SetX
190+
ctx = sentry.SetTag(ctx, "key", "value")
191+
ctx = sentry.SetAttributes(ctx, ...)
192+
ctx = sentry.SetUser(ctx, user)
193+
```
194+
195+
This fits existing Go APIs well. `otel`, `grpc/metadata`, and similar packages already use the `ctx = SetX(ctx, ...)` pattern, so while this is a migration for sentry-go, it is not a conceptual departure from normal Go `context` propagation.
196+
197+
### Pros
198+
199+
- Idiomatic Go (most popular go libraries work this way).
200+
- Stronger isolation semantics.
201+
- Easier to reason and user friendly.
202+
- Removes scope level locking. Race conditions are impossible, simplifies SDK maintainance.
203+
- Users don’t need to manage hidden mutable shared state.
204+
- In general `context.Context` is meant to be immutable, so this option makes the most sense in the Go ecosystem. We simplify the `Scope` API for users.
205+
- This aligns much more naturally with OTel’s immutable `context` model.
206+
- Goroutine propagation becomes safe by default as long as the caller passes `ctx` and each goroutine receives an immutable scope snapshot instead of a shared mutable scope pointer.
207+
208+
### Cons
209+
210+
- Major change from current SDK architecture, both for us and the users.
211+
- More cloning and allocations on write (instead of using a mutable scope). This is `semi-solved` with the vision of using only `SetAttributes`, users would only need one more scope allocation compared to the mutable scope proposal (option 1).
212+
- We would need to be mindful on future APIs, since every logical mutation requires deriving a new `context.Context` value.
213+
214+
### Integration responsibilities under Option 2
215+
216+
To satisfy the upstream scopes spec requirement, integrations need to create an isolation scope automatically.
217+
218+
Examples:
219+
220+
- `sentryhttp` should derive a new isolation scope at request entry before invoking the handler
221+
- goroutine/task helper APIs should preserve the incoming `ctx` snapshot instead of reaching for ambient global state
222+
- tracing helpers should derive current scope from the active isolation scope on `ctx`
223+
224+
Pseudo-shape:
225+
226+
```go
227+
func middleware(next http.Handler) http.Handler {
228+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229+
ctx := sentry.NewContext(r.Context()) // forks isolation scope
230+
next.ServeHTTP(w, r.WithContext(ctx))
231+
})
232+
}
233+
```
234+
235+
This is the Go equivalent of what other SDKs do at async/task boundaries. The important point is that the integration owns the isolation fork, not the user.
236+
237+
## Supporting Data
238+
239+
### Performance considerations
240+
241+
- Allocations
242+
- The `attribute` API currently copies slices/maps into arrays (stack allocated - since they’re fixed size). We cannot/shouldn’t keep mutable user data, so option 1 has more frequent allocations. However in the future where the SDK would use only the `attribute` API, the values would be copied once to the stack, and then further scope copies would point to the same underlying data on the stack. Allocation drawback would be alleviated.
243+
- Lock contention
244+
- The main runtime cost is lock acquisition on every mutation that touches the scope state.
245+
- Performance wise: lock contention vs allocations
246+
- lock impact is (probably?) worse on performance (pending benchmarks)
247+
- allocations can be alleviated while locks would always be there due to design
248+
- locks would always apply vs allocations (might?) become a problem if users are setting many attributes.
249+
- even with mutable scope we still allocate when every isolated code segment finishes.
250+
251+
252+
### Recommendation
253+
254+
Option 2 would be my personal recommendation. It maps the upstream scopes spec to Go-like concepts (does not really feel `Sentry` like), but would simplify integration development, is more user friendly, remove locks, can be a performance improvement (pending benchmarks) and align with the Go environment and what users would expect (users won’t have to think when and where to use `WithScope`). Maintenance wise, a race-free solution makes the most sense and it’s really easy to argue about.
255+
256+
## Some more API considerations
257+
258+
### CaptureX
259+
260+
Whichever option we decide to go with, we need to migrate `CaptureX(error)` to `CaptureX(ctx, error)` , since everything would be `context` related and we would need to strictly type the API. The main benefits would be:
261+
262+
- Remove custom [workaround](https://github.com/getsentry/sentry-go/blob/340c142cf974aaba7dcb6545101fe125a7d8ad7c/scope.go#L577) since tracing/scopes are divergent currently
263+
- Improve user experience with some integrations (eg. [sentry.EventHint](https://docs.sentry.io/platforms/go/tracing/instrumentation/opentelemetry/#linking-errors-to-transactions)).
264+
- The `CaptureException(ctx, error)` already should to happen for the OTLP integration (see above sentry.EventHint bullet), to correctly link errors to traces.
265+
266+
Today we effectively maintain two propagation systems: `context.Context` for tracing and `Hub`/`Scope` for event state. Moving capture APIs to `ctx` lets OTel span state and Sentry scope state travel through the same propagation channel.
267+
268+
### General API deprecation
269+
270+
Whichever approach we go with we should make `ctx` mandatory on our APIs. We already mandate `ctx` usage for logs and metrics.
271+
272+
The main problem here is that Go doesn’t have function overloading. Many breaking changes on the public API. (we are still v0, but it might be a significant change for users)
273+
274+
### `WithScope` under Option 2
275+
276+
Under an immutable `ctx` model, `WithScope` does not need to disappear. It can become a small compatibility helper that derives current scope and passes the derived `ctx` into the callback.
277+
278+
For example:
279+
280+
```go
281+
func WithScope(ctx context.Context, fn func(context.Context)) {
282+
fn(NewContext(ctx))
283+
}
284+
```
285+
286+
This preserves the intent of `WithScope` for local instrumentation while aligning it with immutable `context` propagation. That makes it a good migration shim even if we eventually deprecate it in favor of direct `ctx = sentry.SetX(ctx, ...)` usage.
287+
288+
## Unresolved questions
289+
290+
If an API receives a `context.Context` that does not carry a Sentry scope, should the SDK:
291+
292+
- no-op
293+
- fallback to global scope
294+
- create a fresh isolation scope
295+
296+
This matters because the upstream scopes spec expects captures to conceptually merge global, isolation and current scopes.

0 commit comments

Comments
 (0)