Skip to content

Commit 4b383d0

Browse files
authored
feat(nextjs): Add AsyncLocalStorage async context strategy to edge SDK (#8720)
1 parent ef5cb5f commit 4b383d0

File tree

5 files changed

+110
-0
lines changed

5 files changed

+110
-0
lines changed

packages/e2e-tests/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Prerequisites: Docker
1010
- Copy `.env.example` to `.env`
1111
- Fill in auth information in `.env` for an example Sentry project
1212
- The `E2E_TEST_AUTH_TOKEN` must have all the default permissions
13+
- Run `yarn build:tarball` in the root of the repository
14+
15+
To finally run all of the tests:
1316

1417
```bash
1518
yarn test:e2e
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
export const config = {
4+
runtime: 'edge',
5+
};
6+
7+
export default async function handler() {
8+
// Without `runWithAsyncContext` and a working async context strategy the two spans created by `Sentry.trace()` would be nested.
9+
10+
const outerSpanPromise = Sentry.runWithAsyncContext(() => {
11+
return Sentry.trace({ name: 'outer-span' }, () => {
12+
return new Promise<void>(resolve => setTimeout(resolve, 300));
13+
});
14+
});
15+
16+
setTimeout(() => {
17+
Sentry.runWithAsyncContext(() => {
18+
return Sentry.trace({ name: 'inner-span' }, () => {
19+
return new Promise<void>(resolve => setTimeout(resolve, 100));
20+
});
21+
});
22+
}, 100);
23+
24+
await outerSpanPromise;
25+
26+
return new Response('ok', { status: 200 });
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForTransaction, waitForError } from '../event-proxy-server';
3+
4+
test('Should allow for async context isolation in the edge SDK', async ({ request }) => {
5+
// test.skip(process.env.TEST_ENV === 'development', "Doesn't work in dev mode.");
6+
7+
const edgerouteTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
8+
return transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint';
9+
});
10+
11+
await request.get('/api/async-context-edge-endpoint');
12+
13+
const asyncContextEdgerouteTransaction = await edgerouteTransactionPromise;
14+
15+
const outerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'outer-span');
16+
const innerSpan = asyncContextEdgerouteTransaction.spans?.find(span => span.description === 'inner-span');
17+
18+
// @ts-ignore parent_span_id exists
19+
expect(outerSpan?.parent_span_id).toStrictEqual(asyncContextEdgerouteTransaction.contexts?.trace?.span_id);
20+
// @ts-ignore parent_span_id exists
21+
expect(innerSpan?.parent_span_id).toStrictEqual(asyncContextEdgerouteTransaction.contexts?.trace?.span_id);
22+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Carrier, Hub, RunWithAsyncContextOptions } from '@sentry/core';
2+
import { ensureHubOnCarrier, getHubFromCarrier, setAsyncContextStrategy } from '@sentry/core';
3+
import { GLOBAL_OBJ, logger } from '@sentry/utils';
4+
5+
interface AsyncLocalStorage<T> {
6+
getStore(): T | undefined;
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
run<R, TArgs extends any[]>(store: T, callback: (...args: TArgs) => R, ...args: TArgs): R;
9+
}
10+
11+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
12+
const MaybeGlobalAsyncLocalStorage = (GLOBAL_OBJ as any).AsyncLocalStorage;
13+
14+
/**
15+
* Sets the async context strategy to use AsyncLocalStorage which should be available in the edge runtime.
16+
*/
17+
export function setAsyncLocalStorageAsyncContextStrategy(): void {
18+
if (!MaybeGlobalAsyncLocalStorage) {
19+
__DEBUG_BUILD__ &&
20+
logger.warn(
21+
"Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.",
22+
);
23+
return;
24+
}
25+
26+
const asyncStorage: AsyncLocalStorage<Hub> = new MaybeGlobalAsyncLocalStorage();
27+
28+
function getCurrentHub(): Hub | undefined {
29+
return asyncStorage.getStore();
30+
}
31+
32+
function createNewHub(parent: Hub | undefined): Hub {
33+
const carrier: Carrier = {};
34+
ensureHubOnCarrier(carrier, parent);
35+
return getHubFromCarrier(carrier);
36+
}
37+
38+
function runWithAsyncContext<T>(callback: () => T, options: RunWithAsyncContextOptions): T {
39+
const existingHub = getCurrentHub();
40+
41+
if (existingHub && options?.reuseExisting) {
42+
// We're already in an async context, so we don't need to create a new one
43+
// just call the callback with the current hub
44+
return callback();
45+
}
46+
47+
const newHub = createNewHub(existingHub);
48+
49+
return asyncStorage.run(newHub, () => {
50+
return callback();
51+
});
52+
}
53+
54+
setAsyncContextStrategy({ getCurrentHub, runWithAsyncContext });
55+
}

packages/nextjs/src/edge/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@sentry/utils';
1010

1111
import { getVercelEnv } from '../common/getVercelEnv';
12+
import { setAsyncLocalStorageAsyncContextStrategy } from './asyncLocalStorageAsyncContextStrategy';
1213
import { EdgeClient } from './edgeclient';
1314
import { makeEdgeTransport } from './transport';
1415

@@ -20,6 +21,8 @@ export type EdgeOptions = Options;
2021

2122
/** Inits the Sentry NextJS SDK on the Edge Runtime. */
2223
export function init(options: EdgeOptions = {}): void {
24+
setAsyncLocalStorageAsyncContextStrategy();
25+
2326
if (options.defaultIntegrations === undefined) {
2427
options.defaultIntegrations = defaultIntegrations;
2528
}

0 commit comments

Comments
 (0)