Skip to content

Commit b2bdf1a

Browse files
authored
feat(profiling) add global profile context while profiler is running (#12394)
Attach profile context to events while a profiler is active and assign thread id and name to the trace context. This will be required by the profiling API so that we can figure out the links between spans and profiles. In the future (span streaming), tid and thread name will be assigned to each span
1 parent e09b93a commit b2bdf1a

File tree

5 files changed

+190
-14
lines changed

5 files changed

+190
-14
lines changed

packages/profiling-node/src/integration.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { defineIntegration, getCurrentScope, getIsolationScope, getRootSpan, spanToJSON } from '@sentry/core';
22
import type { NodeClient } from '@sentry/node';
3-
import type { Integration, IntegrationFn, Profile, ProfileChunk, Span } from '@sentry/types';
3+
import type { Event, Integration, IntegrationFn, Profile, ProfileChunk, Span } from '@sentry/types';
44

55
import { LRUMap, logger, timestampInSeconds, uuid4 } from '@sentry/utils';
66

7+
import { getGlobalScope } from '../../core/src/currentScopes';
78
import { CpuProfilerBindings } from './cpu_profiler';
89
import { DEBUG_BUILD } from './debug-build';
910
import { NODE_MAJOR, NODE_VERSION } from './nodeVersion';
1011
import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils';
11-
import type { RawThreadCpuProfile } from './types';
12+
import type { RawChunkCpuProfile, RawThreadCpuProfile } from './types';
1213
import { ProfileFormat } from './types';
14+
import { PROFILER_THREAD_NAME } from './utils';
1315

1416
import {
17+
PROFILER_THREAD_ID_STRING,
1518
addProfilesToEnvelope,
1619
createProfilingChunkEvent,
1720
createProfilingEvent,
@@ -211,7 +214,9 @@ class ContinuousProfiler {
211214
logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`);
212215
return;
213216
}
214-
const profile = CpuProfilerBindings.stopProfiling(this._chunkData.id, ProfileFormat.CHUNK);
217+
218+
const profile = this._stopChunkProfiling(this._chunkData);
219+
215220
if (!profile || !this._chunkData.startTimestampMS) {
216221
DEBUG_BUILD && logger.log(`[Profiling] _chunkiledStartTraceID to collect profile for: ${this._chunkData.id}`);
217222
return;
@@ -274,12 +279,22 @@ class ContinuousProfiler {
274279
});
275280
}
276281

282+
/**
283+
* Stops the profile and clears chunk instrumentation from global scope
284+
* @returns void
285+
*/
286+
private _stopChunkProfiling(chunk: ChunkData): RawChunkCpuProfile | null {
287+
this._teardownSpanChunkInstrumentation();
288+
return CpuProfilerBindings.stopProfiling(chunk.id, ProfileFormat.CHUNK);
289+
}
290+
277291
/**
278292
* Starts the profiler and registers the flush timer for a given chunk.
279293
* @param chunk
280294
*/
281295
private _startChunkProfiling(chunk: ChunkData): void {
282-
CpuProfilerBindings.startProfiling(chunk.id!);
296+
this._setupSpanChunkInstrumentation();
297+
CpuProfilerBindings.startProfiling(chunk.id);
283298
DEBUG_BUILD && logger.log(`[Profiling] starting profiling chunk: ${chunk.id}`);
284299

285300
chunk.timer = global.setTimeout(() => {
@@ -293,6 +308,32 @@ class ContinuousProfiler {
293308
chunk.timer.unref();
294309
}
295310

311+
/**
312+
* Attaches profiling information to spans that were started
313+
* during a profiling session.
314+
*/
315+
private _setupSpanChunkInstrumentation(): void {
316+
if (!this._client) {
317+
DEBUG_BUILD &&
318+
logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.');
319+
return;
320+
}
321+
322+
getGlobalScope().setContext('profile', {
323+
profiler_id: this._profilerId,
324+
});
325+
326+
this._client.on('beforeSendEvent', e => this._assignThreadIdContext(e));
327+
}
328+
329+
/**
330+
* Clear profiling information from global context when a profile is not running.
331+
*/
332+
private _teardownSpanChunkInstrumentation(): void {
333+
const globalScope = getGlobalScope();
334+
globalScope.setContext('profile', {});
335+
}
336+
296337
/**
297338
* Initializes new profile chunk metadata
298339
*/
@@ -305,6 +346,30 @@ class ContinuousProfiler {
305346
};
306347
}
307348

349+
/**
350+
* Assigns thread_id and thread name context to a profiled event.
351+
*/
352+
private _assignThreadIdContext(event: Event): any {
353+
if (!event?.['contexts']?.['profile']) {
354+
return;
355+
}
356+
357+
if (!event.contexts) {
358+
return;
359+
}
360+
361+
// @ts-expect-error the trace fallback value is wrong, though it should never happen
362+
// and in case it does, we dont want to override whatever was passed initially.
363+
event.contexts['trace'] = {
364+
...(event.contexts?.['trace'] ?? {}),
365+
data: {
366+
...(event.contexts?.['trace']?.['data'] ?? {}),
367+
['thread.id']: PROFILER_THREAD_ID_STRING,
368+
['thread.name']: PROFILER_THREAD_NAME,
369+
},
370+
};
371+
}
372+
308373
/**
309374
* Resets the current chunk state.
310375
*/

packages/profiling-node/src/spanProfileUtils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ export function stopSpanProfile(span: Span, profile_id: string | undefined): Raw
115115
}
116116

117117
const profile = CpuProfilerBindings.stopProfiling(profile_id, 0);
118-
119118
DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`);
120119

121120
// In case of an overlapping span, stopProfiling may return null and silently ignore the overlapping profile.

packages/profiling-node/src/utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ import type { RawChunkCpuProfile, RawThreadCpuProfile } from './types';
2828

2929
// We require the file because if we import it, it will be included in the bundle.
3030
// I guess tsc does not check file contents when it's imported.
31-
const THREAD_ID_STRING = String(threadId);
32-
const THREAD_NAME = isMainThread ? 'main' : 'worker';
31+
export const PROFILER_THREAD_ID_STRING = String(threadId);
32+
export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker';
3333
const FORMAT_VERSION = '1';
3434
const CONTINUOUS_FORMAT_VERSION = '2';
3535

@@ -75,8 +75,8 @@ export function enrichWithThreadInformation(
7575
frames: profile.frames,
7676
stacks: profile.stacks,
7777
thread_metadata: {
78-
[THREAD_ID_STRING]: {
79-
name: THREAD_NAME,
78+
[PROFILER_THREAD_ID_STRING]: {
79+
name: PROFILER_THREAD_NAME,
8080
},
8181
},
8282
} as ThreadCpuProfile | ContinuousThreadCpuProfile;
@@ -172,7 +172,7 @@ function createProfilePayload(
172172
name: transaction,
173173
id: event_id,
174174
trace_id: trace_id || '',
175-
active_thread_id: THREAD_ID_STRING,
175+
active_thread_id: PROFILER_THREAD_ID_STRING,
176176
},
177177
};
178178

packages/profiling-node/test/spanProfileUtils.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils';
77
import { CpuProfilerBindings } from '../src/cpu_profiler';
88
import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration';
99

10-
jest.setTimeout(10000);
11-
1210
function makeClientWithHooks(): [Sentry.NodeClient, Transport] {
1311
const integration = _nodeProfilingIntegration();
1412
const client = new Sentry.NodeClient({
@@ -322,7 +320,6 @@ describe('automated span instrumentation', () => {
322320
transaction.end();
323321
expect(stopProfilingSpy).toHaveBeenCalledTimes(1);
324322
});
325-
326323
it('enriches profile with debug_id', async () => {
327324
GLOBAL_OBJ._sentryDebugIds = {
328325
'Error\n at filename.js (filename.js:36:15)': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa',
@@ -574,6 +571,47 @@ describe('continuous profiling', () => {
574571

575572
expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0].type).toBe('profile_chunk');
576573
});
574+
575+
it('sets global profile context', async () => {
576+
const [client, transport] = makeContinuousProfilingClient();
577+
Sentry.setCurrentClient(client);
578+
client.init();
579+
580+
const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({}));
581+
582+
const nonProfiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
583+
nonProfiledTransaction.end();
584+
585+
expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).not.toMatchObject({
586+
contexts: {
587+
profile: {},
588+
},
589+
});
590+
591+
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
592+
if (!integration) {
593+
throw new Error('Profiling integration not found');
594+
}
595+
596+
integration._profiler.start();
597+
const profiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
598+
profiledTransaction.end();
599+
integration._profiler.stop();
600+
601+
expect(transportSpy.mock.calls?.[1]?.[0]?.[1]?.[0]?.[1]).toMatchObject({
602+
contexts: {
603+
trace: {
604+
data: expect.objectContaining({
605+
['thread.id']: expect.any(String),
606+
['thread.name']: expect.any(String),
607+
}),
608+
},
609+
profile: {
610+
profiler_id: expect.any(String),
611+
},
612+
},
613+
});
614+
});
577615
});
578616

579617
describe('span profiling mode', () => {
@@ -610,7 +648,6 @@ describe('span profiling mode', () => {
610648
Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
611649

612650
expect(startProfilingSpy).toHaveBeenCalled();
613-
614651
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
615652

616653
if (!integration) {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Mock the modules before the import, so that the value is initialized before the module is loaded
2+
jest.mock('worker_threads', () => {
3+
return {
4+
isMainThread: false,
5+
threadId: 9999,
6+
};
7+
});
8+
jest.setTimeout(10000);
9+
10+
import * as Sentry from '@sentry/node';
11+
import type { Transport } from '@sentry/types';
12+
import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration';
13+
14+
function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] {
15+
const integration = _nodeProfilingIntegration();
16+
const client = new Sentry.NodeClient({
17+
stackParser: Sentry.defaultStackParser,
18+
tracesSampleRate: 1,
19+
profilesSampleRate: undefined,
20+
debug: true,
21+
environment: 'test-environment',
22+
dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302',
23+
integrations: [integration],
24+
transport: _opts =>
25+
Sentry.makeNodeTransport({
26+
url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302',
27+
recordDroppedEvent: () => {
28+
return undefined;
29+
},
30+
}),
31+
});
32+
33+
return [client, client.getTransport() as Transport];
34+
}
35+
36+
it('worker threads context', () => {
37+
const [client, transport] = makeContinuousProfilingClient();
38+
Sentry.setCurrentClient(client);
39+
client.init();
40+
41+
const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({}));
42+
43+
const nonProfiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
44+
nonProfiledTransaction.end();
45+
46+
expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).not.toMatchObject({
47+
contexts: {
48+
profile: {},
49+
},
50+
});
51+
52+
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
53+
if (!integration) {
54+
throw new Error('Profiling integration not found');
55+
}
56+
57+
integration._profiler.start();
58+
const profiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
59+
profiledTransaction.end();
60+
integration._profiler.stop();
61+
62+
expect(transportSpy.mock.calls?.[1]?.[0]?.[1]?.[0]?.[1]).toMatchObject({
63+
contexts: {
64+
trace: {
65+
data: expect.objectContaining({
66+
['thread.id']: '9999',
67+
['thread.name']: 'worker',
68+
}),
69+
},
70+
profile: {
71+
profiler_id: expect.any(String),
72+
},
73+
},
74+
});
75+
});

0 commit comments

Comments
 (0)