Skip to content

Commit 68847c3

Browse files
committed
remove profile_id between cycles
1 parent 09bfd6c commit 68847c3

File tree

4 files changed

+269
-1
lines changed

4 files changed

+269
-1
lines changed

packages/browser/src/profiling/UIProfiler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@ export class UIProfiler implements ContinuousProfiler<Client> {
188188
this._collectCurrentChunk().catch(e => {
189189
DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e);
190190
});
191+
192+
// Manual: Clear profiling context so spans outside start()/stop() aren't marked as profiled
193+
// Trace: Profile context is kept as long as there is an active root span
194+
if (this._lifecycleMode === 'manual') {
195+
getGlobalScope().setContext('profile', {});
196+
}
191197
}
192198

193199
/** Trace-mode: attach spanStart/spanEnd listeners. */

packages/browser/src/profiling/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function stopProfiler(): void {
6666
}
6767

6868
/**
69-
* Profiler namespace for controlling the profiler in 'manual' mode.
69+
* Profiler namespace for controlling the JS profiler in 'manual' mode.
7070
*
7171
* Requires the `browserProfilingIntegration` from the `@sentry/browser` package.
7272
*/
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import type { Client, ProfileChunk } from '@sentry/core';
2+
import {
3+
type ProfileChunkEnvelope,
4+
createEnvelope,
5+
debug,
6+
dsnToString,
7+
getGlobalScope,
8+
getSdkMetadataForEnvelopeHeader,
9+
uuid4,
10+
} from '@sentry/core';
11+
import { DEBUG_BUILD } from '../../debug-build';
12+
import type { JSSelfProfiler } from '../jsSelfProfiling';
13+
import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils';
14+
15+
const CHUNK_INTERVAL_MS = 60_000; // 1 minute
16+
17+
/**
18+
* Browser manual-lifecycle profiler (UI Profiling / Profiling V2):
19+
* - Controlled via explicit start()/stop() calls
20+
* - While running, periodically stops and restarts the JS self-profiling API to collect chunks
21+
* - Emits standalone `profile_chunk` envelopes on each chunk collection and on stop()
22+
*/
23+
export class BrowserManualLifecycleProfiler {
24+
private _client: Client | undefined;
25+
private _profiler: JSSelfProfiler | undefined;
26+
private _chunkTimer: ReturnType<typeof setTimeout> | undefined;
27+
private _profilerId: string | undefined;
28+
private _isRunning: boolean;
29+
private _sessionSampled: boolean;
30+
private _lifecycleMode: 'manual' | 'trace' | undefined;
31+
32+
public constructor() {
33+
this._client = undefined;
34+
this._profiler = undefined;
35+
this._chunkTimer = undefined;
36+
this._profilerId = undefined;
37+
this._isRunning = false;
38+
this._sessionSampled = false;
39+
this._lifecycleMode = undefined;
40+
}
41+
42+
/** Initialize the profiler with client, session sampling and (optionally) lifecycle mode for no-op warnings. */
43+
public initialize(client: Client, sessionSampled: boolean, lifecycleMode?: 'manual' | 'trace'): void {
44+
this._client = client;
45+
this._sessionSampled = sessionSampled;
46+
this._lifecycleMode = lifecycleMode;
47+
// One Profiler ID per profiling session (user session)
48+
this._profilerId = uuid4();
49+
50+
DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='manual').");
51+
}
52+
53+
/** Start profiling if not already running. No-ops (with debug logs) when not sampled or in 'trace' mode. */
54+
public start(): void {
55+
if (this._isRunning) {
56+
DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.');
57+
return;
58+
}
59+
60+
if (!this._sessionSampled) {
61+
DEBUG_BUILD && debug.log('[Profiling] Session not sampled, start() is a no-op.');
62+
return;
63+
}
64+
65+
if (this._lifecycleMode === 'trace') {
66+
DEBUG_BUILD &&
67+
debug.log(
68+
'[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.',
69+
);
70+
return;
71+
}
72+
73+
this._isRunning = true;
74+
// Match emitted chunks with events
75+
getGlobalScope().setContext('profile', { profiler_id: this._profilerId });
76+
77+
this._startProfilerInstance();
78+
79+
if (!this._profiler) {
80+
DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in manual lifecycle. Stopping.');
81+
this._resetProfilerInfo();
82+
return;
83+
}
84+
85+
this._startPeriodicChunking();
86+
}
87+
88+
/** Stop profiling; final chunk will be collected and sent. */
89+
public stop(): void {
90+
if (!this._isRunning) {
91+
DEBUG_BUILD && debug.log('[Profiling] No profile session running, stop() is a no-op.');
92+
return;
93+
}
94+
95+
if (this._lifecycleMode === 'trace') {
96+
DEBUG_BUILD &&
97+
debug.log(
98+
'[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.',
99+
);
100+
return;
101+
}
102+
103+
this._isRunning = false;
104+
if (this._chunkTimer) {
105+
clearTimeout(this._chunkTimer);
106+
this._chunkTimer = undefined;
107+
}
108+
109+
// Collect whatever was currently recording
110+
this._collectCurrentChunk().catch(e => {
111+
DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e);
112+
});
113+
114+
// Clear profiling context so subsequent events aren't marked as profiled
115+
getGlobalScope().setContext('profile', {});
116+
}
117+
118+
/** Resets profiling info and running state. */
119+
private _resetProfilerInfo(): void {
120+
this._isRunning = false;
121+
getGlobalScope().setContext('profile', {});
122+
}
123+
124+
/** Start a profiler instance if needed. */
125+
private _startProfilerInstance(): void {
126+
if (this._profiler?.stopped === false) {
127+
return;
128+
}
129+
const profiler = startJSSelfProfile();
130+
if (!profiler) {
131+
DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler.');
132+
return;
133+
}
134+
this._profiler = profiler;
135+
}
136+
137+
/** Schedule periodic chunking while running. */
138+
private _startPeriodicChunking(): void {
139+
if (!this._isRunning) {
140+
return;
141+
}
142+
143+
this._chunkTimer = setTimeout(() => {
144+
this._collectCurrentChunk().catch(e => {
145+
DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e);
146+
});
147+
148+
if (this._isRunning) {
149+
this._startProfilerInstance();
150+
151+
if (!this._profiler) {
152+
// If restart failed, stop scheduling further chunks and reset context.
153+
this._resetProfilerInfo();
154+
return;
155+
}
156+
157+
this._startPeriodicChunking();
158+
}
159+
}, CHUNK_INTERVAL_MS);
160+
}
161+
162+
/** Stop the current profiler, convert and send a profile chunk. */
163+
private async _collectCurrentChunk(): Promise<void> {
164+
const prevProfiler = this._profiler;
165+
this._profiler = undefined;
166+
167+
if (!prevProfiler) {
168+
return;
169+
}
170+
171+
try {
172+
const profile = await prevProfiler.stop();
173+
174+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
175+
const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId);
176+
177+
const validationReturn = validateProfileChunk(chunk);
178+
if ('reason' in validationReturn) {
179+
DEBUG_BUILD &&
180+
debug.log(
181+
'[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):',
182+
validationReturn.reason,
183+
);
184+
return;
185+
}
186+
187+
this._sendProfileChunk(chunk);
188+
189+
DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.');
190+
} catch (e) {
191+
DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e);
192+
}
193+
}
194+
195+
/** Send a profile chunk as a standalone envelope. */
196+
private _sendProfileChunk(chunk: ProfileChunk): void {
197+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
198+
const client = this._client!;
199+
200+
const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.());
201+
const dsn = client.getDsn();
202+
const tunnel = client.getOptions().tunnel;
203+
204+
const envelope = createEnvelope<ProfileChunkEnvelope>(
205+
{
206+
event_id: uuid4(),
207+
sent_at: new Date().toISOString(),
208+
...(sdkInfo && { sdk: sdkInfo }),
209+
...(!!tunnel && dsn && { dsn: dsnToString(dsn) }),
210+
},
211+
[[{ type: 'profile_chunk' }, chunk]],
212+
);
213+
214+
client.sendEnvelope(envelope).then(null, reason => {
215+
DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason);
216+
});
217+
}
218+
}

