Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge Hub & Scope in SDKs #122

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open

Merge Hub & Scope in SDKs #122

wants to merge 12 commits into from

Conversation

mydea
Copy link
Member

@mydea mydea commented Nov 8, 2023

This RFC is about merging the concepts of hubs & scopes in the SDK into a singular Scope concept.

Rendered RFC

Note that this RFC is specifically designed to be relevant to all SDKs. For more specific requirements/changes for e.g. the JavaScript SDK, there is a separate RFC here: #120. This way we can keep the discussion more focused on generally applicable things vs. more language/SDK specific things.

@mydea mydea self-assigned this Nov 8, 2023
Copy link
Member

@cleptric cleptric left a comment

Choose a reason for hiding this comment

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

Is the plan to remove the hub or hide it from the user-facing API moving forward?

text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Show resolved Hide resolved
Comment on lines 92 to 100
```js
const client1 = new Client();
const client2 = new Client();

const scope1 = client1.getScope();
const scope2 = client2.getScope();

scope1.captureException(); // <-- isolated from scope2
```
Copy link
Member

Choose a reason for hiding this comment

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

👍

Copy link
Member

@markushi markushi left a comment

Choose a reason for hiding this comment

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

Nice RFC! I left a few comments, looking good overall! My only concern is about the ownership of scope / clients, see my comments.

text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved

`client.getScope()` should return the current scope of this client in the current execution context.

From a users perspective, this should mostly not be noticeable - they can always run `getScope()` to get the current scope, or `withScope(callback)` to fork a new scope off the current scope.
Copy link
Member

Choose a reason for hiding this comment

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

This has been a major pain point in the past: withScope() was used in places where we actually wanted to change the current scope, but instead created a fork and then people were wondering why the changes were not visible on the original scope. Not sure if feasible within this RFC, but renaming withScope() to forkScope() would help here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have no strong feelings about withScope, interested to hear what others think on this! I'd be fine with either.

Choose a reason for hiding this comment

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

I find forkScope better, also considering this:

You can still clone scopes manually the same way as before, e.g. via Scope.clone(oldScope) or a similar API. In contrast to withScope(), this will not fork an execution context.

Both essentially create a new scope off of an existing one, but in different ways. It'd be good to have the name communicate what the difference is.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think the main reason to keep withScope is to avoid breaking as much as possible. We could also consider keeping withScope (deprecated?) and adding forkScope as an alias.

For reference only, OTEL uses context.with(callback) to do the same, so it seems we are not the only ones using this wording. Doesn't mean we have to keep it, just pointing this out :)

Copy link
Member

Choose a reason for hiding this comment

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

Isn't withScope() mainly used to create a temporary scope which is only applied to the callback?
I don't see a good reason to rename/remove this.

Copy link
Member Author

Choose a reason for hiding this comment

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

IMHO generally we should strive to replace this usage of withScope - only to add a temporary event processor - with different pattern(s). (not possible for all cases, but most of them). So generally there should be little (not none) need to use it manually!

mydea and others added 2 commits November 9, 2023 10:14
Co-authored-by: Markus Hintersteiner <markus.hintersteiner@sentry.io>
@mydea
Copy link
Member Author

mydea commented Nov 9, 2023

Updated this based on feedback in regard to scope<>client relationship.

Now, a scope holds a reference to a client, which can be set via scope.setClient(). The client is inherited by child scopes normally. scope.getClient() returns a Noop client if none has been set yet (=init has not been called yet).

Copy link
Member

@adinauer adinauer left a comment

Choose a reason for hiding this comment

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

Left some questions / comments. Generally I'm surprised to see such a focus on client and its APIs. In Java SDK these are rarely touched by clients and rather hidden away.

text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
@mydea
Copy link
Member Author

mydea commented Nov 9, 2023

Left some questions / comments. Generally I'm surprised to see such a focus on client and its APIs. In Java SDK these are rarely touched by clients and rather hidden away.

This is something that should not concern most users, but for those who need it (multiple clients...) we need to make sure stuff still works/is possible to do. But yes, we should def. focus on making the "regular" use case most convenient and abstracted away!

@antonpirker
Copy link
Member

I read it and everything looks reasonable. How clients and the noop client are handled i have not yet fully figured out for python. But I guess I need to tinker with some code to fully get the picture and give meaningful feedback. Will try to make time for this this week.

@philipphofmann
Copy link
Member

philipphofmann commented Nov 13, 2023

I don't have much to add from the Mobile perspective because, on Mobile, we encourage our users to call the static APIs Sentry.captureException, etc., and hardly any users interact with the hub or client. I don't see a problem merging the scope and the hub for Mobile. Correct me if I missed something on other mobile platforms, @krystofwoldrich, @romtsn, @markushi, @brustolin, @stefanosiano.

Generally I'm surprised to see such a focus on client and its APIs. In Java SDK these are rarely touched by clients and rather hidden away.

