|
| 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