From b20bacd07d8b64cd568d12754c34ac64bc980a3d Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Tue, 27 Oct 2026 15:46:03 +0100 Subject: [PATCH 1/2] fix(browser): Preserve empty stacks in continuous profiles Co-Authored-By: GPT-6 --- packages/browser/src/profiling/utils.ts | 11 ++- packages/browser/test/profiling/utils.test.ts | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/browser/test/profiling/utils.test.ts diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index a81878e9c55d..cc9a523d6f0e 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -147,17 +147,26 @@ function convertToContinuousProfile(input: { stacks[i] = list; } + let emptyStackId: number | undefined; const samples: ContinuousThreadCpuProfile['samples'] = []; for (let i = 0; i < input.samples.length; i++) { const sample = input.samples[i]; if (!sample) { continue; } + let stackId = sample.stackId; + if (stackId === undefined) { + if (emptyStackId === undefined) { + emptyStackId = input.stacks.length; + stacks[emptyStackId] = []; + } + stackId = emptyStackId; + } // Sample timestamps are relative to `performance.timeOrigin`, so we convert them like performance entries. // The cast is safe: the JS Self-Profiling API only exists if the Performance API does. const timestampSeconds = _INTERNAL_performanceTimeToSeconds(sample.timestamp) as number; samples[i] = { - stack_id: sample.stackId ?? 0, + stack_id: stackId, thread_id: PROFILER_THREAD_ID_STRING, timestamp: timestampSeconds, }; diff --git a/packages/browser/test/profiling/utils.test.ts b/packages/browser/test/profiling/utils.test.ts new file mode 100644 index 000000000000..6913b0a2f58a --- /dev/null +++ b/packages/browser/test/profiling/utils.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient } from '../../src/client'; +import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; +import { createProfileChunkPayload } from '../../src/profiling/utils'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; + +describe('createProfileChunkPayload', () => { + let client: BrowserClient; + + beforeEach(() => { + client = new BrowserClient(getDefaultBrowserClientOptions()); + }); + + afterEach(async () => { + await client.close(); + }); + + it('assigns samples without a stack to one empty stack and preserves recorded stacks', () => { + const input: JSSelfProfile = { + frames: [{ name: 'sentryWrapped' }, { name: 'startRequest' }], + stacks: [{ frameId: 0 }, { frameId: 1, parentId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10, stackId: 0 }, { timestamp: 20, stackId: 1 }, { timestamp: 30 }], + resources: [], + }; + + const { profile } = createProfileChunkPayload(input, client); + + expect(profile.stacks).toEqual([[0], [1, 0], []]); + expect(profile.samples.map(sample => sample.stack_id)).toEqual([2, 0, 1, 2]); + }); + + it('does not add an empty stack when all samples have stacks', () => { + const input: JSSelfProfile = { + frames: [{ name: 'startRequest' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0, stackId: 0 }], + resources: [], + }; + + const { profile } = createProfileChunkPayload(input, client); + + expect(profile.stacks).toEqual([[0]]); + expect(profile.samples.map(sample => sample.stack_id)).toEqual([0]); + }); + + it('reuses the empty stack at index zero when no stacks were recorded', () => { + const input: JSSelfProfile = { + frames: [], + stacks: [], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }; + + const { profile } = createProfileChunkPayload(input, client); + + expect(profile.stacks).toEqual([[]]); + expect(profile.samples.map(sample => sample.stack_id)).toEqual([0, 0]); + }); + + it('places the empty stack after the input stack indices even with trailing holes', () => { + const input: JSSelfProfile = { + frames: [{ name: 'startRequest' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10, stackId: 0 }], + resources: [], + }; + input.stacks.length = 3; + + const { profile } = createProfileChunkPayload(input, client); + + expect(profile.stacks).toHaveLength(4); + expect(profile.stacks[0]).toEqual([0]); + expect(profile.stacks[3]).toEqual([]); + expect(profile.samples.map(sample => sample.stack_id)).toEqual([3, 0]); + }); +}); From fedd906f42378ea266c429acaf01884826be739f Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 28 Oct 2026 09:16:24 +0100 Subject: [PATCH 2/2] refactor(browser): Simplify empty profile stack handling Co-Authored-By: GPT-6 --- packages/browser/src/profiling/utils.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index cc9a523d6f0e..a2bfaaa0f508 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -147,20 +147,16 @@ function convertToContinuousProfile(input: { stacks[i] = list; } - let emptyStackId: number | undefined; + const emptyStackId = input.stacks.length; const samples: ContinuousThreadCpuProfile['samples'] = []; for (let i = 0; i < input.samples.length; i++) { const sample = input.samples[i]; if (!sample) { continue; } - let stackId = sample.stackId; - if (stackId === undefined) { - if (emptyStackId === undefined) { - emptyStackId = input.stacks.length; - stacks[emptyStackId] = []; - } - stackId = emptyStackId; + const stackId = sample.stackId ?? emptyStackId; + if (stackId === emptyStackId) { + stacks[emptyStackId] ??= []; } // Sample timestamps are relative to `performance.timeOrigin`, so we convert them like performance entries. // The cast is safe: the JS Self-Profiling API only exists if the Performance API does.