packages/browser/test/profiling/UIProfiler.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,5 +804,49 @@ describe('Browser Profiling v2 manual lifecycle', () => {
804804
}),
805805
});
806806
});
807+
808+
it('reuses the same profiler_id while profiling across multiple stop/start calls', async () => {
809+
mockProfiler();
810+
const send = vi.fn().mockResolvedValue(undefined);
811+
812+
Sentry.init({
813+
...getBaseOptionsForManualLifecycle(send),
814+
});
815+
816+
// 1. profiling cycle
817+
Sentry.uiProfiler.startProfiler();
818+
Sentry.startSpan({ name: 'manual-span-1', parentSpan: null, forceTransaction: true }, () => {});
819+
Sentry.uiProfiler.stopProfiler();
820+
await Promise.resolve();
821+
822+
// Not profiled -> should not have profile context
823+
Sentry.startSpan({ name: 'manual-span-between', parentSpan: null, forceTransaction: true }, () => {});
824+
825+
// 2. profiling cycle
826+
Sentry.uiProfiler.startProfiler();
827+
Sentry.startSpan({ name: 'manual-span-2', parentSpan: null, forceTransaction: true }, () => {});
828+
Sentry.uiProfiler.stopProfiler();
829+
await Promise.resolve();
830+
831+
const client = Sentry.getClient();
832+
await client?.flush(1000);
833+
834+
const calls = send.mock.calls;
835+
const transactionEvents = calls
836+
.filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')
837+
.map(call => call?.[0]?.[1]?.[0]?.[1]);
838+
839+
expect(transactionEvents.length).toBe(3);
840+
841+
const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id;
842+
expect(typeof firstProfilerId).toBe('string');
843+
844+
// Middle transaction (not profiled)
845+
expect(transactionEvents[1]?.contexts?.profile?.profiler_id).toBeUndefined();
846+
847+
const thirdProfilerId = transactionEvents[2]?.contexts?.profile?.profiler_id;
848+
expect(typeof thirdProfilerId).toBe('string');
849+
expect(firstProfilerId).toBe(thirdProfilerId); // same profiler_id across session
850+
});
807851
});
808852
});

0 commit comments

Comments
 (0)