Skip to content

Commit 53ae9ae

Browse files
authored
feat(node): Add AsyncLocalStorage implementation of AsyncContextStrategy (#7800)
1 parent 27db660 commit 53ae9ae

File tree

2 files changed

+156
-0
lines changed

2 files changed

+156
-0
lines changed

packages/node/src/async/hooks.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Carrier, Hub, RunWithAsyncContextOptions } from '@sentry/core';
2+
import { ensureHubOnCarrier, getHubFromCarrier, setAsyncContextStrategy } from '@sentry/core';
3+
import * as async_hooks from 'async_hooks';
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+
type AsyncLocalStorageConstructor = { new <T>(): AsyncLocalStorage<T> };
12+
// AsyncLocalStorage only exists in async_hook after Node v12.17.0 or v13.10.0
13+
type NewerAsyncHooks = typeof async_hooks & { AsyncLocalStorage: AsyncLocalStorageConstructor };
14+
15+
/**
16+
* Sets the async context strategy to use AsyncLocalStorage which requires Node v12.17.0 or v13.10.0.
17+
*/
18+
export function setHooksAsyncContextStrategy(): void {
19+
const asyncStorage = new (async_hooks as NewerAsyncHooks).AsyncLocalStorage<Hub>();
20+
21+
function getCurrentHub(): Hub | undefined {
22+
return asyncStorage.getStore();
23+
}
24+
25+
function createNewHub(parent: Hub | undefined): Hub {
26+
const carrier: Carrier = {};
27+
ensureHubOnCarrier(carrier, parent);
28+
return getHubFromCarrier(carrier);
29+
}
30+
31+
function runWithAsyncContext<T>(callback: (hub: Hub) => T, options: RunWithAsyncContextOptions): T {
32+
const existingHub = getCurrentHub();
33+
34+
if (existingHub && options?.reuseExisting) {
35+
// We're already in an async context, so we don't need to create a new one
36+
// just call the callback with the current hub
37+
return callback(existingHub);
38+
}
39+
40+
const newHub = createNewHub(existingHub);
41+
42+
return asyncStorage.run(newHub, () => {
43+
return callback(newHub);
44+
});
45+
}
46+
47+
setAsyncContextStrategy({ getCurrentHub, runWithAsyncContext });
48+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { getCurrentHub, Hub, runWithAsyncContext, setAsyncContextStrategy } from '@sentry/core';
2+
3+
import { setHooksAsyncContextStrategy } from '../../src/async/hooks';
4+
import { conditionalTest } from '../utils';
5+
6+
conditionalTest({ min: 12 })('async_hooks', () => {
7+
afterAll(() => {
8+
// clear the strategy
9+
setAsyncContextStrategy(undefined);
10+
});
11+
12+
test('without context', () => {
13+
const hub = getCurrentHub();
14+
expect(hub).toEqual(new Hub());
15+
});
16+
17+
test('without strategy hubs should be equal', () => {
18+
runWithAsyncContext(hub1 => {
19+
runWithAsyncContext(hub2 => {
20+
expect(hub1).toBe(hub2);
21+
});
22+
});
23+
});
24+
25+
test('hub scope inheritance', () => {
26+
setHooksAsyncContextStrategy();
27+
28+
const globalHub = getCurrentHub();
29+
globalHub.setExtra('a', 'b');
30+
31+
runWithAsyncContext(hub1 => {
32+
expect(hub1).toEqual(globalHub);
33+
34+
hub1.setExtra('c', 'd');
35+
expect(hub1).not.toEqual(globalHub);
36+
37+
runWithAsyncContext(hub2 => {
38+
expect(hub2).toEqual(hub1);
39+
expect(hub2).not.toEqual(globalHub);
40+
41+
hub2.setExtra('e', 'f');
42+
expect(hub2).not.toEqual(hub1);
43+
});
44+
});
45+
});
46+
47+
test('context single instance', () => {
48+
setHooksAsyncContextStrategy();
49+
50+
runWithAsyncContext(hub => {
51+
expect(hub).toBe(getCurrentHub());
52+
});
53+
});
54+
55+
test('context within a context not reused', () => {
56+
setHooksAsyncContextStrategy();
57+
58+
runWithAsyncContext(hub1 => {
59+
runWithAsyncContext(hub2 => {
60+
expect(hub1).not.toBe(hub2);
61+
});
62+
});
63+
});
64+
65+
test('context within a context reused when requested', () => {
66+
setHooksAsyncContextStrategy();
67+
68+
runWithAsyncContext(hub1 => {
69+
runWithAsyncContext(
70+
hub2 => {
71+
expect(hub1).toBe(hub2);
72+
},
73+
{ reuseExisting: true },
74+
);
75+
});
76+
});
77+
78+
test('concurrent hub contexts', done => {
79+
setHooksAsyncContextStrategy();
80+
81+
let d1done = false;
82+
let d2done = false;
83+
84+
runWithAsyncContext(hub => {
85+
hub.getStack().push({ client: 'process' } as any);
86+
expect(hub.getStack()[1]).toEqual({ client: 'process' });
87+
// Just in case so we don't have to worry which one finishes first
88+
// (although it always should be d2)
89+
setTimeout(() => {
90+
d1done = true;
91+
if (d2done) {
92+
done();
93+
}
94+
});
95+
});
96+
97+
runWithAsyncContext(hub => {
98+
hub.getStack().push({ client: 'local' } as any);
99+
expect(hub.getStack()[1]).toEqual({ client: 'local' });
100+
setTimeout(() => {
101+
d2done = true;
102+
if (d1done) {
103+
done();
104+
}
105+
});
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)