Skip to content

Commit 7038775

Browse files
authored
fix: setTraceAttributes cross trace attributes leakage issue (#200)
Ref: HDX-2742
1 parent 96fe060 commit 7038775

File tree

3 files changed

+158
-2
lines changed

3 files changed

+158
-2
lines changed

.changeset/funny-bags-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/node-opentelemetry': patch
3+
---
4+
5+
fix: setTraceAttributes cross trace attributes leakage issue

packages/node-opentelemetry/src/MutableAsyncLocalStorageContextManager.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ export class MutableAsyncLocalStorageContextManager extends AbstractAsyncHooksCo
4444
thisArg?: ThisParameterType<F>,
4545
...args: A
4646
): ReturnType<F> {
47-
const mutableContext = this._asyncLocalStorage.getStore()
48-
?.mutableContext ?? {
47+
// Create a fresh mutableContext for each new context to prevent
48+
// cross-contamination between concurrent requests
49+
const mutableContext = {
4950
traceAttributes: new Map(),
5051
};
5152

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { ROOT_CONTEXT } from '@opentelemetry/api';
2+
3+
import { MutableAsyncLocalStorageContextManager } from '../MutableAsyncLocalStorageContextManager';
4+
5+
describe('MutableAsyncLocalStorageContextManager - trace attributes isolation', () => {
6+
let contextManager: MutableAsyncLocalStorageContextManager;
7+
8+
beforeEach(() => {
9+
contextManager = new MutableAsyncLocalStorageContextManager();
10+
});
11+
12+
it('should not leak trace attributes between concurrent contexts', async () => {
13+
const results: Array<{ userId: string; requestId: string }> = [];
14+
15+
// Simulate two concurrent requests with different trace attributes
16+
await Promise.all([
17+
new Promise<void>((resolve) => {
18+
contextManager.with(ROOT_CONTEXT, () => {
19+
const ctx = contextManager.getMutableContext();
20+
ctx?.traceAttributes.set('userId', 'user-1');
21+
ctx?.traceAttributes.set('requestId', 'req-1');
22+
23+
// Simulate async work
24+
setTimeout(() => {
25+
const finalCtx = contextManager.getMutableContext();
26+
results.push({
27+
userId: finalCtx?.traceAttributes.get('userId'),
28+
requestId: finalCtx?.traceAttributes.get('requestId'),
29+
});
30+
resolve();
31+
}, 10);
32+
});
33+
}),
34+
35+
new Promise<void>((resolve) => {
36+
contextManager.with(ROOT_CONTEXT, () => {
37+
const ctx = contextManager.getMutableContext();
38+
ctx?.traceAttributes.set('userId', 'user-2');
39+
ctx?.traceAttributes.set('requestId', 'req-2');
40+
41+
// Simulate async work
42+
setTimeout(() => {
43+
const finalCtx = contextManager.getMutableContext();
44+
results.push({
45+
userId: finalCtx?.traceAttributes.get('userId'),
46+
requestId: finalCtx?.traceAttributes.get('requestId'),
47+
});
48+
resolve();
49+
}, 10);
50+
});
51+
}),
52+
]);
53+
54+
// Each context should have its own attributes without cross-contamination
55+
expect(results).toHaveLength(2);
56+
expect(results).toEqual(
57+
expect.arrayContaining([
58+
{ userId: 'user-1', requestId: 'req-1' },
59+
{ userId: 'user-2', requestId: 'req-2' },
60+
]),
61+
);
62+
});
63+
64+
it('should create fresh trace attributes for each new context', () => {
65+
// First context sets some attributes
66+
contextManager.with(ROOT_CONTEXT, () => {
67+
const ctx1 = contextManager.getMutableContext();
68+
ctx1?.traceAttributes.set('userId', 'user-1');
69+
ctx1?.traceAttributes.set('sharedKey', 'parent-value');
70+
71+
expect(ctx1?.traceAttributes.get('userId')).toBe('user-1');
72+
expect(ctx1?.traceAttributes.get('sharedKey')).toBe('parent-value');
73+
74+
// Nested context should have its own fresh trace attributes
75+
contextManager.with(ROOT_CONTEXT, () => {
76+
const ctx2 = contextManager.getMutableContext();
77+
78+
// Should NOT have parent's attributes (fresh Map)
79+
expect(ctx2?.traceAttributes.get('userId')).toBeUndefined();
80+
expect(ctx2?.traceAttributes.get('sharedKey')).toBeUndefined();
81+
82+
// Set new attributes in child context
83+
ctx2?.traceAttributes.set('userId', 'user-2');
84+
ctx2?.traceAttributes.set('sharedKey', 'child-value');
85+
86+
expect(ctx2?.traceAttributes.get('userId')).toBe('user-2');
87+
expect(ctx2?.traceAttributes.get('sharedKey')).toBe('child-value');
88+
});
89+
90+
// After exiting child context, parent should still have original attributes
91+
const ctx1Again = contextManager.getMutableContext();
92+
expect(ctx1Again?.traceAttributes.get('userId')).toBe('user-1');
93+
expect(ctx1Again?.traceAttributes.get('sharedKey')).toBe('parent-value');
94+
});
95+
});
96+
97+
it('should handle rapid sequential context creation without contamination', async () => {
98+
const results: string[] = [];
99+
100+
// Simulate rapid sequential requests
101+
for (let i = 0; i < 5; i++) {
102+
await new Promise<void>((resolve) => {
103+
contextManager.with(ROOT_CONTEXT, () => {
104+
const ctx = contextManager.getMutableContext();
105+
const userId = `user-${i}`;
106+
ctx?.traceAttributes.set('userId', userId);
107+
108+
// Verify the correct userId is set in this context
109+
const actualUserId = ctx?.traceAttributes.get('userId');
110+
results.push(actualUserId as string);
111+
112+
resolve();
113+
});
114+
});
115+
}
116+
117+
// Each request should have had its own userId
118+
expect(results).toEqual(['user-0', 'user-1', 'user-2', 'user-3', 'user-4']);
119+
});
120+
121+
it('should maintain separate mutable contexts across parallel operations', async () => {
122+
const contextSnapshots: Array<Map<string, any>> = [];
123+
124+
await Promise.all(
125+
Array.from(
126+
{ length: 10 },
127+
(_, i) =>
128+
new Promise<void>((resolve) => {
129+
contextManager.with(ROOT_CONTEXT, () => {
130+
const ctx = contextManager.getMutableContext();
131+
ctx?.traceAttributes.set('index', i);
132+
133+
setTimeout(() => {
134+
const finalCtx = contextManager.getMutableContext();
135+
// Clone the Map to preserve its state
136+
contextSnapshots.push(
137+
new Map(finalCtx?.traceAttributes || new Map()),
138+
);
139+
resolve();
140+
}, Math.random() * 10);
141+
});
142+
}),
143+
),
144+
);
145+
146+
// Each context should have its own unique index
147+
const indices = contextSnapshots.map((map) => map.get('index'));
148+
expect(indices.sort()).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
149+
});
150+
});

0 commit comments

Comments
 (0)