I agree with @adinauer. I would actually love to make the client internal/private. I would prefer passing options to the scope, and that's all the flexibility you get.

On Cocoa, we don't support creating your own implementation of a hub, client, or transport, and nobody ever complained. Our only complaints so far are that some customers want to send the data to Sentry and their backend, but that has no impact on this RFC.

@adinauer
Copy link
Member

adinauer commented Nov 14, 2023

make the client internal/private

There are users who customize clients, e.g. to support multi DSN or send events somewhere else as well. While rare this is still a use case we probably shouldn't drop without good reason.

@mydea
Copy link
Member Author

mydea commented Nov 14, 2023

make the client internal/private

There are users who customize clients, e.g. to support multi DSN or send events somewhere else as well. While rare this is still a use case we probably shouldn't drop without good reason.

IMHO the goal should be to hide the client away as much as possible by default (which we already do anyhow generally, via init() - nobody will generally see a client reference in this scenario), but allow to configure it for "edge cases" (as @adinauer mentioned). But I also think that this can be decided in detail on a SDK-by-SDK basis, where it makes sense to adjust this, do it! (FWIW in JS there are some scenarios where you need to interact with a client manually)

@mydea mydea changed the title DRAFT: Merge Hub & Scope in SDKs Merge Hub & Scope in SDKs Nov 14, 2023
@mydea mydea marked this pull request as ready for review November 14, 2023 10:03
@mydea
Copy link
Member Author

mydea commented Nov 14, 2023

I applied all the feedback and moved this out of draft - I think this is ready to "really" review!

@krystofwoldrich
Copy link
Member

From RN point of view I don't we can merge Hub and Scope. 99% time users can use Sentry.captureException static API, the same as in browser JS.

RN Uses only one Hub and extends browser JS implementation of Scope to synchronize with the native SDKs (iOS, Android).

Since RN heavily depends on browser JS SDKs, we will have to adopt it basically immediately when this changes in JS. Event if the native SDKs haven't made the change at the time, it should not affect the scope synchronizing.

@mydea
Copy link
Member Author

mydea commented Nov 15, 2023

From RN point of view I don't we can merge Hub and Scope. 99% time users can use Sentry.captureException static API, the same as in browser JS.

RN Uses only one Hub and extends browser JS implementation of Scope to synchronize with the native SDKs (iOS, Android).

Since RN heavily depends on browser JS SDKs, we will have to adopt it basically immediately when this changes in JS. Event if the native SDKs haven't made the change at the time, it should not affect the scope synchronizing.

This change will def. happen in a major only, so there should be enough time etc. to sync this up with RN!
The general Scope API should also remain more or less similar to right now, so IMHO it should be possible to continue doing more or less what you're doing right now in RN (*small tweaks will probably be necessary, but nothing we can't figure out!)

@philipphofmann
Copy link
Member

After talking to more mobile folks, we don't see a problem with moving forward with this, but we don't see a need to change that cause it doesn't cause any problems in our SDKs. We will most likely make that change in one of our next major versions, but we won't do a major version because of that.

@sl0thentr0py sl0thentr0py self-requested a review November 16, 2023 10:25
@mydea
Copy link
Member Author

mydea commented Nov 16, 2023

After talking about this with @mitsuhiko , I updated this RFC to again include the (updated) concepts of a global scope, as well as an isolation scope.

The idea is that ALL SDKs should have the concepts. Although it may be less relevant for some - but in these cases, they will simply do nothing anyhow. But esp. for isomorphic envs like JS, the same APIs should exist for server & client.

Please re-read the RFC for the added sections. We had some talks before about the fact that this is not equally relevant for all SDKs (which is why I initially split this into two RFCs), but @mitsuhiko reiterated that we should do this in an aligned way.

## Global Scope

In addition to the currently active scope, there will also be a new special scope, the **Global Scope**.
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.
Copy link
Member

Choose a reason for hiding this comment

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

This would make the client stateful all of a sudden. Not the end of the world, but feels odd.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not necessarily. This does not mean that the global scope is defined on the client, but that the global scope has a reference to the client :) the mechanism to look up the global scope can/should probably be more in parallel to getting the active scope. Think of it like this:

const executionContext1; // this is one execution context somewhere
const executionContext2; // this is some other execution context somewhere else

executionContext1.globalScope --> Scope
executionContext2.activeScope --> Scope
executionContext3.isolationScope --> Scope

So all three of these scopes can be put on the execution context (or wherever the active scope is put). How exactly this is done would be up to the SDK/language, but the idea is that the client remains stateless.

Choose a reason for hiding this comment

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

I was also initially confused by this, but I think it's just a matter of wording this a bit differently. It says:

but a special scope that belongs to a client and is applied to any event that belongs to this client

This makes it sound like the client owns the scope, even though technically it's the other way around. Maybe we can write something like

