diff --git a/CHANGELOG.md b/CHANGELOG.md index 9329741fd2..9a2262812e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Copy `app.vitals.start.screen` and `app.vitals.start.type` onto standalone `app.start` children, including user spans under `app.start.extended` ([#6631](https://github.com/getsentry/sentry-react-native/pull/6631)) - `featureFlagsIntegration` now forwards flag evaluations to the native SDKs, so flags are attached to native crashes too ([#6613](https://github.com/getsentry/sentry-react-native/pull/6613)) ### Dependencies 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();