sentry-javascript: Sentry produces incorrect data when async code is involved
- Review the documentation: https://docs.sentry.io/
- Search for existing issues: https://github.com/getsentry/sentry-javascript/issues
- Use the latest release: https://github.com/getsentry/sentry-javascript/releases
Package + Version
-
@sentry/node
Version:
6.13.3
Description
When dealing with async code sentry fails to properly record data. Not only does it fail to record data, it also constantly adds bad data.
Exception 1: The problem is somewhat hidden when you deal with a context that sits in a sandbox environment which only exists for the duration of ONE AND ONLY ONE transaction. The most popular example would be a browser session, but since we’re speaking node scenarios the other example would be a “cloud function” (ie. pops into existence per request, and only ever handles hopefully the one transactions); there’s also the scenarios that use actual threads (which are rare in the current ecosystem). Needless to say, those 3 I just mentioned are also technically only fine so long as they use only one transactions; if more then one is used for any reason they’re suddenly going to have bad data.
Exception 2: The problem is also hidden when transactions are completely dependent on user input (eg. user has to click some navigation item) and also happen faster then user input. This creates an artificial serial nature to them, hiding the fact that if more then one run at once they botch things since no more then one would ever run at once. However, the more complex the system, the less likely this exception will save you.
The problem is most evident and most aggravating in the scenario of a http server (which arguably covers a lot of the use cases for sentry with nodejs). Since a http server will pickup requests and work on them as-they-come, regardless of if the previous one is finished, sentry has to work perfectly with 2, 3, 1000 transactions running simultaneously.
In general, with your average http server (and similar use cases), it’s your job to keep your stuff in one request out of the others. This isn’t particularly hard normally since all it means is: don’t use globals. Unfortunately sentry seems to do just that, for everything.
I’ve read though maybe 50 issues, and this problem has been brought before under various forms, some more direct then others. The solution has also been mentioned several times. And I’ve also seen the reply of “works for us” with the not-very obvious subtext “in this environment where only one transaction would ever exist before the process exists”.
So, I’ll make this very very simple, by giving you some code you can easily run (and is in accordence to what your documentation, responses to issues, etc are saying is the “correct way”) along with screenshots of your dashboards with datapoints that are as obvious as I can possibly make them for you.
There will be only one file, so just copy all the blocks in a main.mjs file; .mjs is the current nodejs of saying the file uses modern imports.
First some basic boilerplate.
import Sentry from '@sentry/node';
const SENTRY_DSN = ""; // add your test project DSN here
Sentry.init({
dsn: SENTRY_DSN,
release: 'v1', // increment to get clean test sample
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0
});
Next we’ll add a function to help simulate “real world long async operations”
async function delay(time, fn) {
return new Promise((resolve) => {
setTimeout(async () => {
resolve(await fn());
}, time);
});
}
Next we’ll add a function that simulates all the breadcrumbs sentry adds whenever some random 3rd party library is used god knows where in dependencies. The parameter theRealTransaction is only used to make it obvious what the breadcrumbs and spans actually originate from.
async function randomThirdPartyIntegration(theRealTransaction) {
// eg. "Got" library or whatever you guys are doing that's capturing HTTP
// This context intentionally doesn't have access to objects in parameters
// or other methods since that's how it typically happens in the real world.
let hub = Sentry.getCurrentHub();
let scope = hub.getScope();
let transaction = scope.getTransaction();
let span;
if (transaction) {
span = transaction.startChild({
op: 'http_req',
description: `http://google.com (real transaction: ${theRealTransaction})`
});
}
scope.addBreadcrumb({
category: 'http',
message: `REQ http://google.com [Start] (real transaction: ${theRealTransaction})`
});
return await delay(300, () => {
scope.addBreadcrumb({
category: 'http',
message: `REQ http://google.com [End] (real transaction: ${theRealTransaction})`
});
if (span) {
span.finish();
}
});
}
Next we’ll create a function that simulates what may happen in typical http server (or whatever else) request handle; we’ll call it exampleInput since it will be the only function that takes input in theory (our randomThirdPartyIntegration is also a dependency of it).
Edit 1: Original function had incorrect tag code. (As such tags errors were ignored, does not affect rest of issue) Scope has been added to exception handler, to be even more correct. (Still produces same problems)
async function exampleInput ({
name, // the name of this transaction
user, // the user for the transaction
throwsError,
timers: { // delays between actions
action1,
action2,
action3
}
}) {
const tagName = name.replace('#', '');
const hub = Sentry.getCurrentHub();
const scope = hub.pushScope();
scope.setUser({ username: user });
const transaction = hub.startTransaction({
op: "test",
name: `Test Transaction ${name}`,
});
scope.setSpan(transaction);
try {
// Tag
scope.setTag(`T${tagName} Tag 1`, 'yes');
// Action 1
let span1 = transaction.startChild({
op: 'action',
description: `Transaction ${name} Action #1`
})
try {
await delay(action1, () => {
scope.addBreadcrumb({
category: 'step',
message: `Transaction ${name} Action #1`
});
});
}
finally {
span1.finish();
}
// Tag
scope.setTag(`T${tagName} Tag 2`, 'yes')
// Action 2
let span2 = transaction.startChild({
op: 'action',
description: `Transaction ${name} Action #2`
})
try {
await delay(action2, () => {
scope.addBreadcrumb({
category: 'step',
message: `Transaction ${name} Action #2`
});
});
}
finally {
span2.finish();
}
// Random Third Party Dependency
let span3 = transaction.startChild({
op: 'third_party',
description: `Transaction ${name} Third-Party Thing`
});
try {
await randomThirdPartyIntegration(name);
}
finally {
span3.finish();
}
// Tag
scope.setTag(`T${tagName} Tag 3`, 'yes')
if (throwsError) {
throw new Error("Test Error: Something went wrong");
}
// Action 3
let span4 = transaction.startChild({
op: 'action',
description: `Transaction ${name} Action #3`
});
try {
await delay(action3, () => {
scope.addBreadcrumb({
category: 'step',
message: `Transaction ${name} Action #3`
});
});
}
finally {
span4.finish();
}
// Tag
scope.setTag(`T${tagName} Tag 4`, 'yes');
}
catch (err) {
Sentry.captureException(err, scope)
}
finally {
hub.popScope();
transaction.finish();
}
}
And, finally we’ll simulate some transactions. I’ve included a Timeline, but be aware that the timeline isn’t something that will necessarily happen guaranteed as it suggests, its simply where the current settings in the example actually align if plotted on a timeline.
// The following simulates what a basic http server would do
// We don't wait for one transaction to finish before other starts because
// no sane server in the world does that sh*t
// a1 = action1, a2 = action2, a3 = action3
//
// Timeline (ms) 0 300 400 500 600 700 800 900 1000 1100
// -----------------------------------------------------------------------------
// Transaction 1 start a1 a2 a3
// Transaction 2 start a1 a2 a3
// Transaction 3 start a1 a2 a3
// Transaction 3 start a1 a2 -> err
exampleInput ({
name: '#1',
user: 'transaction1_user',
throwsError: false,
timers: {
action1: 500,
action2: 300,
action3: 300
}
})
exampleInput ({
name: '#2',
user: 'transaction2_user',
throwsError: false,
timers: {
action1: 300,
action2: 300,
action3: 300
}
})
exampleInput ({
name: '#3',
user: 'transaction3_user',
throwsError: false,
timers: {
action1: 400,
action2: 300,
action3: 300
}
})
exampleInput ({
name: '#4',
user: 'transaction4_user',
throwsError: true,
timers: {
action1: 600,
action2: 100,
action3: 100
}
})
To run this just do. (after you provide the SENTRY_DSN)
node ./main.mjs
You can also change the release to have a clean copy you can filter to in transactions.
Now lets look at what sentry has to say after running it once. I’ll highlight all the mistakes I can spot with red arrows (there may be more).

