From 50c010ff322d2706c2f4be225fcf9ed6b1307a2f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 28 Aug 2026 12:49:08 +0200 Subject: [PATCH] feat(tracing): Copy app start vitals onto standalone children Mobile vitals grouping queries app.vitals.start.type and app.vitals.start.screen on child spans. Those attributes were only on the standalone app.start root, so breakdown and user spans under app.start.extended did not group with the root. Co-Authored-By: Cursor --- CHANGELOG.md | 6 ++ .../src/js/tracing/integrations/appStart.ts | 32 +++++++++ .../__snapshots__/appStart.test.ts.snap | 1 + .../tracing/integrations/appStart.test.ts | 71 +++++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d34db6a9..65762a3266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Copy `app.vitals.start.screen` and `app.vitals.start.type` onto standalone `app.start` children, including user spans under `app.start.extended` + ## 8.24.0 ### Features diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 2b737a8f62..0bd5446c2a 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -833,6 +833,10 @@ export const appStartIntegration = ({ children.push(...appStartSpans); debug.log('[AppStart] Added app start spans to transaction event.', JSON.stringify(appStartSpans, undefined, 2)); + if (standalone) { + copyStandaloneAppStartVitalsToChildren(event); + } + if (!standalone && !suppressMeasurement) { const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; const measurementValue = { @@ -1059,6 +1063,34 @@ export const appStartIntegration = ({ } as AppStartIntegration; }; +/** + * Copies `app.vitals.start.type` and `app.vitals.start.screen` from the standalone `app.start` + * root onto every child span so mobile-vitals queries can group/drill down by those dimensions. + * `app.vitals.start.value` stays on the root only. Screen is omitted when the root has none. + */ +function copyStandaloneAppStartVitalsToChildren(event: TransactionEvent): void { + const rootData = event.contexts?.trace?.data; + if (!rootData) { + return; + } + + const type = rootData[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]; + const screen = rootData[SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN]; + if (type === undefined && screen === undefined) { + return; + } + + for (const span of event.spans || []) { + span.data = span.data || {}; + if (type !== undefined) { + span.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE] = type; + } + if (screen !== undefined) { + span.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN] = screen; + } + } +} + function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, label: string, span: SpanJSON): void { if (!span.timestamp || !span.start_timestamp) { debug.warn('Span is missing start or end timestamp. Cam not set measurement on transaction event.'); diff --git a/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap b/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap index c17504789f..7531213c21 100644 --- a/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap +++ b/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap @@ -94,6 +94,7 @@ exports[`App Start Integration Standalone App Start matches the locked standalon "spans": [ { "data": { + "app.vitals.start.type": "cold", "sentry.op": "app.start", "sentry.origin": "auto.app.start", }, diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 6b8f5e751f..497c68fe2f 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -168,6 +168,7 @@ describe('App Start Integration', () => { expectEventWithStandaloneColdAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), ); expect(actualEvent?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN]).toBe('HomeScreen'); + expectStandaloneChildrenHaveAppStartVitals(actualEvent, { type: 'cold', screen: 'HomeScreen' }); } finally { screenSpy.mockRestore(); } @@ -178,6 +179,14 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); expect(actualEvent?.contexts?.trace?.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN); + expectStandaloneChildrenHaveAppStartVitals(actualEvent, { type: 'cold' }); + }); + + it('copies app.vitals.start.type onto standalone children including native spans', async () => { + mockAppStart({ cold: false, enableNativeSpans: true }); + + const actualEvent = await captureStandAloneAppStart(); + expectStandaloneChildrenHaveAppStartVitals(actualEvent, { type: 'warm' }); }); it('Does not add any spans or measurements when App Start Span is longer than threshold', async () => { @@ -271,6 +280,7 @@ describe('App Start Integration', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]: 'cold', }, }), ); @@ -299,6 +309,7 @@ describe('App Start Integration', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]: 'cold', }, }), ); @@ -327,6 +338,7 @@ describe('App Start Integration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]: 'cold', }, }), ); @@ -1259,6 +1271,42 @@ describe('Extended App Start', () => { expect(childSpan?.parent_span_id).toBe(extended?.span_id); }); + it('copies app start vitals onto the extended span, user children, and nested descendants', async () => { + mockAppStart({ cold: true }); + const screenSpy = jest + .spyOn(ReactNativeTracing, 'getCurrentReactNativeTracingIntegration') + .mockReturnValue({ state: { currentRoute: 'HomeScreen' } } as ReturnType< + typeof ReactNativeTracing.getCurrentReactNativeTracingIntegration + >); + const { integration, client } = setupStandaloneIntegration(); + + try { + integration.extendAppStart(); + const extendedSpan = integration.getExtendedAppStartSpan(); + const child = startInactiveSpan({ parentSpan: extendedSpan, op: 'app.init', name: 'load config' }); + const grandchild = startInactiveSpan({ parentSpan: child, op: 'app.init', name: 'parse flags' }); + grandchild.end(); + child.end(); + + await integration.finishExtendedAppStart(); + + const event = client.event as TransactionEvent; + expect(event?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]).toBe('cold'); + expect(event?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN]).toBe('HomeScreen'); + expectStandaloneChildrenHaveAppStartVitals(event, { type: 'cold', screen: 'HomeScreen' }); + + const extended = event?.spans?.find(s => s.op === APP_START_EXTENDED_OP); + const childSpan = event?.spans?.find(s => s.description === 'load config'); + const grandchildSpan = event?.spans?.find(s => s.description === 'parse flags'); + expect(extended).toBeDefined(); + expect(childSpan).toBeDefined(); + expect(grandchildSpan).toBeDefined(); + expect(grandchildSpan?.parent_span_id).toBe(childSpan?.span_id); + } finally { + screenSpy.mockRestore(); + } + }); + it('trims the transaction end to the last child span', async () => { const [timeOriginMilliseconds] = mockAppStart({ cold: true }); const { integration, client } = setupStandaloneIntegration(); @@ -1358,6 +1406,9 @@ describe('Extended App Start', () => { const event = client.eventQueue[0] as TransactionEvent; expect(event?.contexts?.trace?.op).toBe(APP_START_OP); expect(event?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE]).toBeUndefined(); + expect(event?.contexts?.trace?.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE); + expect(event?.contexts?.trace?.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN); + expectStandaloneChildrenHaveAppStartVitals(event, {}); }); it('does not claim the run when the standalone transaction is not recording (falls back to normal capture)', async () => { @@ -2281,6 +2332,26 @@ function processEvent(event: Event): PromiseLike | Event | null { return processEventWithIntegration(integration, event); } +function expectStandaloneChildrenHaveAppStartVitals( + event: Event | null | undefined, + { type, screen }: { type?: string; screen?: string } = {}, +): void { + expect(event?.spans?.length).toBeGreaterThan(0); + for (const span of event!.spans!) { + if (type !== undefined) { + expect(span.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]).toBe(type); + } else { + expect(span.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE); + } + if (screen !== undefined) { + expect(span.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN]).toBe(screen); + } else { + expect(span.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_SCREEN); + } + expect(span.data).not.toHaveProperty(SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE); + } +} + async function captureStandAloneAppStart(): Promise | Event | null> { getCurrentScope().clear(); getIsolationScope().clear();