diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/subject.js new file mode 100644 index 000000000000..c2b910dde899 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/subject.js @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration, spanStreamingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [spanStreamingIntegration(), browserProfilingIntegration()], + tracesSampleRate: 1, + traceLifecycle: 'stream', + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-span-1', parentSpan: span }, () => { + fibonacci(20); + }); + + Sentry.startSpan({ name: 'child-span-2', parentSpan: span }, () => { + fibonacci(20); + }); + + await new Promise(resolve => setTimeout(resolve, 40)); + span.end(); +}); + +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/test.ts new file mode 100644 index 000000000000..f033302c9579 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_streamed-spans/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../utils/spanUtils'; + +sentryTest( + 'attaches thread.id and thread.name to streamed spans (trace mode)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const spansPromise = waitForStreamedSpans(page, receivedSpans => { + return receivedSpans.some(s => s.name === 'root-fibonacci'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + await page.goto(url); + + const spans = await spansPromise; + + const rootSpan = spans.find(s => s.name === 'root-fibonacci'); + expect(rootSpan).toBeDefined(); + + expect(rootSpan!.attributes?.['thread.id']).toEqual({ type: 'string', value: '0' }); + expect(rootSpan!.attributes?.['thread.name']).toEqual({ type: 'string', value: 'main' }); + + const childSpans = spans.filter(s => s.name === 'child-span-1' || s.name === 'child-span-2'); + expect(childSpans.length).toBeGreaterThanOrEqual(1); + + for (const child of childSpans) { + expect(child.attributes?.['thread.id']).toEqual({ type: 'string', value: '0' }); + expect(child.attributes?.['thread.name']).toEqual({ type: 'string', value: 'main' }); + } + }, +); diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index aa9c6a5f5fdf..e64f7720710b 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -11,7 +11,13 @@ import { import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from './../debug-build'; import type { JSSelfProfiler } from './jsSelfProfiling'; -import { createProfileChunkPayload, shouldProfileSession, startJSSelfProfile, validateProfileChunk } from './utils'; +import { + createProfileChunkPayload, + setThreadAttributes, + shouldProfileSession, + startJSSelfProfile, + validateProfileChunk, +} from './utils'; const CHUNK_INTERVAL_MS = 60_000; // 1 minute // Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) @@ -78,6 +84,12 @@ export class UIProfiler implements ContinuousProfiler { if (lifecycleMode === 'trace') { this._setupTraceLifecycleListeners(client); } + + client.on('spanStart', span => { + if (this._isRunning) { + setThreadAttributes(span); + } + }); } /** Starts UI profiling (only effective in 'manual' mode and when sampled). */ @@ -142,6 +154,10 @@ export class UIProfiler implements ContinuousProfiler { this._beginProfiling(); } + + if (this._isRunning) { + setThreadAttributes(rootSpan); + } } /** diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 22706117fa74..c94be3bc5d7c 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -8,12 +8,13 @@ import { UIProfiler } from './UIProfiler'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, - attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, hasLegacyProfiling, isAutomatedPageLoadSpan, + isSpanProfiled, + setThreadAttributes, shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -92,8 +93,13 @@ const _browserProfilingIntegration = (() => { } client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpanLegacy(span)) { - startProfileForSpan(span); + const rootSpan = getRootSpan(span); + if (span === rootSpan) { + if (shouldProfileSpanLegacy(span)) { + startProfileForSpan(span); + } + } else if (isSpanProfiled(rootSpan)) { + setThreadAttributes(span); } }); @@ -151,9 +157,6 @@ const _browserProfilingIntegration = (() => { }); } }, - processEvent(event) { - return attachProfiledThreadToEvent(event); - }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/startProfileForSpan.ts b/packages/browser/src/profiling/startProfileForSpan.ts index 6c36c6bf84aa..b760f44a9d17 100644 --- a/packages/browser/src/profiling/startProfileForSpan.ts +++ b/packages/browser/src/profiling/startProfileForSpan.ts @@ -3,7 +3,14 @@ import { debug, getCurrentScope, spanToJSON, timestampInSeconds, uuid4 } from '@ import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import type { JSSelfProfile } from './jsSelfProfiling'; -import { addProfileToGlobalCache, isAutomatedPageLoadSpan, MAX_PROFILE_DURATION_MS, startJSSelfProfile } from './utils'; +import { + addProfileToGlobalCache, + isAutomatedPageLoadSpan, + markSpanAsProfiled, + MAX_PROFILE_DURATION_MS, + setThreadAttributes, + startJSSelfProfile, +} from './utils'; /** * Wraps startTransaction and stopTransaction with profiling related logic. @@ -48,6 +55,9 @@ export function startProfileForSpan(span: Span): void { start_timestamp: startTimestamp, }); + markSpanAsProfiled(span); + setThreadAttributes(span); + /** * Idempotent handler for profile stop */ diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 12446e6fb986..dd49ec5d4505 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -786,39 +786,17 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi } } -/** - * Attaches the profiled thread information to the event's trace context. - */ -export function attachProfiledThreadToEvent(event: Event): Event { - if (!event?.contexts?.profile) { - return event; - } +const PROFILED_ROOT_SPANS: WeakSet = new WeakSet(); - if (!event.contexts) { - return event; - } - - // Only mutate the trace context when it already has a trace_id — that - // guarantees `applySpanToEvent` has already run, and we are not creating a partial trace context from scratch. - if (event.contexts.trace?.trace_id) { - event.contexts.trace = { - ...event.contexts.trace, - data: { - ...(event.contexts.trace.data ?? {}), - ['thread.id']: PROFILER_THREAD_ID_STRING, - ['thread.name']: PROFILER_THREAD_NAME, - }, - }; - } +export function markSpanAsProfiled(span: Span): void { + PROFILED_ROOT_SPANS.add(span); +} - // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. - event.spans?.forEach(span => { - span.data = { - ...(span.data || {}), - ['thread.id']: PROFILER_THREAD_ID_STRING, - ['thread.name']: PROFILER_THREAD_NAME, - }; - }); +export function isSpanProfiled(span: Span): boolean { + return PROFILED_ROOT_SPANS.has(span); +} - return event; +export function setThreadAttributes(span: Span): void { + span.setAttribute('thread.id', PROFILER_THREAD_ID_STRING); + span.setAttribute('thread.name', PROFILER_THREAD_NAME); } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index d1702cfcbd49..f09f573e1aeb 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -583,6 +583,96 @@ describe('Browser Profiling v2 trace lifecycle', () => { }); }); + describe('thread attributes', () => { + it('sets thread.id and thread.name on root span', async () => { + vi.useRealTimers(); + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + }); + + Sentry.startSpan({ name: 'root-thread-attrs', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction.contexts.trace.data['thread.id']).toBe('0'); + expect(transaction.contexts.trace.data['thread.name']).toBe('main'); + }); + + it('sets thread.id and thread.name on child spans', async () => { + vi.useRealTimers(); + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + }); + + Sentry.startSpan({ name: 'root-with-children', parentSpan: null, forceTransaction: true }, () => { + Sentry.startSpan({ name: 'child-span-1' }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'child-span-2' }, () => { + /* empty */ + }); + }); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction.spans).toHaveLength(2); + for (const span of transaction.spans) { + expect(span.data['thread.id']).toBe('0'); + expect(span.data['thread.name']).toBe('main'); + } + }); + + it('does not set thread attributes when session is not sampled', async () => { + vi.useRealTimers(); + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + profileSessionSampleRate: 0, + }); + + Sentry.startSpan({ name: 'unsampled-root', parentSpan: null, forceTransaction: true }, () => { + Sentry.startSpan({ name: 'unsampled-child' }, () => { + /* empty */ + }); + }); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + expect(transaction.contexts.trace.data['thread.id']).toBeUndefined(); + expect(transaction.contexts.trace.data['thread.name']).toBeUndefined(); + + expect(transaction.spans).toHaveLength(1); + expect(transaction.spans[0].data['thread.id']).toBeUndefined(); + expect(transaction.spans[0].data['thread.name']).toBeUndefined(); + }); + }); + it('calling start and stop in trace lifecycle prints warnings', async () => { const { stop } = mockProfiler(); const send = vi.fn().mockResolvedValue(undefined); @@ -848,4 +938,77 @@ describe('Browser Profiling v2 manual lifecycle', () => { expect(firstProfilerId).toBe(thirdProfilerId); // same profiler_id across session }); }); + + describe('thread attributes', () => { + it('sets thread.id and thread.name on spans created while profiling is active', async () => { + vi.useRealTimers(); + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + Sentry.uiProfiler.startProfiler(); + + Sentry.startSpan({ name: 'manual-profiled-root', parentSpan: null, forceTransaction: true }, () => { + Sentry.startSpan({ name: 'manual-profiled-child' }, () => { + /* empty */ + }); + }); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction.contexts.trace.data['thread.id']).toBe('0'); + expect(transaction.contexts.trace.data['thread.name']).toBe('main'); + + expect(transaction.spans).toHaveLength(1); + expect(transaction.spans[0].data['thread.id']).toBe('0'); + expect(transaction.spans[0].data['thread.name']).toBe('main'); + }); + + it('does not set thread attributes on spans created outside of profiling window', async () => { + vi.useRealTimers(); + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + // Create span BEFORE profiling starts + Sentry.startSpan({ name: 'before-profiling', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + // Create span AFTER profiling stops + Sentry.startSpan({ name: 'after-profiling', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const txnCalls = calls.filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + + for (const txnCall of txnCalls) { + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + expect(transaction.contexts.trace.data['thread.id']).toBeUndefined(); + expect(transaction.contexts.trace.data['thread.name']).toBeUndefined(); + } + }); + }); }); diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 9ec370bc827f..b281c54c578c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -108,4 +108,64 @@ describe('BrowserProfilingIntegration', () => { const lifecycle = client?.getOptions()?.profileLifecycle; expect(lifecycle).toBe('manual'); }); + + describe('legacy profiling thread attributes', () => { + it('sets thread.id and thread.name on root span and child spans', async () => { + class MockProfiler { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return Promise.resolve({ + frames: [{ name: 'test_fn', line: 1, column: 1 }], + stacks: [{ frameId: 0, parentId: undefined }], + samples: [ + { stackId: 0, timestamp: 0 }, + { stackId: 0, timestamp: 100 }, + ], + resources: [], + } as JSSelfProfile); + } + } + + // @ts-expect-error this is a mock constructor + window.Profiler = MockProfiler; + + const send = vi.fn().mockResolvedValue(undefined); + const client = Sentry.init({ + tracesSampleRate: 1, + profilesSampleRate: 1, + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + transport: _opts => ({ + flush: vi.fn().mockResolvedValue(true), + send, + }), + integrations: [Sentry.browserProfilingIntegration()], + }); + + Sentry.startSpan({ name: 'legacy-root', parentSpan: null, forceTransaction: true }, () => { + Sentry.startSpan({ name: 'legacy-child' }, () => { + /* empty */ + }); + }); + + await client!.flush(1000); + + const txnCall = send.mock.calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + // Root span thread attributes are in contexts.trace.data + expect(transaction.contexts.trace.data['thread.id']).toBe('0'); + expect(transaction.contexts.trace.data['thread.name']).toBe('main'); + + // Child span thread attributes + expect(transaction.spans).toHaveLength(1); + expect(transaction.spans[0].data['thread.id']).toBe('0'); + expect(transaction.spans[0].data['thread.name']).toBe('main'); + + (window as any).Profiler = undefined; + }); + }); });