Skip to content

Instantly share code, notes, and snippets.

@jasonk
Created November 12, 2021 20:08
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonk/a06153476ae7fad41c527e321e318088 to your computer and use it in GitHub Desktop.
Save jasonk/a06153476ae7fad41c527e321e318088 to your computer and use it in GitHub Desktop.
Sentry NodeJS with AsyncLocalStorage (from async_hooks).

Sentry is awesome, but their NodeJS Platform is slightly less great. It's basically entirely synchronous, so if you have a lot of async operations going on things like breadcrumbs and other context information will all get mixed up together.

I put this gist together to share with other people how I worked around this problem in our code base. It's not a perfect solution, but it works pretty well.

How it works

The way this works is that Sentry has a global store (global.__SENTRY__) that includes a hub property that stores the current hub. The hub has a stack of scopes that are the things you interact with when using things like Sentry.withScope and Sentry.configureScope. What I'm doing here is replacing that hub property with a getter that return an async context local hub instead of a global one. It does this by using the Node native AsyncLocalStorage module, which creates stores that stay coherent through asynchronous operations.

Using the withSentryHub function creates a brand new hub instance and then runs the wrapped code with that new hub provided as the "global" hub. Since we patched Sentry's global store to return the appropriate hub instance for each thread, any code that gets run in the "tree" of code spawned by the withSentryHub call will get that specific hub instance returned, without affecting any other hubs that any other async contexts might be using.

What this means is that if use withSentryHub to wrap a "transaction" function (such as the express middleware included at the bottom) then any exceptions captured within the scope of that request should only have breadcrumbs and spans related to that actual request. As bonus, this also makes it easy to have different hubs connected to different clients if you need that sort of thing.

Some related GitHub issues

import { AsyncLocalStorage } from 'async_hooks';
import { getCurrentHub as _getCurrentHub, Hub } from '@sentry/hub';
import { getGlobalObject } from '@sentry/utils';
import { ScopeContext, Span } from '@sentry/types';
import { NodeClient } from '@sentry/node';
// Create an AsyncLocalStorage instance to hold our hubs.
const hub_als = new AsyncLocalStorage<Hub>();
// Make sure we have a reference to the "main" hub and it's client
const root_hub = _getCurrentHub();
const root_client = root_hub.getClient();
// Find or create the global.__SENTRY__ object the same way Sentry
// does.
const global = getGlobalObject();
( global as any ).__SENTRY__ = global.__SENTRY__ || {};
// We replace the `hub` property on the global __SENTRY__ object with
// a getter that returns the current hub from the AsyncLocalStorage
// store.
Object.defineProperty( global.__SENTRY__, 'hub', {
enumerable : true,
configurable : false,
get : getCurrentHub,
} );
/**
* Options that you can pass to the makeHub function to preconfigure
* the returned hub. By providing a Sentry NodeClient instance you
* can create hubs that are associated with different clients.
*/
export interface MakeHubOptions extends Partial<ScopeContext> {
client?: NodeClient;
transaction?: string;
span?: Span;
}
/**
* Make a new Sentry hub and attach it to our root_client unless an
* alternate client was provided.
*/
export function makeHub( options: MakeHubOptions = {} ): Hub {
const { client = root_client, transaction, span, ...opts } = options;
const hub = new Hub( client );
hub.configureScope( scope => {
if ( transaction ) scope.setTransactionName( transaction );
if ( span ) scope.setSpan( span );
scope.update( opts );
} );
return hub;
}
/**
* Call a function with a newly created Sentry hub. You can also
* optionally pass an options object to this function to pre-configure
* the root scope of the newly created hub.
*/
export function withSentryHub<T=unknown>(
callback: () => T,
options?: MakeHubOptions,
): T {
const hub = makeHub( options );
return hub_als.run( hub, callback );
}
/**
* This is just exported here for convenience, the normal
* `getCurrentHub` function from Sentry will also return the same hub
* thanks to the __SENTRY__ hacking up above.
*/
export function getCurrentHub(): Hub {
return hub_als.getStore() || root_hub;
}
/**
* This is an express-style middleware that wraps requests in their
* own Sentry hub.
* This should ideally be the first middleware in your stack.
*
* @example
* const app = express();
* app.use( sentryHubMiddleware( opts ) );
* app.use( bodyParser.json() );
*/
export function sentryHubMiddleware( opts: MakeHubOptions ) {
return function sentry_hub_middleware( _req, _res, next ) {
withSentryHub( next, opts );
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment