Skip to content

Commit 13e0c27

Browse files
committed
add global & isolation scope concepts
1 parent 6252bfb commit 13e0c27

1 file changed

Lines changed: 148 additions & 4 deletions

File tree

text/0122-sdk-hub-scope-merge.md

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Also, how Hub & Scope forking behaves is currently a bit tricky, and does not pl
1414

1515
This RFC aims to streamline this by merging the concepts of Hub and Scope into a singular Scope concept.
1616

17+
It also proposes the new concepts of global & isolated scopes.
18+
1719
# Background
1820

1921
TODO
@@ -27,7 +29,7 @@ Scopes will be _similar_ to how they work today, but not entirely the same.
2729
Scopes can have data (e.g. tags, user, ...) added to them the same way you can do today.
2830
This RFC _does not_ aim to change any of the data that is kept on the scope and is applied to events.
2931

30-
The following APIs will be removed:
32+
The following APIs will be removed/deprecated:
3133

3234
* `getCurrentHub()`
3335
* `configureScope()` (instead just get the scope and set on it directly)
@@ -51,6 +53,15 @@ export function getClient(): Client;
5153

5254
// make a scope the current scope. Replacement for `makeMain(hub)`
5355
export function setCurrentScope(scope: Scope): void;
56+
57+
// get the currently active global scope
58+
export function getGlobalScope(): Scope;
59+
60+
// get the currently active isolation scope
61+
export function getIsolationScope(): Scope;
62+
63+
// similar to `withScope`, but defines an isolation scope
64+
export function withIsolationScope(callback: (scope: Scope) => unknown): unknown;
5465
```
5566

5667
The following APIs already exist but will behave differently:
@@ -119,6 +130,135 @@ You can still clone scopes manually the same way as before, e.g. via `Scope.clon
119130

120131
You can update the client of a scope via `scope.setClient(newClient)`. This will not affect any scope that has already been forked off this scope, but any scope forked off _after_ the client was updated will also receive the updated client.
121132

133+
Every scope is always tied to a client.
134+
135+
## Global Scope
136+
137+
In addition to the currently active scope, there will also be a new special scope, the **Global Scope**.
138+
The global scope is _not_ the initial scope, but a special scope that belongs to a client and is applied to any event that belongs to this client.
139+
140+
You can get the current global scope via `getGlobalScope()`. There _may_ be a function `setGlobalScope(scope)` to update the global scope - or SDKs can decide that there is no need to update the global scope, you can only mutate it.
141+
142+
If you call `getGlobalScope()` before a client is initialized, we should still get a global scope back (tied to a Noop client). Once an actual client is initialized, the global scope of the noop client should be merged into the new global scope for the new client. This should ensure that even if you call `getGlobalScope().setTag(...)` before the SDK is initialized, no data is lost.
143+
144+
The reason that the global scope is not the same as the initial scope of a client, is that you cannot accidentally mutate it - nothing ever inherits off the global scope.
145+
146+
## Isolation Scopes
147+
148+
Furthermore, there can also be **Isolation Scopes**.
149+
Similar to the global scope, these are also applied to events. However, isolation scopes can be created, either by us internally (the most common scenario), or also by users. The new APIs for this are:
150+
151+
```js
152+
// Returns the currently active isolation scope.
153+
export function getIsolationScope(): Scope;
154+
155+
// Create a new isolation scope for this scope
156+
// This will NOT make this scope the isolation scope, but will create a new isolation scope (based on the currently active isolation scope, if one exists)
157+
scope.isolate();
158+
159+
// Similar to `withScope`, but it forks a new scope AND sets a new isolation scope for this context
160+
export function withIsolationScope(callback: (scope) => void): void;
161+
```
162+
163+
You can fetch the currently active isolation scope via `getIsolationScope()`. You can define a new isolation scope via `scope.isolate()`, which will define a new isolation scope for this scope, and for all scopes that will be forked off this scope. When a client is created & bound, an initial isolation scope will immediately be created, similar to the global scope for a client.
164+
165+
An isolation scope is attached to the current execution context, similar to the active scope. There is always exactly one active isolation scope. If you call `getIsolationScope()` before a client has been created, a noop isolation scope is returned, which should be merged in once a client is actually created (same as with the global scope).
166+
167+
Similar to the global scope, an isolation scope is always a separate scope, so nothing will inherit off it - except for a potential superseding isolation scope.
168+
If an isolation scope is created, and there is already an isolation scope in the current execution context, then the new isolation scope should be forked off the previous one (with copy-on-write).
169+
170+
### When to create an isolation scope
171+
172+
For most server-side SDKs, an isolation scope will be created for each request being processed.
173+
Roughly, it will equate to each time we currently fork a hub.
174+
175+
### Examples for isolation scopes
176+
177+
Example for instrumentation that we would write:
178+
179+
```ts
180+
function wrapHttpServerRequest(original: Function): Function {
181+
// Fork an execution context for this server request, that is isolated
182+
return Sentry.withIsolatedScope((scope) => {
183+
// anything in here will have the same isolated scope!
184+
return original();
185+
})
186+
}
187+
```
188+
189+
Example for hooking into external auto-instrumentation (e.g. OpenTelemetry):
190+
191+
```ts
192+
let onRequestHook: (span: Span) => void;
193+
194+
// This method is not defined by us, but is some external code
195+
// Here just for demonstration purposes of how that may be implemented
196+
function otelWrapHttpServerRequest(original: Function): Function {
197+
// Fork an execution context for this server request,
198+
// but without isolating this!
199+
return Sentry.withScope((scope) => {
200+
onRequestHook(trace.getActiveSpan());
201+
return original();
202+
});
203+
}
204+
205+
// This would be our custom sentry configuration
206+
onRequestHook = () => {
207+
const scope = getScope();
208+
scope.isolate(); // Add an isolation scope to the already forked scope
209+
}
210+
```
211+
212+
## Applying scopes
213+
214+
Scopes are applied in this order to events:
215+
216+
```ts
217+
class Scope {
218+
public captureEvent(event: Event, additionalScope?: Scope) {
219+
// Global scope is always applied first
220+
const scopeData = getGlobalScope().getScopeData();
221+
222+
// Apply isolations cope next
223+
const isolationScope = getIsolationScope();
224+
merge(scopeData, isolationScope.getScopeData());
225+
226+
// Now the scope data itself is added
227+
merge(scopeData, scope.getScopeData());
228+
229+
// If defined, add the captureContext/scope
230+
// This is e.g. what you pass to Sentry.captureException(error, { tags: [] })
231+
if (additionalScope) {
232+
merge(scopeData, additionalScope.getScopeData());
233+
}
234+
235+
// Finally, this is merged with event data, where event data takes precedence!
236+
mergeIntoEvent(event, scopeData);
237+
}
238+
}
239+
```
240+
241+
Note that there is _always_ exactly one global & one isolation scope active.
242+
243+
## What about environments that do not have isolation of execution contexts (e.g. mobile, browser)?
244+
245+
Where not useful, you simply don't have to use the isolation scope. But it's always there, if the need arises.
246+
While it is empty it does nothing anyhow.
247+
248+
## What should be called from top level methods?
249+
250+
Top level APIs should generally interactive with the current active scope:
251+
252+
```js
253+
Sentry.setTag();
254+
Sentry.setUser();
255+
Sentry.captureException();
256+
// ...
257+
```
258+
259+
The only exception is `addBreadcrumb()`. This should generally add breadcrumbs to the currently active isolation scope.
260+
SDKs _may_ also add an option to the client to opt-in to put breadcrumbs on the global scope instead (e.g. for mobile or scenarios where you always want breadcrumbs to be global).
261+
122262
## Should users care about Clients?
123263

124264
Generally speaking, for most regular use cases the client should be mostly hidden away from users.
@@ -136,17 +276,21 @@ These occurences should be updated to instead take a scope or a client.
136276
We should strive to provide a wrapper/proxy `getCurrentHub()` method that still exposes the key functionality to ease upgrading. E.g.:
137277

138278
```js
139-
import {getScope, getClient} from '../internals';
279+
import { getScope, getClient, captureException, withScope } from '../internals';
140280

141281
function getCurrentHub() {
142282
return {
143283
getClient,
144-
getScope
284+
getScope,
285+
captureException,
286+
withScope,
287+
// ...
145288
}
146289
}
147290
```
148291

149-
We need to decide what to keep in that proxy and what not.
292+
Based on the SDK, we can decide to keep _everything_ in this proxy (then we can do this even in a minor release),
293+
or keep _most of it_ (if we do a major) - to break as little things in user land as possible.
150294

151295
## What about globals?
152296

0 commit comments

Comments
 (0)