Suggested change
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.
The global scope is _not_ the initial scope, but a special scope that has a client and is applied to any event that belongs to its client.

Copy link
Member Author

Choose a reason for hiding this comment

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

The tricky thing to convey (and actually, we need to specifically spec this out), is that each client has a separete global scope.

At least, I think this is what makes most sense 🤔 When talking with @mitsuhiko both options are possible (global scope is truly global, or global scope is basically a "client scope").

Personally I tend to the idea that each client should have it's own global scope - simply because thinking about e.g. MFE and similar, we probably don't want to "pollute" other clients when doing getGlobalScope().setTag().

Wording wise, we could say:

The global scope is not the initial scope, but a separate, special scope. The global scope is different for each client, and is applied to every event.

Or something like this?

text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
Comment on lines 148 to 149
Furthermore, there can also be **Isolation Scopes**.
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:
Copy link
Member

Choose a reason for hiding this comment

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

The client already holds the global scope, and now it also holds an isolation scope. But we do not expect to create a new client for each request, so the client should hold a stack of isolation scopes IMO, which will be tricky. Can we somehow clarify how this all should work?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a comment above hopefully explaining the idea here - was probably not fully clear from how I wrote this down in the RFC. TLDR: active scope, global scope, isolation scope should be stored "next to each other" on each execution context. nothing should be stored directly on the client, which should remain stateless.

text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved
text/0122-sdk-hub-scope-merge.md Outdated Show resolved Hide resolved

# Unresolved questions

TODO
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
TODO
- What is the user experience like if there is no instrumentation to set isolation scopes?

@szokeasaurusrex
Copy link
Member

I agree with @antonpirker's assessment that these changes seem reasonable at first glance for the Python SDK.

My one suggestion for the RFC would be to consider adding a visualization, similar to the one on the unified API docs page, to describe how these changes will affect the API at a high level.

mydea and others added 2 commits November 20, 2023 08:56
Co-authored-by: Ivana Kellyerova <ivana.kellyerova@sentry.io>
An isolation scope is similar to a global scope, but tied to a specific section of your app/code that should be isolated.
In a server side language, an isolation scope will commonly be created per request. Each request gets an isolated scope that you can assign data to, which should be applied to all events that are processed in this request. However, an isolation scope is not limited to requests - you could also have an isolation scope for a scheduled job, or for a zone in your UI (in theory).

The isolation scope can be used to e.g. add user or request data that should be applied to any event for the current request (or similar isolation unit). Similar to the global scope, it does not matter where you call it, but you can always be sure that data is correctly attached/available. This can especially be useful when writing hooks or plugins where you may not easily get access to the scope of a request:
Copy link
Member

Choose a reason for hiding this comment

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

Let's explicitly call out that breadcrumbs should be added on the isolation scope.

Copy link
Member Author

Choose a reason for hiding this comment

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

I mention this further down here: https://github.com/getsentry/rfcs/blob/fn/merge-hub-scope/text/0122-sdk-hub-scope-merge.md#what-should-be-called-from-top-level-methods

but it's a general challenge for this RFC that stuff feels like it should be explained earlier that comes up later, making it hard to keep it somewhat readable 😬

Copy link
Member

Choose a reason for hiding this comment

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

My comment here was I think that the breadcrumbs use case helps build the best intuition here why we need a isolation scope concept, it's also why we need something like this while opentelemetry does not fwiw.

Copy link
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

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

Thanks for the detailed RFC @mydea 👏

I like the idea of merging hub & scope, but I don't think an isolation scope is needed for Mobile. It will only confuse users. Furthermore, I'm confused about where to store the current scope on Mobile and Browser JS. If we keep it globally, it's basically the same as the global scope.

}
```

The global scope is _not_ the initial scope, but a separate, special scope. The global scope is different for each client, in order to avoid polluting this on accident when working with multiple completely separate clients.
Copy link
Member

Choose a reason for hiding this comment

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

I'm a bit confused as to why the global scope is different for each client. You set the client to the scope, but you also need to keep a global scope for every client. If I got this right, do we need some bookkeeping of global scopes for clients?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is something I am not 100% sure about yet. On the one hand, it kind of makes sense that a global scope is truly global. Thinking about this some more, probably you are right, and this should be truly global. If you have multiple clients, it's probably up to you to simply not use the global scope if you care about this not being polluted!

Copy link
Member

Choose a reason for hiding this comment

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

If you have multiple clients, it's probably up to you to simply not use the global scope if you care about this not being polluted!

Yes. Furthermore, we don't need bookkeeping of global hubs for clients. I think that a truly global scope would simplify things.

}
```

The global scope is _not_ the initial scope, but a separate, special scope. The global scope is different for each client, in order to avoid polluting this on accident when working with multiple completely separate clients.
Copy link
Member

Choose a reason for hiding this comment

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

