diff --git a/CHANGELOG.md b/CHANGELOG.md index 11842dd352..2abea43047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Adds Metrics Beta ([#5402](https://github.com/getsentry/sentry-react-native/pull/5402)) - Improves Expo Router integration to optionally include full paths to components instead of just component names ([#5414](https://github.com/getsentry/sentry-react-native/pull/5414)) - Report slow and frozen frames as TTID/TTFD span data ([#5419](https://github.com/getsentry/sentry-react-native/pull/5419)) +- Report slow and frozen frames on spans created through the API ([#5420](https://github.com/getsentry/sentry-react-native/issues/5420)) ### Fixes diff --git a/packages/core/src/js/tracing/integrations/nativeFrames.ts b/packages/core/src/js/tracing/integrations/nativeFrames.ts index 58de636e0a..d46b350954 100644 --- a/packages/core/src/js/tracing/integrations/nativeFrames.ts +++ b/packages/core/src/js/tracing/integrations/nativeFrames.ts @@ -54,7 +54,8 @@ export const createNativeFramesIntegrations = (enable: boolean | undefined): Int }; /** - * Instrumentation to add native slow/frozen frames measurements onto transactions. + * Instrumentation to add native slow/frozen frames measurements onto transactions + * and frame data (frames.total, frames.slow, frames.frozen) onto all spans. */ export const nativeFramesIntegration = (): Integration => { /** The native frames at the finish time of the most recent span. */ @@ -81,13 +82,11 @@ export const nativeFramesIntegration = (): Integration => { client.on('spanEnd', fetchEndFramesForSpan); }; - const fetchStartFramesForSpan = (rootSpan: Span): void => { - if (!isRootSpan(rootSpan)) { - return; - } + const fetchStartFramesForSpan = (span: Span): void => { + const spanId = span.spanContext().spanId; + const spanType = isRootSpan(span) ? 'root' : 'child'; + debug.log(`[${INTEGRATION_NAME}] Fetching frames for ${spanType} span start (${spanId}).`); - const spanId = rootSpan.spanContext().spanId; - debug.log(`[${INTEGRATION_NAME}] Fetching frames for root span start (${spanId}).`); _spanToNativeFramesAtStartMap.set( spanId, new Promise(resolve => { @@ -101,17 +100,26 @@ export const nativeFramesIntegration = (): Integration => { ); }; - const fetchEndFramesForSpan = (span: Span): void => { + /** + * Fetches end frames for a span and attaches frame data as span attributes. + * + * Note: This makes one native bridge call per span end. While this creates O(n) calls + * for n spans, it's necessary for accuracy. Frame counts are cumulative and continuously + * incrementing, so each span needs the exact frame count at its end time. Caching would + * produce incorrect deltas. The native bridge calls are async and non-blocking. + */ + const fetchEndFramesForSpan = async (span: Span): Promise => { const timestamp = timestampInSeconds(); const spanId = span.spanContext().spanId; + const hasStartFrames = _spanToNativeFramesAtStartMap.has(spanId); - if (isRootSpan(span)) { - const hasStartFrames = _spanToNativeFramesAtStartMap.has(spanId); - if (!hasStartFrames) { - // We don't have start frames, won't be able to calculate the difference. - return; - } + if (!hasStartFrames) { + // We don't have start frames, won't be able to calculate the difference. + return; + } + if (isRootSpan(span)) { + // Root spans: Store end frames for transaction measurements (backward compatibility) debug.log(`[${INTEGRATION_NAME}] Fetch frames for root span end (${spanId}).`); _spanToNativeFramesAtEndMap.set( spanId, @@ -129,17 +137,45 @@ export const nativeFramesIntegration = (): Integration => { }); }), ); - return undefined; - } else { - debug.log(`[${INTEGRATION_NAME}] Fetch frames for child span end (${spanId}).`); - fetchNativeFrames() - .then(frames => { - _lastChildSpanEndFrames = { - timestamp, - nativeFrames: frames, - }; - }) - .catch(error => debug.log(`[${INTEGRATION_NAME}] Error while fetching native frames.`, error)); + } + + // All spans (root and child): Attach frame data as span attributes + try { + const startFrames = await _spanToNativeFramesAtStartMap.get(spanId); + if (!startFrames) { + debug.log(`[${INTEGRATION_NAME}] No start frames found for span ${spanId}, skipping frame data.`); + return; + } + + // NOTE: For root spans, this is the second call to fetchNativeFrames() for the same span. + // The calls are very close together (microseconds apart), so inconsistency is minimal. + // Future optimization: reuse the first call's promise to avoid redundant native bridge call. + const endFrames = await fetchNativeFrames(); + + // Calculate deltas + const totalFrames = endFrames.totalFrames - startFrames.totalFrames; + const slowFrames = endFrames.slowFrames - startFrames.slowFrames; + const frozenFrames = endFrames.frozenFrames - startFrames.frozenFrames; + + // Only attach if we have meaningful data + if (totalFrames > 0 || slowFrames > 0 || frozenFrames > 0) { + span.setAttribute('frames.total', totalFrames); + span.setAttribute('frames.slow', slowFrames); + span.setAttribute('frames.frozen', frozenFrames); + debug.log( + `[${INTEGRATION_NAME}] Attached frame data to span ${spanId}: total=${totalFrames}, slow=${slowFrames}, frozen=${frozenFrames}`, + ); + } + + // Update last child span end frames for root span fallback logic + if (!isRootSpan(span)) { + _lastChildSpanEndFrames = { + timestamp, + nativeFrames: endFrames, + }; + } + } catch (error) { + debug.log(`[${INTEGRATION_NAME}] Error while capturing end frames for span ${spanId}.`, error); } }; @@ -233,8 +269,23 @@ export const nativeFramesIntegration = (): Integration => { function fetchNativeFrames(): Promise { return new Promise((resolve, reject) => { + let settled = false; + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + reject('Fetching native frames took too long. Dropping frames.'); + } + }, FETCH_FRAMES_TIMEOUT_MS); + NATIVE.fetchNativeFrames() .then(value => { + if (settled) { + return; + } + clearTimeout(timeoutId); + settled = true; + if (!value) { reject('Native frames response is null.'); return; @@ -242,12 +293,13 @@ function fetchNativeFrames(): Promise { resolve(value); }) .then(undefined, error => { + if (settled) { + return; + } + clearTimeout(timeoutId); + settled = true; reject(error); }); - - setTimeout(() => { - reject('Fetching native frames took too long. Dropping frames.'); - }, FETCH_FRAMES_TIMEOUT_MS); }); } diff --git a/packages/core/test/tracing/integrations/nativeframes.test.ts b/packages/core/test/tracing/integrations/nativeframes.test.ts index 143ef6c787..28c257b010 100644 --- a/packages/core/test/tracing/integrations/nativeframes.test.ts +++ b/packages/core/test/tracing/integrations/nativeframes.test.ts @@ -273,4 +273,113 @@ describe('NativeFramesInstrumentation', () => { }), ]); }); + + it('attaches frame data to child spans', async () => { + const rootStartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 }; + const childStartFrames = { totalFrames: 110, slowFrames: 11, frozenFrames: 6 }; + const childEndFrames = { totalFrames: 160, slowFrames: 16, frozenFrames: 8 }; + const rootEndFrames = { totalFrames: 200, slowFrames: 20, frozenFrames: 10 }; + + mockFunction(NATIVE.fetchNativeFrames) + .mockResolvedValueOnce(rootStartFrames) + .mockResolvedValueOnce(childStartFrames) + .mockResolvedValueOnce(childEndFrames) + .mockResolvedValueOnce(rootEndFrames); + + await startSpan({ name: 'test' }, async () => { + startSpan({ name: 'child-span' }, () => {}); + await Promise.resolve(); // Flush frame captures + await Promise.resolve(); + await Promise.resolve(); + }); + + await client.flush(); + + expect(client.event).toBeDefined(); + const childSpan = client.event!.spans!.find(s => s.description === 'child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan!.data).toEqual( + expect.objectContaining({ + 'frames.total': 50, + 'frames.slow': 5, + 'frames.frozen': 2, + }), + ); + }); + + it('does not attach frame data to child spans when deltas are zero', async () => { + const frames = { + totalFrames: 100, + slowFrames: 10, + frozenFrames: 5, + }; + mockFunction(NATIVE.fetchNativeFrames).mockResolvedValue(frames); // Same frames = delta of 0 + + await startSpan({ name: 'test' }, async () => { + startSpan({ name: 'child-span' }, () => {}); + await Promise.resolve(); // Flush frame captures + await Promise.resolve(); + await Promise.resolve(); + }); + + await client.flush(); + + expect(client.event).toBeDefined(); + const childSpan = client.event!.spans!.find(s => s.description === 'child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan!.data).not.toHaveProperty('frames.total'); + expect(childSpan!.data).not.toHaveProperty('frames.slow'); + expect(childSpan!.data).not.toHaveProperty('frames.frozen'); + }); + + it('attaches frame data to multiple child spans', async () => { + const rootStartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 }; + const child1StartFrames = { totalFrames: 100, slowFrames: 10, frozenFrames: 5 }; + const child2StartFrames = { totalFrames: 120, slowFrames: 12, frozenFrames: 6 }; + const child1EndFrames = { totalFrames: 120, slowFrames: 12, frozenFrames: 6 }; + const child2EndFrames = { totalFrames: 150, slowFrames: 15, frozenFrames: 8 }; + const rootEndFrames = { totalFrames: 200, slowFrames: 20, frozenFrames: 10 }; + + mockFunction(NATIVE.fetchNativeFrames) + .mockResolvedValueOnce(rootStartFrames) + .mockResolvedValueOnce(child1StartFrames) + .mockResolvedValueOnce(child2StartFrames) + .mockResolvedValueOnce(child1EndFrames) + .mockResolvedValueOnce(child2EndFrames) + .mockResolvedValueOnce(rootEndFrames); + + await startSpan({ name: 'test' }, async () => { + startSpan({ name: 'child-span-1' }, () => {}); + startSpan({ name: 'child-span-2' }, () => {}); + + await Promise.resolve(); // Flush all frame captures + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await client.flush(); + + expect(client.event).toBeDefined(); + + const childSpan1 = client.event!.spans!.find(s => s.description === 'child-span-1'); + expect(childSpan1).toBeDefined(); + expect(childSpan1!.data).toEqual( + expect.objectContaining({ + 'frames.total': 20, + 'frames.slow': 2, + 'frames.frozen': 1, + }), + ); + + const childSpan2 = client.event!.spans!.find(s => s.description === 'child-span-2'); + expect(childSpan2).toBeDefined(); + expect(childSpan2!.data).toEqual( + expect.objectContaining({ + 'frames.total': 30, + 'frames.slow': 3, + 'frames.frozen': 2, + }), + ); + }); });