In the above transaction 1 doesn’t have a user, even though we set user on every transactions.

Above is Transaction 1. We’re missing the spans generated by the randomThirdPartyIntegration function itself, and we’re missing all breadcrumbs.

Second transaction is also incorrect.

Third transaction somehow got wrong user and for some reason managed to miss the 3rd action breadcrumb.

Fourth transaction also got wrong user and also has 3rd party spans from transaction 2 and 3.

The 4th transaction was set to also throw a error. Above we can see what we get in the error. Why are we getting in the error breadcrumbs that never appear in the transactions themselves?
Anyway, so how is this fixed?
Technically speaking there are three approaches.
The first is to use is to use Async Context Tracking that is made available as stable api from nodejs 16 onwards. This doesn’t solve all the problems with how (from a quick glance at the code) sentry seems to work, but it does mean sentry is able to consistently get the real context with out the use of globals. Specifically the only parts it doesnt’ solve are how sentry currently works with scope; which is not even clear to me how exactly this “ever merging” scope is suppose to work with what is essentially “isolated contexts” (eg. http requests, work queues, etc)
The second method will work in all versions of node, including prior versions (and people paranoid about perf impact of async context tracking), and the method is to just REQUIRE every operation pass the transaction object. So things like Sentry.captureException(err) should really be Sentry.captureException(err, transaction) where the transaction is ignored if null (and I mean IGNORED, no guessing of what it might be, there is none). Scope should just be transaction.getScope().
The third method, and this is purely only for these special cases that sentry devs claim “works for them” is to have a method Sentry.setMasterTransaction that works as follows: if no transaction is set, set the transaction as the master; if a master is already set and not finished then error out and invalidate the master and the current transaction; if the master is set but finished (not counting transport costs), replace the current master with the new transaction. Essentially, you can have your “global transaction” but sentry guarantees you won’t shoot yourself in the foot with it.
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 19
- Comments: 46 (22 by maintainers)
With the
7.48.0release of the JS SDK we useAsyncLocalStorageinstead ofdomainsunder the hood.If you want to manually wrap methods in an async context to isolate breadcrumbs/scope/spans, you can use
Sentry.runWithAsyncContext, which is documented here: https://docs.sentry.io/platforms/node/configuration/async-context/Note the above only works with Node - in Browser JavaScript environments there is no async context mechanism we can use to accomplish this (like @timfish mentioned above).
This is something we are tracking and working toward! https://github.com/getsentry/sentry-javascript/issues/3660. It’s also part of our roadmap this year: https://github.com/getsentry/sentry-javascript/discussions/5878
If each request is isolated to it’s own domain, it should get a unique hub, which means that we isolate scope/transactions/breadcrumbs correctly. We do this out of the box for our express, nextjs, and remix integrations.
If you wrap your request handler (or whatever your concurrency generation mechanism is) with a new domain, it should stay isolated.
I know domains are deprecated, but for now they work - and support all the wide variety of Sentry users we have (different node versions, deployment configs, etc.). This year we are going to look at adding async hooks (or similar) - so look forward to news there about this.
We might also take a slighty different approach though, like around what cloudflare workers did with
AsyncLocalStorage, seeing if we can generate a more performant approach by directly hooking into v8 or similar. We’ve started investigating this as part of our production profiling sdk and support for local variables in stacktraces - still need to experiment! Any feedback/thoughts about that is greatly appreciated.@AbhiPrasad But
@sentry/nodedoesn’t use AsyncLocalStorage, so it suffers the jumbling of spans problem that this issue is tracking. It’s not possible to use Sentry in Node without your spans getting randomly assigned to the latest transaction, instead of the unit of concurrency they should be. For instance, an http server processing 20 requests at the same time, where each request for example hits thepgauto-instrumentation, which calls the same global hub from all locations. Yourpgquery spans will be assigned to the wrong transactions, because obviously 20 different units of concurrency are all clobbering the same global.For anyone stumbling on this, here’s a wrapper.
Only tested on my original example code (which doesn’t cover all cases). Still, produces perfect result when refactored to use the wrapper; including when
randomThirdPartyIntegrationis refactored to use the wrapper’sfindTransactionmethod that usesasync_hooks.Usage
etc
Hi @mmmoussa, if you avoid using the global APIs (
Sentry.*calls that mutate global state), and is willing to turn off integrations that are also inheritly global (those that hook up to global state, for example, Fetch and XHR,console, etc), then it is viable to use independent clients and scope per unit of concurrency. If for your app you can clearly determine which contextual data goes with which context of execution then you might even be able to bring back features like properly scoped breadcrumbs.We’ve explored this subject in the past years and collectively learned it’s hard to have a one-size-fits-all solution.
I’m sure the team still has this topic top of their mind.
Indeed, I just took a look at the code here:
https://github.com/getsentry/sentry-javascript/blob/99f9b5580d80b167e2d833fbc0e22972ab59b016/packages/hub/src/hub.ts#L132-L148
It seems evident that this isn’t going to support async processes properly. The stack is a singleton, and popping and pushing from it might happen at any given time.
I notice some related discussion in https://github.com/getsentry/sentry-javascript/issues/3572 along with a potential ugly workaround. I’m currently also running into this issue in my own projects and will investigate further.
There’s an issue tracking progress towards fixing this.
We’re using uWebSockets.js and seem to have come across the same general problem. We have long-running sessions that run concurrently and our Sentry context and breadcrumbs tend to be useless except for whatever context we logged immediately before capturing an exception due to pollution across the different connections being handled. If we could manually setup an isolated sentry scope per request being handled we could probably work with that but that doesn’t seem to be supported.
Tested a bunch of variations. You’re right only passing any scope change though
hub.configureScopeover and over seems to work. Trying to use things likeconst scope = hub.pushScope();while in theory should work (since theconfigureScopemethod is only grabbing the top scope) they only actually produce anything when used with theSentryglobal version and produce nothing when used with the explicithubversion.Thanks @andreialecu
Users are being logged for all transactions for me and I have a relatively high traffic app, make sure you attach the user to the transaction. This seems to work for me:
Yup explicit propagation of hubs and scopes will always work - the issues arise when you try to implicitly propagate it (such as when you have automatic instrumentation of libraries). In the case of implicit propagation, the auto-instrumentation has to “pick” the correct hub/scope to call methods on, which breaks down in async environments. Using async hooks and domains is the way to work toward a solution here.
On the browser though, this is still something that needs to be figured out (unless one uses a library like https://www.npmjs.com/package/zone.js)
@AbhiPrasad I’ve been able to work around this by creating a
hubvia (ref: https://github.com/getsentry/sentry-javascript/issues/4071#issuecomment-946935926):Then I can call methods and start transactions on that
hubinstance instead of the global sentry object and things properly get isolated.Is this the right approach? I haven’t seen it documented, and the global hub is unusable in nodejs otherwise (as explained in the OP).
@srcspider it’s not always that simple, unfortunately. Depending on which libraries you use, you can still lose the context in certain instances.
Take a look at https://stackoverflow.com/questions/64330910/nodejs-asynclocalstorage-getstore-return-undefined for some other reports about context being lost just by using
bodyparserfor express.Also a report https://github.com/apollographql/apollo-server/issues/2042 which also applies to
AsyncLocalStorageabout context being lost in Apollo server.See the “Edit” I added in that post, in my case I’m using
mongoosewhich usesmongodbvia callbacks instead of promises internally. The callbacks lose the async local storage context. Without patching mongoose or mongodb this can’t easily be fixed.I have however found a workaround which I posted in https://github.com/Automattic/mongoose/issues/10020#issuecomment-946908407
Anyways, this discussion is a bit offtopic for this thread.
See my second post where I mentioned I got everything working. 🙂
@andreialecu, I just mean that your code is something that will work without modifying the current Sentry SDK, as opposed to a demonstration of how to fix the SDK. I think you just confirmed that. I should have some time to put it to the test next week. Thanks!