I am confused as to why the global scope is different for each client. If I got this right, do we need some bookkeeping of global scopes for clients? How is that going to work?
I would expect that the global scope is global 😄. It applies to all events.

});
```

Note that in environments where you do not have multiple execution contexts (e.g. Browser JS), there is basically only a single isolation scope that is fundamentally the same as the global scope. In these environments, global & isolation scope can be used completely interchangeably. However, for consistency we still have these concepts everywhere - but users do not need to care about them, and we can reflect this in the SDK specific documentation.
Copy link
Member

Choose a reason for hiding this comment

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

I don't think adding the isolation scope is a good idea when it's the same as the global scope. People will ask questions and need clarification about what the difference is. If it's the same, I would drop the isolation scope. We can always add it.

Copy link
Member Author

Choose a reason for hiding this comment

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

it's not the same - the global scope will always be the same, while the isolation scope can be changed per context. In environments where there are no execution contexts (e.g. browser or probably mobile), they are more or less the same. But the idea is generally to not really expose this to users too much - users should just use Sentry.doX and not Sentry.getCurrentScope().doX, and we route it to the correct scope under the hood. that should work just as well in browser or mobile.

So the concept should still be there for consistency sake, and it does work (e.g. you can call it), it's just not commonly useful by default - which is fine, nobody is forced to use it, and users should not have to think about it! but for example looking at browser, if somebody decides to add and setup zone.js or something like this, or browsers eventually add something like execution contexts, we can then also wire this up to work there as well.

Copy link
Member

Choose a reason for hiding this comment

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

I'm OK with your proposal.


Isolation scopes will be documented in a new section under "Enriching Events > Per request data" (or similar, based on SDK language & needs), which will explain how to use `getIsolationScope()` and when/how data added there will be applied.

Global methods to set data, like `Sentry.setTag()` or `Sentry.setUser()`, will set on the isolation scope. Only if you want to set on a different scope, you need to manually get it (e.g. `getGlobalScope().setTag()`).
Copy link
Member

Choose a reason for hiding this comment

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

On mobile, global methods should change the global scope, as we don't need an isolation scope, IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

it can/should still set on the isolation scope, which in absence of execution contexts will be functionally basically global. But if execution contexts are ever added in some way, this will then also "just work" - that's the idea here, at least.

Scopes are applied in this order to events:

```ts
class Scope {
Copy link
Member

Choose a reason for hiding this comment

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

So, clients will not be aware that there is a scope. The Scope class merges data into the event before passing it to the client. Did I get this correctly?

Copy link
Member Author

Choose a reason for hiding this comment

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

that's the idea, this is also how it works today (at least in JS and I think more or less everywhere?) - this RFC does not propose to change this from it is today, except that the special scope data is also applied to the events!


- How should we handle this in docs?
- Naming: Can we come up with something better than Isolation Scope?
- Global Scope: Should it be per-client (current RFC) or truly global?
Copy link
Member

Choose a reason for hiding this comment

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

IMO, if you call it global, then it should be truly global. If you want it to be per-client, I would choose a different name.


The following chart gives an overview of how these scopes work and to which events they would be applied:

![image](./0122-images/scope-inheritance.png)
Copy link
Member

Choose a reason for hiding this comment

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

Is the green scope in that diagram the current scope? If yes, you could update the diagram by renaming scope to current scope to make it more straightforward.


### How to store/reference scopes

Similar to how we currently store the current hub, we also need to store the current scope. The exact mechanism to do that is up to the concrete SDK/language. In environments with multiple execution contexts (or thread locals), we should attach the current scope to the execution context (or thread local). In other environments, it may be attached to some global (e.g. in Browser JS). This RFC does not propose to change this, so generally the current scope may be stored the same was as the current hub used to be.
Copy link
Member

Choose a reason for hiding this comment

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

If we store the current context globally on mobile and Brower JS, where there is no execution context, how is the current scope different from the global scope?

Copy link
Member Author

Choose a reason for hiding this comment

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

In e.g. mobile or browser, we'll need to implement the current scope somehow differently. Either we still need to do it via some kind of stack, or with some other mechanism - the idea is to get as close to this desired UX as possible, with the tech that is available!

async function setupGlobalData() {
const revision = await getRevision();
// No need to worry or care about where this is called, this will _always_ work!
Sentry.getGlobalScope().setTag("revision", "abc");
Copy link
Member

Choose a reason for hiding this comment

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

On Mobile, Sentry.setTag("revision", "abc") already does what Sentry.getGlobalScope().setTag("revision", "abc"); does, as the static API of Sentry. has a hub bound including a scope.


- This changes _a lot_ of public APIs and behavior.

# Unresolved questions
Copy link
Member

Choose a reason for hiding this comment

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

We still need to figure out how scope sync across multiple layers works. For example, on RN we sync the scope to Cocoa, which syncs the scope to SentryCrash. I guess the SDKs should only sync the global scopes. We don't sync the scope from a lower layer, for example, Cocoa to RN.


## Applying scopes

Scopes are applied in this order to events:
Copy link
Member

Choose a reason for hiding this comment

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

Given the example on how scopes are merged I'm wondering what happens the other way round when scopes are read? E.g. Would Sentry.getCurrentScope().getRelease() return a value when the release was set on the global scope ~ Sentry.getGlobalScope().setRelease("1.0.0")

Copy link
Member Author

Choose a reason for hiding this comment

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

It's a good question! I would say it should only return the value on the actual scope being accessed. If you want to have some cascading behavior it should be a top-level method like Sentry.getRelease() or something like this?

# Unresolved questions

- How should we handle this in docs?
- Naming: Can we come up with something better than Isolation Scope?
Copy link
Member

Choose a reason for hiding this comment

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

Just throwing in some ideas: How about having a Global Scope / Root Scope / Current Scope? Or if we want to be similar to otel, naming it Context instead of scope; although it's not exactly the same as context is just a part of the scope as I understood it. Then we'd have Global Execution Context / Root Execution Context / Current Execution Context.
Not super happy with my ideas either, as root could imply some tree structure 🤷

Copy link
Member Author

Choose a reason for hiding this comment

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

We also had root as a name already in earlier iterations here 😅 which was also found to not be super understandable by some folks. It's a hard naming problem 😬

antonpirker added a commit to getsentry/sentry that referenced this pull request Jan 22, 2024
Cleaning up existing code to make the refactoring of Hubs and Scopes
easier. (See getsentry/rfcs#122)

If this dogfooding will not raise any issues, we will merge and release
it in the Sentry SDK.
isabellaenriquez pushed a commit to getsentry/sentry that referenced this pull request Jan 22, 2024
Cleaning up existing code to make the refactoring of Hubs and Scopes
easier. (See getsentry/rfcs#122)

If this dogfooding will not raise any issues, we will merge and release
it in the Sentry SDK.
Copy link
Member

@adinauer adinauer left a comment

Choose a reason for hiding this comment

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

After giving this some more thought, looking at the python implementation currently being done and starting to think about how this would work for Java, I've now given this a more in depth review - sorry it's happening so late.


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.

A current scope always inherits from the previous current scope.
Copy link
Member

Choose a reason for hiding this comment

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

I'm assuming this only applies when withScope is used exclusively and setCurrentScope is never called, correct? Otherwise what stops you from simply creating a new scope and setting it as current?

export function getIsolationScope(): Scope;

// Create a new isolation scope for this scope
// This will NOT make this scope the isolation scope, but will create a new isolation scope (based on the active isolation scope, if one exists)
Copy link
Member

Choose a reason for hiding this comment

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

This confuses me. So what happens if I call getActiveScope().isolate()?
Would it do some sort of multi clone where it clones the active scope and the isolation scope of the current execution context at the same time?
Or is this a method that should only be used on an isolation scope?

Also linked to this confusion is the following sentence:

The isolation scope always inherits from the previously active isolation scope.

If .isolate() has nothing to do with the scope it's called on I'd argue the method should go somewhere else or be skipped completely.

How long does the isolation scope set via scope.isolate() stay active?

### When to create an isolation scope

For most server-side SDKs, an isolation scope will be created for each request being processed.
Roughly, it will equate to each time we currently fork a hub.
Copy link
Member

Choose a reason for hiding this comment

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

I'm assuming the answer here is that adding tags, breadcrumbs, setting user etc. might get lost due to the user setting them on a scope that'll be lost and thus won't apply to sibling scopes.

// ... request incoming
const requestScope = getCurrentScope().fork() // whatever the correct syntax here would be

// some part of handling the request
val somePartScope = requestScope.fork()
setCurrentScope(somePartScope);

// somewhere her the user sets tags, breadcrumbs and user which is applied to events happening within the same scope or child scopes

// a different part of handling the request
val otherPartScope = requestScope.fork()
setCurrentScope(otherPartScope);

// tags, breadcrumbs and user are not applied for things happening here

The same example using isolation scope would allow the user to set tags, breadcrumbs, user etc. for the whole request without having to be in the right place to do so (i.e. gain access to the requestScope instead of a child scope). In other words isolation scope just seems to be an explicit concept for adding things to a scope specific for a request or whatever other things we wanna use it for (e.g. cron execution).

This behaviour would happen e.g. when we switch threads where today we clone the hub onto the other thread but when execution comes back the hub from that thread doesn't affect the rest of the request handling part. In the future instead of cloning the hub we'd be forking execution context + scope I presume.


This RFC aims to align this so we can handle the OTEL way of context forking (=doing this very frequent and liberally).

Also, the RFC acknoledges that not all of the concepts laid out below are equally important for every environment - e.g. for Browser JS or mobile, we may not have multiple execution contexts, so some of the concept may not be so relevant. However, we still want to align the concepts across all SDKs for the following reasons:
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Also, the RFC acknoledges that not all of the concepts laid out below are equally important for every environment - e.g. for Browser JS or mobile, we may not have multiple execution contexts, so some of the concept may not be so relevant. However, we still want to align the concepts across all SDKs for the following reasons:
Also, the RFC acknowledges that not all of the concepts laid out below are equally important for every environment - e.g. for Browser JS or mobile, we may not have multiple execution contexts, so some of the concept may not be so relevant. However, we still want to align the concepts across all SDKs for the following reasons:

export function getGlobalScope(): Scope;

// get the curren isolation scope
export function getIsolationScope(): Scope;
Copy link
Member

Choose a reason for hiding this comment

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

Some SDKs may also need a setter for isolation scope. In Java some frameworks do not play nicely with the callback based approach and instead we're just being told that something's about to happend and something just happened (e.g. changing threads).

// This method is not defined by us, but is some external code
// Here just for demonstration purposes of how that may be implemented
function otelWrapHttpServerRequest(original: Function): Function {
// Fork an execution context for this server request,
Copy link
Member

Choose a reason for hiding this comment

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

I also don't really understand this example here. So is otelWrapHttpServerRequest some generic method, that's not defined by us? Why is that able to run withScope then if it has nothing to do with Sentry? Is this a generic OTEL hook that always triggers when a new span is created and automatically forks the current scope via withScope?

I'm unable to find scope.isolate() in JS implementation (https://github.com/search?q=repo%3Agetsentry%2Fsentry-javascript+%22isolate%22&type=code). I have another comment further up about it as well where I'm unsure what it should do.

It looks like this has been implemented using getIsolationScope.clone() instead of scope.isolate() here https://github.com/getsentry/sentry-javascript/blob/b0752b5d3e0840f3359688c58619fb0f638470bc/packages/opentelemetry/src/contextManager.ts#L53

Should we remove it if it's not implemented and unclear?

// ...
```

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).
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't isolation scope and global scope be the same usually on mobile anyways?

While they have generally served us well, the shortcomings of the current systems have become apparent over time.
Apart from generally being unclear to users - what is applied when, why, how? - there are also problems with the way we currently instruct users to add event data.

A major reason for this change is also to better align with OpenTelemetry. In OpenTelemetry (OTEL), the equivalent to our scope is the "Context". However, the Context is immutable in OTEL, and works via copy-on-write - so each time you want to change _anything_ on the context, you fork it. This means that you have much more forking than you have in Sentry currently, and also since you always fork a context when you change the active span (as the span is set on the context, and updating it means forking a context), you can quickly get into deeply nested context situations due to auto instrumentation.
Copy link
Member

Choose a reason for hiding this comment

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

This RFC doesn't seem to mention where to store the current span. While we might no longer have a Sentry span after completely switching over to OTEL, there'll likely be intermediate steps as well as some SDKs that can't yet move to pure OTEL spans.

Where we could put it:

  • Isolation span
    • I don't think that's a good idea as
      • It's a potential footgun allowing users to hide the current span set on isolation scope by the SDK by setting a span on the current span
      • When a span ends we have to restore the previous span on the isolation scope
  • Current Span
    • Since we're forking current scope anytime a new span is created anyways this is a good candidate
    • A drawback here is that it requires a more complex copy-on-write implementation that's per property instead of simply copying the whole scope. It seems the number one reason for copying scope will be creating a new span.
  • Execution Context
    • This would keep current span separate from current scope and thus not cause as many current scope copies

Copy link
Member

Choose a reason for hiding this comment

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

Another thing that probably should be mentioned is how to treat PropagationContext, which is used for tracing without performance (TwP).

I think it makes sense to put it on the isolation scope when we e.g. handle a new incoming request. Should Sentry.continueTrace() also write to isolation scope? This would probably only work if it's always combined with forking an isolation scope so maybe there should be a more abstract API to isolate which then forks isolation scope, current scope and sets a new propagation context on the isolation scope.

Things to consider here:

  • freezing baggage should probably apply to the whole isolation scope as well
  • if we put propagation context on the current scope we probably can't clone it but rather have to share the instance down into forked scopes
  • this might be closely related to the question of whether to always for isolation scope and current scope together


## What about other Hub references?

While the Hub is mainly exposed via `getCurrentHub()`, it is also used as argument or similar in many places.
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't this need to access all three scopes (global, isolation and current) instead of the single hub?


In environments where you don't have execution contexts, you may store them in a global place. The exact mechanism to do this is up to the concrete SDK/language.

For each execution context, there will _always_ be exactly one current & isolation scope. When the execution context is forked, these are forked along.
Copy link
Member

Choose a reason for hiding this comment

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

I feel like this can easily be misinterpreted as if you fork execution context (e.g. because a new span was started) you always have to fork isolation scope and current scope.

Maybe we should add a chapter about forking scopes as well as passing scopes along when e.g. changing to another thread. I feel like whenever we fork an isolation scope we should also fork the current scope as otherwise we'd open the door for scope bleed and always require users to call both withIsolationScope and withScope in combination. When moving to another thread we should bring along both isolation and current scope to avoid scope bleed from things that happened on that thread before.

Copy link
Member

Choose a reason for hiding this comment

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

Another thing here is how do we figure out, when to create a new isolation scope for OTEL auto instrumentation spans?

Do we check if a span has no parent in the current process and then fork isolation scope for it? Does this happen in SpanProcessor?

Does this also work the same way, e.g. for queue workers?

Copy link
Member

@adinauer adinauer left a comment

Choose a reason for hiding this comment

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

Today @antonpirker and me discovered some more questions / remarks.


## What should be called from top level methods?

Top level APIs that set something should generally interact with the isolation scope:
Copy link
Member

@adinauer adinauer Feb 22, 2024

Choose a reason for hiding this comment

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

h I'm concerned about upgrade experience for users here.

Previously, Sentry.setTag() and Sentry.configureScope(s -> s.setTag) produced the same result.
In the future this is no longer true, since Sentry.setTag() will suddenly write to isolation scope and thus leak out of the scope it was previously bound to. While I agree this is a desired behaviour for the future, users upgrading may not notice this and suddenly have completely different behaviour.

IMO this change should be part of a major and we should try to make it very prominent in changelog, migration guide etc.

Here's an example. Please ignore the fact that pushScope and popScope will be replaced by withScope, the result should still be the same.

Here's an example, where tag A=1 suddenly leaks out to the whole isolation scope.

Previously:

global 		isolation			current

						Sentry.setTag(Z, 99)

						Sentry.pushScope

						Sentry.setTag(A, 1)
						A = 1

						Sentry.configureScope(s -> s.setTag(B, 2))
						B = 2

						HTTP GET -> google, A=1, B=2, Z=99

						Sentry.popScope

						HTTP GET -> reddit, Z=99
						^---------------- old

Future:

global 		isolation			current

		Sentry.setTag(Z, 99)
		Z = 99

						Sentry.pushScope

		Sentry.setTag(A, 1)
		A = 1

						Sentry.configureScope(s -> s.setTag(B, 2))
						B = 2

						HTTP GET -> google, A=1, B=2, Z=99

						Sentry.popScope

						HTTP GET -> reddit, A=1, Z=99
						^---------------- new

While they have generally served us well, the shortcomings of the current systems have become apparent over time.
Apart from generally being unclear to users - what is applied when, why, how? - there are also problems with the way we currently instruct users to add event data.

A major reason for this change is also to better align with OpenTelemetry. In OpenTelemetry (OTEL), the equivalent to our scope is the "Context". However, the Context is immutable in OTEL, and works via copy-on-write - so each time you want to change _anything_ on the context, you fork it. This means that you have much more forking than you have in Sentry currently, and also since you always fork a context when you change the active span (as the span is set on the context, and updating it means forking a context), you can quickly get into deeply nested context situations due to auto instrumentation.
Copy link
Member

Choose a reason for hiding this comment

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

Another thing that probably should be mentioned is how to treat PropagationContext, which is used for tracing without performance (TwP).

I think it makes sense to put it on the isolation scope when we e.g. handle a new incoming request. Should Sentry.continueTrace() also write to isolation scope? This would probably only work if it's always combined with forking an isolation scope so maybe there should be a more abstract API to isolate which then forks isolation scope, current scope and sets a new propagation context on the isolation scope.

Things to consider here:

  • freezing baggage should probably apply to the whole isolation scope as well
  • if we put propagation context on the current scope we probably can't clone it but rather have to share the instance down into forked scopes
  • this might be closely related to the question of whether to always for isolation scope and current scope together


In environments where you don't have execution contexts, you may store them in a global place. The exact mechanism to do this is up to the concrete SDK/language.

For each execution context, there will _always_ be exactly one current & isolation scope. When the execution context is forked, these are forked along.
Copy link
Member

Choose a reason for hiding this comment

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

Another thing here is how do we figure out, when to create a new isolation scope for OTEL auto instrumentation spans?

Do we check if a span has no parent in the current process and then fork isolation scope for it? Does this happen in SpanProcessor?

Does this also work the same way, e.g. for queue workers?

antonpirker added a commit to getsentry/sentry-python that referenced this pull request Feb 26, 2024
This refactors the SDK to move away from the Hub and have all the functionality in the Scope. Introducing different types of scopes. This aligns the SDK with how Opentelementry (OTel) handles data bringing us closer to be 100% OTel compatible.

This change was discussed in this RFC: 
getsentry/rfcs#122

There is also a small FAQ: 
https://gist.github.com/mitsuhiko/1bc78d04ea7d08e5b50d27e42676db80

And a Miro board showing how the new scopes manage data: 
https://miro.com/app/board/uXjVNtPiOfI=/?share_link_id=216270218892

### This RP contains
- Introduction of global, isolation, and current scope
- Deprecation of the Hub
- All existing Hub based API still works and is still used by most of our integrations. Under the hood the new Scopes are used.
- (this PR now includes all the changes made in the [first PR](#2609) introducing the new API)

### Breaking changes
- The Pyramid integration will not capture errors that might happen in `authenticated_userid()` in a custom `AuthenticationPolicy` class.
- The parameter `propagate_hub` in `ThreadingIntegration()` was deprecated and renamed to `propagate_scope`.
## Clients

Instad of picking the client from the current hub, going forward a client should always be fetched via `Sentry.getClient()`.
This should _always_ return a client. If `Sentry.init()` was not called yet, it should return a Noop Client that does nothing.

Choose a reason for hiding this comment

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

Instad of picking the client from the current hub, going forward a client should always be fetched via Sentry.getClient().

This feels odd. If SDK users are only using one client (Sentry.getClient() implies a static/singleton reference to a Sentry Client) then this could work. It might be easier to make the Scope Stack a property of the client though and use typical DI techniques to get a reference to the Client... avoiding all the problems with statics/singletons.

If SDK users have multiple clients, getting the client via Sentry.getClient() is problematic. It results in weird things like temporarily pushing a different client to the scope stack (so Sentry.getClient() can return the top/current client from the stack)... in the case of Global/Shared scope, this could easily lead to problems.

For SDK users and SDK designers, it would be easier if the ScopeStack was a property of the client. If SDK users wanted two different clients to share the same Scope, those clients could be configured/initialised with some kind of shared scope stack (like the GlobalScope from this RFC)... but events would always end up being sent to the correct Sentry instance/project.

I'm not sure I understand why/when anyone would want the client to be ambient context (in the same way that scopes are) or why anyone would maintain a stack of clients (except as a workaround to our SDK design - namely that we retrieve the client reference via a static/singleton reference).

Copy link
Member

Choose a reason for hiding this comment

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

This should just work like other things on static API that interact with the scope by creating a sort of view on top of global, isolation and current scope, then handing back whatever wins according to logic that should be detailed somewhere in this RFC. There shouldn't be a need to change the stack and stack should in theory go away with the proposed changes here.


An isolation scope is similar to a global scope, but tied to a specific section of your app/code that should be isolated.
In a server side language, an isolation scope will commonly be created per request. Each request gets an isolated scope that you can assign data to, which should be applied to all events that are processed in this request. However, an isolation scope is not limited to requests - you could also have an isolation scope for a scheduled job, or for a zone in your UI (in theory).

Choose a reason for hiding this comment

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

Maybe not necessary to implement IsolatedScopes in all SDKs.

The dotnet SDK has an asynclocal scope manager that achieves this already. I'm not sure how unique that is to .NET though and whether equivalents exist in other languages/runtimes.

Copy link
Member

Choose a reason for hiding this comment

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

Does that mean in dotnet you can have both an isolation scope (via asynclocal scope manager) and a separate current scope?

Choose a reason for hiding this comment

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

Possibly but it's not really a problem we'd need to solve in .NET. In ASP.NET Core requests get handled by middleware which is async/await all the way through and so we use an asynclocal scope container (that has affinity for these requests).

For most server-side SDKs, an isolation scope will be created for each request being processed.

... this isn't really necessary in .NET as we already get that for free from asynclocal.

Copy link
Member

Choose a reason for hiding this comment

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

I was trying to say that parts of static API interact with isolation scope and other parts interact with current scope. I'm guessing you have to propagate both using this asynclocal scope container. Not sure about .NET but there'll probably be some Sentry.withScope() and/or Sentry.pushScope/popScope API that forks current scope.

Copy link

@jamescrosswell jamescrosswell Mar 4, 2024

Choose a reason for hiding this comment

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

Not sure about .NET but there'll probably be some Sentry.withScope() and/or Sentry.pushScope/popScope API that forks current scope

There is yes. There's the SentrySdk.ConfigureScope which clones the current scope, puts the clone on the stack, then pops it off again once it's done (ie. when it's disposed).

I guess we could replace that with SentrySdk.GetIsolationScope or similar but I don't understand why we would. It doesn't seem to solve a problem that any of our users have and it doesn't get us any closer to using OTEL for spans (at least not for the .NET SDK - I realise it may be a prerequisite for the JavaScript SDKs).

- RFC PR: https://github.com/getsentry/rfcs/pull/122
- RFC Status: draft

# Summary
Copy link
Member

Choose a reason for hiding this comment

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

Since this RFC is no longer being updated, we're writing down how this works and collecting learnings etc. here: https://develop.sentry.dev/sdk/hub_and_scope_refactoring/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.