From 03d06ac70d92eee4a921a9d1021a2112e6109379 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 12:58:09 +0200 Subject: [PATCH 1/7] feat(tracing): Add standalone app start transaction (RN-541) Add experimental `_experiments.enableStandaloneAppStartTracing` to send app start as a dedicated `app.start` transaction (Span V2) instead of attaching app start data to the first navigation (`ui.load`) transaction. The standalone transaction uses op `app.start`, name `App Start`, and carries the vitals as attributes on the root span (`app.vitals.start.value`, `app.vitals.start.type`). This decouples app start from the navigation transaction lifecycle, so it is no longer lost when no qualifying navigation transaction is sent. The legacy (non-standalone) path is unchanged and remains the default. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + packages/core/src/js/integrations/default.ts | 2 +- packages/core/src/js/options.ts | 19 ++ .../src/js/tracing/integrations/appStart.ts | 118 +++++--- packages/core/src/js/tracing/ops.ts | 3 + .../core/src/js/tracing/semanticAttributes.ts | 4 + .../test/integrations/defaultAppStart.test.ts | 45 +++ .../tracing/integrations/appStart.test.ts | 256 +++++++----------- 8 files changed, 255 insertions(+), 193 deletions(-) create mode 100644 packages/core/test/integrations/defaultAppStart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a83db4e18d..a4e1675bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add experimental `enableStandaloneAppStartTracing` to send app start as a standalone `app.start` transaction ([#5839](https://github.com/getsentry/sentry-react-native/issues/5839)) - Use the runtime's native `btoa` for envelope base64 encoding when available, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if `btoa` is missing ([#6351](https://github.com/getsentry/sentry-react-native/pull/6351)). ### Dependencies diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index 4c4fb963b3..d545b88d43 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -113,7 +113,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ // `tracesSampleRate: undefined` should not enable tracing const hasTracingEnabled = typeof options.tracesSampleRate === 'number' || typeof options.tracesSampler === 'function'; if (hasTracingEnabled && options.enableAppStartTracking && options.enableNative) { - integrations.push(appStartIntegration()); + integrations.push(appStartIntegration({ standalone: !!options._experiments?.enableStandaloneAppStartTracing })); } const nativeFramesIntegrationInstance = createNativeFramesIntegrations( hasTracingEnabled && options.enableNativeFramesTracking && options.enableNative, diff --git a/packages/core/src/js/options.ts b/packages/core/src/js/options.ts index d836144351..a40d550947 100644 --- a/packages/core/src/js/options.ts +++ b/packages/core/src/js/options.ts @@ -382,6 +382,25 @@ export interface BaseReactNativeOptions { * @deprecated Use `profilingOptions` instead. This option will be removed in the next major version. */ androidProfilingOptions?: ProfilingOptions; + + /** + * Sends app start as a dedicated standalone `app.start` transaction instead of + * attaching app start data to the first navigation (`ui.load`) transaction. + * + * This decouples app start data from the navigation transaction lifecycle, so it + * is no longer lost when no qualifying navigation transaction is sent. The standalone + * transaction uses op `app.start`, name `App Start`, and the span attributes + * `app.vitals.start.value` (duration) and `app.vitals.start.type` (`cold` / `warm`). + * + * Note: the standalone transaction still respects `tracesSampleRate`. A dedicated + * app start sample rate is not yet available. + * + * Requires `enableAppStartTracking` and performance monitoring to be enabled. + * + * @experimental + * @default false + */ + enableStandaloneAppStartTracing?: boolean; }; /** diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index e4b0cd9a6a..8b8e35f8bd 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -25,12 +25,17 @@ import { convertSpanToTransaction, isRootSpan, setEndTimeValue } from '../../uti import { NATIVE } from '../../wrapper'; import { getRootSpanDiscardReason, getTransactionEventDiscardReason } from '../onSpanEndUtils'; import { + APP_START as APP_START_OP, APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, UI_LOAD as UI_LOAD_OP, } from '../ops'; import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../origin'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; +import { + SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE, + SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, +} from '../semanticAttributes'; import { setMainThreadInfo } from '../span'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; @@ -436,7 +441,7 @@ export const appStartIntegration = ({ const span = startInactiveSpan({ forceTransaction: true, name: APP_START_TX_NAME, - op: UI_LOAD_OP, + op: APP_START_OP, }); if (span instanceof SentryNonRecordingSpan) { // Tracing is disabled or the transaction was sampled @@ -453,8 +458,11 @@ export const appStartIntegration = ({ } await attachAppStartToTransactionEvent(event); - if (!event.spans || event.spans.length === 0) { - // No spans were added to the transaction, so we don't need to send it + // App start data is carried as Span V2 attributes on the root transaction, so the standalone + // transaction is meaningful even without breakdown child spans. If attachment was skipped + // (e.g. already flushed, or native data unavailable) the vitals attribute is absent — skip send. + if (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE] === undefined) { + debug.log('[AppStart] No app start data attached to the standalone transaction. Skipping send.'); return; } @@ -571,21 +579,27 @@ export const appStartIntegration = ({ appStartDataFlushed = true; - event.contexts.trace.data = event.contexts.trace.data || {}; - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = UI_LOAD_OP; - event.contexts.trace.op = UI_LOAD_OP; - const origin = isRecordedAppStartEndTimestampMsManual ? SPAN_ORIGIN_MANUAL_APP_START : SPAN_ORIGIN_AUTO_APP_START; + // Standalone uses the Span V2 `app.start` op; non-standalone keeps the legacy `ui.load` op + // on the carrier navigation transaction. + const traceOp = standalone ? APP_START_OP : UI_LOAD_OP; + + event.contexts.trace.data = event.contexts.trace.data || {}; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = traceOp; + event.contexts.trace.op = traceOp; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin; event.contexts.trace.origin = origin; const appStartTimestampSeconds = appStartTimestampMs / 1000; + const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000; event.start_timestamp = appStartTimestampSeconds; event.spans = event.spans || []; /** event.spans reference */ const children: SpanJSON[] = event.spans; + // Re-anchor the screen-load display spans to the process/app start time. These are only + // present on the non-standalone `ui.load` transaction; a no-op for the standalone `app.start`. const maybeTtidSpan = children.find(({ op }) => op === 'ui.load.initial_display'); if (maybeTtidSpan) { maybeTtidSpan.start_timestamp = appStartTimestampSeconds; @@ -598,7 +612,6 @@ export const appStartIntegration = ({ setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_full_display', maybeTtfdSpan); } - const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000; if (event.timestamp && event.timestamp < appStartEndTimestampSeconds) { debug.log( '[AppStart] Transaction event timestamp is before app start end. Adjusting transaction event timestamp.', @@ -606,19 +619,48 @@ export const appStartIntegration = ({ event.timestamp = appStartEndTimestampSeconds; } - const op = appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP; - const appStartSpanJSON: SpanJSON = createSpanJSON({ - op, - description: appStart.type === 'cold' ? 'Cold Start' : 'Warm Start', - start_timestamp: appStartTimestampSeconds, - timestamp: appStartEndTimestampSeconds, - trace_id: event.contexts.trace.trace_id, - parent_span_id: event.contexts.trace.span_id, - origin, - }); + // Parent of the app start breakdown spans (JS bundle execution, native init): + // - Standalone (Span V2): the root `app.start` transaction itself, carrying the app start + // vitals as attributes. No legacy per-type span or `app_start_*` measurement is emitted — + // Relay backfills the V1 encoding from these attributes. + // - Non-standalone (legacy V1): a dedicated `app.start.cold`/`app.start.warm` child span plus + // the `app_start_*` measurement, attached to the `ui.load` navigation transaction. + let breakdownParent: SpanJSON; + if (standalone) { + // Bound the standalone transaction exactly to the app start window. + event.timestamp = appStartEndTimestampSeconds; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE] = appStartDurationMs; + event.contexts.trace.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE] = appStart.type; + + // Synthetic parent referencing the root transaction span, so breakdown spans attach + // directly under it. `data` is shared with the root trace context so frame data lands + // on the root span. + breakdownParent = { + status: 'ok', + description: APP_START_TX_NAME, + op: traceOp, + origin, + span_id: event.contexts.trace.span_id, + trace_id: event.contexts.trace.trace_id, + parent_span_id: event.contexts.trace.parent_span_id, + start_timestamp: appStartTimestampSeconds, + timestamp: appStartEndTimestampSeconds, + data: event.contexts.trace.data, + }; + } else { + breakdownParent = createSpanJSON({ + op: appStart.type === 'cold' ? APP_START_COLD_OP : APP_START_WARM_OP, + description: appStart.type === 'cold' ? 'Cold Start' : 'Warm Start', + start_timestamp: appStartTimestampSeconds, + timestamp: appStartEndTimestampSeconds, + trace_id: event.contexts.trace.trace_id, + parent_span_id: event.contexts.trace.span_id, + origin, + }); + } if (appStartEndData?.endFrames) { - attachFrameDataToSpan(appStartSpanJSON, appStartEndData.endFrames); + attachFrameDataToSpan(breakdownParent, appStartEndData.endFrames); try { const framesDelay = await Promise.race([ @@ -626,36 +668,40 @@ export const appStartIntegration = ({ new Promise(resolve => setTimeout(() => resolve(null), 2_000)), ]); if (framesDelay != null) { - appStartSpanJSON.data = appStartSpanJSON.data || {}; - appStartSpanJSON.data['frames.delay'] = framesDelay; + breakdownParent.data = breakdownParent.data || {}; + breakdownParent.data['frames.delay'] = framesDelay; } } catch (error) { debug.log('[AppStart] Error while fetching frames delay for app start span.', error); } } - const jsExecutionSpanJSON = createJSExecutionStartSpan(appStartSpanJSON, rootComponentCreationTimestampMs); + const jsExecutionSpanJSON = createJSExecutionStartSpan(breakdownParent, rootComponentCreationTimestampMs); const appStartSpans = [ - appStartSpanJSON, + // In standalone mode the parent IS the root transaction, so it is not pushed as a child + // span; only its breakdown children are added. + ...(standalone ? [] : [breakdownParent]), ...(jsExecutionSpanJSON ? [jsExecutionSpanJSON] : []), - ...convertNativeSpansToSpanJSON(appStartSpanJSON, appStart.spans), + ...convertNativeSpansToSpanJSON(breakdownParent, appStart.spans), ]; children.push(...appStartSpans); debug.log('[AppStart] Added app start spans to transaction event.', JSON.stringify(appStartSpans, undefined, 2)); - const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; - const measurementValue = { - value: appStartDurationMs, - unit: 'millisecond', - }; - event.measurements = event.measurements || {}; - event.measurements[measurementKey] = measurementValue; - debug.log( - '[AppStart] Added app start measurement to transaction event.', - JSON.stringify(measurementValue, undefined, 2), - ); + if (!standalone) { + const measurementKey = appStart.type === 'cold' ? APP_START_COLD_MEASUREMENT : APP_START_WARM_MEASUREMENT; + const measurementValue = { + value: appStartDurationMs, + unit: 'millisecond', + }; + event.measurements = event.measurements || {}; + event.measurements[measurementKey] = measurementValue; + debug.log( + '[AppStart] Added app start measurement to transaction event.', + JSON.stringify(measurementValue, undefined, 2), + ); + } } const resetAppStartDataFlushed = (): void => { diff --git a/packages/core/src/js/tracing/ops.ts b/packages/core/src/js/tracing/ops.ts index 79c7c239b1..0d392bc3fa 100644 --- a/packages/core/src/js/tracing/ops.ts +++ b/packages/core/src/js/tracing/ops.ts @@ -8,5 +8,8 @@ export const UI_ACTION_TOUCH = 'ui.action.touch'; export const APP_START_COLD = 'app.start.cold'; export const APP_START_WARM = 'app.start.warm'; +/** Standalone app start transaction op (Span V2 / EAP). */ +export const APP_START = 'app.start'; + export const UI_LOAD_INITIAL_DISPLAY = 'ui.load.initial_display'; export const UI_LOAD_FULL_DISPLAY = 'ui.load.full_display'; diff --git a/packages/core/src/js/tracing/semanticAttributes.ts b/packages/core/src/js/tracing/semanticAttributes.ts index 046d162e77..ab8f033000 100644 --- a/packages/core/src/js/tracing/semanticAttributes.ts +++ b/packages/core/src/js/tracing/semanticAttributes.ts @@ -18,3 +18,7 @@ export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID = 'previous_route.co export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE = 'previous_route.component_type'; export const SEMANTIC_ATTRIBUTE_TIME_TO_INITIAL_DISPLAY_FALLBACK = 'route.initial_display_fallback'; export const SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE = 'navigation.action_type'; + +// App start vitals (Span V2 / EAP). Emitted on the standalone `app.start` transaction. +export const SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE = 'app.vitals.start.value'; +export const SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE = 'app.vitals.start.type'; diff --git a/packages/core/test/integrations/defaultAppStart.test.ts b/packages/core/test/integrations/defaultAppStart.test.ts new file mode 100644 index 0000000000..87a66a1a5f --- /dev/null +++ b/packages/core/test/integrations/defaultAppStart.test.ts @@ -0,0 +1,45 @@ +import type { ReactNativeClientOptions } from '../../src/js/options'; + +import { getDefaultIntegrations } from '../../src/js/integrations/default'; +import { appStartIntegration } from '../../src/js/integrations/exports'; + +jest.mock('../../src/js/integrations/exports', () => { + const actual = jest.requireActual('../../src/js/integrations/exports'); + return { + ...actual, + appStartIntegration: jest.fn(() => ({ name: 'AppStart' })), + }; +}); + +describe('getDefaultIntegrations - standalone app start wiring', () => { + beforeEach(() => { + (appStartIntegration as jest.Mock).mockClear(); + }); + + const createOptions = (overrides: Partial): ReactNativeClientOptions => + ({ + dsn: 'https://example.com/1', + enableNative: true, + enableAppStartTracking: true, + tracesSampleRate: 1.0, + ...overrides, + }) as ReactNativeClientOptions; + + it('creates a non-standalone app start integration by default', () => { + getDefaultIntegrations(createOptions({})); + + expect(appStartIntegration).toHaveBeenCalledWith({ standalone: false }); + }); + + it('creates a standalone app start integration when the experiment flag is enabled', () => { + getDefaultIntegrations(createOptions({ _experiments: { enableStandaloneAppStartTracing: true } })); + + expect(appStartIntegration).toHaveBeenCalledWith({ standalone: true }); + }); + + it('creates a non-standalone app start integration when the experiment flag is false', () => { + getDefaultIntegrations(createOptions({ _experiments: { enableStandaloneAppStartTracing: false } })); + + expect(appStartIntegration).toHaveBeenCalledWith({ standalone: false }); + }); +}); diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 54677da33f..03519f623f 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -20,6 +20,7 @@ import { APP_START_WARM as APP_START_WARM_MEASUREMENT, } from '../../../src/js/measurements'; import { + APP_START as APP_START_OP, APP_START_COLD as APP_START_COLD_OP, APP_START_WARM as APP_START_WARM_OP, UI_LOAD, @@ -35,6 +36,10 @@ import { setRootComponentCreationTimestampMs, } from '../../../src/js/tracing/integrations/appStart'; import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../../../src/js/tracing/origin'; +import { + SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE, + SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE, +} from '../../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_MAIN } from '../../../src/js/tracing/span'; import { getTimeOriginMilliseconds } from '../../../src/js/tracing/utils'; import { RN_GLOBAL_OBJ } from '../../../src/js/utils/worldwide'; @@ -170,31 +175,23 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); + const rootSpanId = actualEvent!.contexts!.trace!.span_id; const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Start', ); - expect(appStartRootSpan).toEqual( - expect.objectContaining(>{ - span_id: expect.any(String), - description: 'Cold Start', - op: APP_START_COLD_OP, - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, - }, - }), - ); + // Span V2: the transaction itself is the app start; no per-type `Cold Start` child span. + expect(actualEvent!.contexts!.trace!.op).toBe(APP_START_OP); + expect(actualEvent!.spans!.find(({ description }) => description === 'Cold Start')).toBeUndefined(); expect(bundleStartSpan).toEqual( expect.objectContaining(>{ description: 'JS Bundle Execution Start', start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span + parent_span_id: rootSpanId, // parent is the root app start transaction span + op: APP_START_OP, data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }, }), @@ -208,38 +205,28 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); + const rootSpanId = actualEvent!.contexts!.trace!.span_id; const bundleStartSpan = actualEvent!.spans!.find( ({ description }) => description === 'JS Bundle Execution Before React Root', ); - expect(appStartRootSpan).toEqual( - expect.objectContaining(>{ - span_id: expect.any(String), - description: 'Cold Start', - op: APP_START_COLD_OP, - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, - }, - }), - ); + expect(actualEvent!.contexts!.trace!.op).toBe(APP_START_OP); expect(bundleStartSpan).toEqual( expect.objectContaining(>{ description: 'JS Bundle Execution Before React Root', start_timestamp: expect.closeTo((timeOriginMilliseconds - 50) / 1000), timestamp: (timeOriginMilliseconds - 10) / 1000, - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span + parent_span_id: rootSpanId, // parent is the root app start transaction span + op: APP_START_OP, data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }, }), ); }); - it('adds native spans as a child of the main app start span', async () => { + it('adds native spans as a child of the app start transaction', async () => { const [timeOriginMilliseconds] = mockAppStart({ cold: true, enableNativeSpans: true, @@ -247,29 +234,19 @@ describe('App Start Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartRootSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); + const rootSpanId = actualEvent!.contexts!.trace!.span_id; const nativeSpan = actualEvent!.spans!.find(({ description }) => description === 'test native app start span'); - expect(appStartRootSpan).toEqual( - expect.objectContaining(>{ - span_id: expect.any(String), - description: 'Cold Start', - op: APP_START_COLD_OP, - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, - }, - }), - ); + expect(actualEvent!.contexts!.trace!.op).toBe(APP_START_OP); expect(nativeSpan).toEqual( expect.objectContaining(>{ description: 'test native app start span', start_timestamp: (timeOriginMilliseconds - 100) / 1000, timestamp: (timeOriginMilliseconds - 50) / 1000, - parent_span_id: appStartRootSpan!.span_id, // parent is the root app start span - op: appStartRootSpan!.op, // op is the same as the root app start span + parent_span_id: rootSpanId, // parent is the root app start transaction span + op: APP_START_OP, data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN, }, @@ -438,15 +415,10 @@ describe('App Start Integration', () => { expect(actualEvent?.spans).toBeDefined(); expect(actualEvent?.spans?.length).toBeGreaterThan(0); - // Verify that app start was attached successfully - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - expect(appStartSpan).toBeDefined(); - expect(appStartSpan).toEqual( - expect.objectContaining>({ - description: 'Cold Start', - op: APP_START_COLD_OP, - }), - ); + // Verify that app start was attached successfully (Span V2: vitals on the root transaction) + expect(actualEvent?.contexts?.trace?.op).toBe(APP_START_OP); + expect(actualEvent?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]).toBe('cold'); + expect(actualEvent?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE]).toBeDefined(); // Verify the standalone transaction has a different span ID than the navigation transaction // This confirms that the span ID check was skipped (otherwise app start wouldn't be attached) @@ -454,8 +426,36 @@ describe('App Start Integration', () => { if (navigationSpanId) { expect(actualEvent?.contexts?.trace?.span_id).not.toBe(navigationSpanId); } + }); + + it('Connects the standalone app start transaction to the active trace', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + mockAppStart({ cold: true }); - expect(actualEvent?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeDefined(); + const integration = appStartIntegration({ standalone: true }); + const client = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(client); + integration.setup(client); + + // A navigation transaction defines the active trace. + const navigationSpan = startInactiveSpan({ name: 'home', op: 'navigation', forceTransaction: true }); + const navigationTraceId = navigationSpan?.spanContext().traceId; + navigationSpan?.end(); + + await integration.captureStandaloneAppStart(); + + const actualEvent = client.event as TransactionEvent | undefined; + expect(actualEvent?.contexts?.trace?.op).toBe(APP_START_OP); + // The standalone app.start transaction must share the trace with the ui.load (navigation) + // transaction so they are connected in the Sentry UI. + expect(actualEvent?.contexts?.trace?.trace_id).toBe(navigationTraceId); }); }); @@ -1292,11 +1292,12 @@ describe('appLoaded() standalone mode', () => { const actualEvent = standaloneClient.event; expect(actualEvent).toBeDefined(); - const appStartSpan = actualEvent?.spans?.find(s => s.op === APP_START_COLD_OP); - expect(appStartSpan).toBeDefined(); - expect(appStartSpan?.timestamp).toBeCloseTo(appLoadedTimeSeconds, 1); - expect(appStartSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); - expect(appStartSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); + // Span V2: app start vitals and timing are on the root transaction. + expect(actualEvent?.contexts?.trace?.op).toBe(APP_START_OP); + expect(actualEvent?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]).toBe('cold'); + expect(actualEvent?.timestamp).toBeCloseTo(appLoadedTimeSeconds, 1); + expect(actualEvent?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + expect(actualEvent?.contexts?.trace?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); }); it('overrides already-flushed standalone transaction when appLoaded() is called after auto-capture', async () => { @@ -1335,11 +1336,10 @@ describe('appLoaded() standalone mode', () => { // Only one transaction should be sent — the manual one expect(standaloneClient.eventQueue.length).toBe(1); const manualEvent = standaloneClient.eventQueue[0]; - const manualSpan = manualEvent?.spans?.find(s => s.op === APP_START_COLD_OP); - expect(manualSpan).toBeDefined(); - expect(manualSpan?.timestamp).toBeCloseTo(manualTimeSeconds, 1); - expect(manualSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); - expect(manualSpan?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); + expect(manualEvent?.contexts?.trace?.op).toBe(APP_START_OP); + expect(manualEvent?.timestamp).toBeCloseTo(manualTimeSeconds, 1); + expect(manualEvent?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + expect(manualEvent?.contexts?.trace?.origin).toBe(SPAN_ORIGIN_MANUAL_APP_START); }); it('sends deferred standalone transaction when appLoaded() is not called', async () => { @@ -1377,10 +1377,9 @@ describe('appLoaded() standalone mode', () => { expect(standaloneClient.eventQueue.length).toBe(1); const autoEvent = standaloneClient.eventQueue[0]; - const autoSpan = autoEvent?.spans?.find(s => s.op === APP_START_COLD_OP); - expect(autoSpan).toBeDefined(); - expect(autoSpan?.timestamp).toBeCloseTo(autoTimeSeconds, 1); - expect(autoSpan?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); + expect(autoEvent?.contexts?.trace?.op).toBe(APP_START_OP); + expect(autoEvent?.timestamp).toBeCloseTo(autoTimeSeconds, 1); + expect(autoEvent?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); }); it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => { @@ -1443,15 +1442,13 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).toEqual( + // Span V2: frame data lands on the root app start transaction span. + expect(actualEvent!.contexts!.trace!.data).toEqual( expect.objectContaining({ 'frames.total': 150, 'frames.slow': 5, 'frames.frozen': 2, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }), ); @@ -1470,15 +1467,12 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Warm Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).toEqual( + expect(actualEvent!.contexts!.trace!.data).toEqual( expect.objectContaining({ 'frames.total': 200, 'frames.slow': 8, 'frames.frozen': 1, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_WARM_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }), ); @@ -1579,19 +1573,16 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).toEqual( + expect(actualEvent!.contexts!.trace!.data).toEqual( expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }), ); - expect(appStartSpan!.data).not.toHaveProperty('frames.total'); - expect(appStartSpan!.data).not.toHaveProperty('frames.slow'); - expect(appStartSpan!.data).not.toHaveProperty('frames.frozen'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.total'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.slow'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.frozen'); }); it('does not attach frame data when NATIVE is not enabled', async () => { @@ -1603,19 +1594,16 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).toEqual( + expect(actualEvent!.contexts!.trace!.data).toEqual( expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, }), ); - expect(appStartSpan!.data).not.toHaveProperty('frames.total'); - expect(appStartSpan!.data).not.toHaveProperty('frames.slow'); - expect(appStartSpan!.data).not.toHaveProperty('frames.frozen'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.total'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.slow'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.frozen'); } finally { (NATIVE as any).enableNative = originalEnableNative; } @@ -1635,10 +1623,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).toEqual( + expect(actualEvent!.contexts!.trace!.data).toEqual( expect.objectContaining({ 'frames.delay': 0.25, }), @@ -1659,10 +1644,7 @@ describe('Frame Data Integration', () => { const actualEvent = await captureStandAloneAppStart(); - const appStartSpan = actualEvent!.spans!.find(({ description }) => description === 'Cold Start'); - - expect(appStartSpan).toBeDefined(); - expect(appStartSpan!.data).not.toHaveProperty('frames.delay'); + expect(actualEvent!.contexts!.trace!.data).not.toHaveProperty('frames.delay'); }); }); @@ -1846,7 +1828,7 @@ function expectEventWithAttachedWarmAppStart({ } function expectEventWithStandaloneColdAppStart( - actualEvent: Event, + _actualEvent: Event, { timeOriginMilliseconds, appStartTimeMilliseconds, @@ -1855,47 +1837,29 @@ function expectEventWithStandaloneColdAppStart( appStartTimeMilliseconds: number; }, ) { + // Span V2 / EAP encoding: the standalone `app.start` transaction carries the app start vitals + // as attributes on the root span. No legacy `ui.load` op, per-type child span, or + // `app_start_*` measurement is emitted. return expect.objectContaining({ type: 'transaction', start_timestamp: appStartTimeMilliseconds / 1000, contexts: expect.objectContaining({ trace: expect.objectContaining({ - op: UI_LOAD, + op: APP_START_OP, origin: SPAN_ORIGIN_AUTO_APP_START, data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE]: timeOriginMilliseconds - appStartTimeMilliseconds, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]: 'cold', }), }), }), - measurements: expect.objectContaining({ - [APP_START_COLD_MEASUREMENT]: { - value: timeOriginMilliseconds - appStartTimeMilliseconds, - unit: 'millisecond', - }, - }), - spans: expect.arrayContaining([ - { - op: APP_START_COLD_OP, - description: 'Cold Start', - start_timestamp: appStartTimeMilliseconds / 1000, - timestamp: expect.any(Number), - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: actualEvent.contexts.trace.span_id, - origin: SPAN_ORIGIN_AUTO_APP_START, - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_COLD_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, - }, - }, - ]), }); } function expectEventWithStandaloneWarmAppStart( - actualEvent: Event, + _actualEvent: Event, { timeOriginMilliseconds, appStartTimeMilliseconds, @@ -1911,37 +1875,17 @@ function expectEventWithStandaloneWarmAppStart( start_timestamp: appStartTimeMilliseconds / 1000, contexts: expect.objectContaining({ trace: expect.objectContaining({ - op: UI_LOAD, + op: APP_START_OP, origin: SPAN_ORIGIN_AUTO_APP_START, data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: UI_LOAD, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_OP, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE]: + appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds, + [SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE]: 'warm', }), }), }), - measurements: expect.objectContaining({ - [APP_START_WARM_MEASUREMENT]: { - value: appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds, - unit: 'millisecond', - }, - }), - spans: expect.arrayContaining([ - { - op: APP_START_WARM_OP, - description: 'Warm Start', - start_timestamp: appStartTimeMilliseconds / 1000, - timestamp: expect.any(Number), - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: actualEvent.contexts.trace.span_id, - origin: SPAN_ORIGIN_AUTO_APP_START, - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: APP_START_WARM_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, - }, - }, - ]), }); } From 24ce58067f0332beefdd524a3e96c293515fdb6e Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 13:13:57 +0200 Subject: [PATCH 2/7] docs: Link PR in changelog entry Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4e1675bc0..5cfd788cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Add experimental `enableStandaloneAppStartTracing` to send app start as a standalone `app.start` transaction ([#5839](https://github.com/getsentry/sentry-react-native/issues/5839)) +- Add experimental `enableStandaloneAppStartTracing` to send app start as a standalone `app.start` transaction ([#6359](https://github.com/getsentry/sentry-react-native/pull/6359)) - Use the runtime's native `btoa` for envelope base64 encoding when available, to improve `captureEnvelope` performance. Falls back to the bundled JS encoder if `btoa` is missing ([#6351](https://github.com/getsentry/sentry-react-native/pull/6351)). ### Dependencies From e5c98aefa0b3dafe28469ab84aa25e7bf226b7bd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 13:29:25 +0200 Subject: [PATCH 3/7] test(tracing): Lock app start event shape with snapshots Add hermetic snapshot tests that byte-lock the full event for both the default (non-standalone) and opt-in standalone cold app start flows. The default snapshot was generated against the pre-change SDK and matches unchanged after the standalone changes, proving the non-opt-in path is unaffected. The standalone snapshot guards the Span V2 encoding (op `app.start`, `app.vitals.start.*`, no legacy per-type span/measurement). Co-Authored-By: Claude Opus 4.8 --- .../__snapshots__/appStart.test.ts.snap | 119 ++++++++++++++++++ .../tracing/integrations/appStart.test.ts | 62 +++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap diff --git a/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap b/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap new file mode 100644 index 0000000000..c17504789f --- /dev/null +++ b/packages/core/test/tracing/integrations/__snapshots__/appStart.test.ts.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`App Start Integration App Start Attached to the First Root Span matches the locked default (non-standalone) cold app start event 1`] = ` +{ + "contexts": { + "trace": { + "data": { + "sentry.op": "ui.load", + "sentry.origin": "auto.app.start", + }, + "op": "ui.load", + "origin": "auto.app.start", + "span_id": "123", + "trace_id": "456", + }, + }, + "measurements": { + "app_start_cold": { + "unit": "millisecond", + "value": 100, + }, + }, + "spans": [ + { + "data": {}, + "description": "Test", + "op": "test", + "span_id": "123", + "start_timestamp": 100, + "timestamp": 200, + "trace_id": "456", + }, + { + "data": { + "sentry.op": "app.start.cold", + "sentry.origin": "auto.app.start", + }, + "description": "Cold Start", + "op": "app.start.cold", + "origin": "auto.app.start", + "parent_span_id": "123", + "span_id": Any, + "start_timestamp": Any, + "status": "ok", + "timestamp": Any, + "trace_id": "456", + }, + { + "data": { + "sentry.op": "app.start.cold", + "sentry.origin": "auto.app.start", + }, + "description": "JS Bundle Execution Start", + "op": "app.start.cold", + "origin": "auto.app.start", + "parent_span_id": Any, + "span_id": Any, + "start_timestamp": Any, + "status": "ok", + "timestamp": Any, + "trace_id": "456", + }, + ], + "start_timestamp": Any, + "type": "transaction", +} +`; + +exports[`App Start Integration Standalone App Start matches the locked standalone (opt-in) cold app start event 1`] = ` +{ + "breadcrumbs": undefined, + "contexts": { + "trace": { + "data": { + "app.vitals.start.type": "cold", + "app.vitals.start.value": 100, + "sentry.op": "app.start", + "sentry.origin": "auto.app.start", + "sentry.sample_rate": 1, + "sentry.source": "custom", + }, + "links": undefined, + "op": "app.start", + "origin": "auto.app.start", + "parent_span_id": undefined, + "span_id": Any, + "status": undefined, + "trace_id": Any, + }, + }, + "environment": "production", + "event_id": Any, + "request": undefined, + "spans": [ + { + "data": { + "sentry.op": "app.start", + "sentry.origin": "auto.app.start", + }, + "description": "JS Bundle Execution Start", + "op": "app.start", + "origin": "auto.app.start", + "parent_span_id": Any, + "span_id": Any, + "start_timestamp": Any, + "status": "ok", + "timestamp": Any, + "trace_id": Any, + }, + ], + "start_timestamp": Any, + "timestamp": Any, + "transaction": "App Start", + "transaction_info": { + "source": "custom", + }, + "type": "transaction", +} +`; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 03519f623f..be4029c29b 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -114,6 +114,39 @@ describe('App Start Integration', () => { ); }); + // Byte-level lock on the opt-in standalone `app.start` (Span V2) cold event. Guards the V2 + // encoding (op `app.start`, name `App Start`, `app.vitals.start.*` attributes, no legacy + // per-type span or `app_start_*` measurement) against accidental regressions. Only dynamic + // ids/timestamps are matched. + it('matches the locked standalone (opt-in) cold app start event', async () => { + // Reset module-level state so the snapshot is hermetic and order-independent. + _clearAppStartEndData(); + _clearRootComponentCreationTimestampMs(); + mockAppStart({ cold: true }); + + const actualEvent = await captureStandAloneAppStart(); + expect(actualEvent as unknown).toMatchSnapshot({ + event_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + spans: [ + { + span_id: expect.any(String), + parent_span_id: expect.any(String), + trace_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }, + ], + }); + }); + it('Does not add any spans or measurements when App Start Span is longer than threshold', async () => { set__DEV__(false); mockTooLongAppStart(); @@ -991,6 +1024,35 @@ describe('App Start Integration', () => { expect(secondEvent).toStrictEqual(getMinimalTransactionEvent()); }); + // Byte-level lock on the default (non-opt-in) cold app start event. This snapshot was + // generated against the pre-change SDK and must remain identical after the standalone + // changes — proving the default path is unaffected. Only inherently dynamic ids/timestamps + // are matched; every op, description, span, measurement, and data key is locked. + it('matches the locked default (non-standalone) cold app start event', async () => { + // Reset module-level state so the snapshot is hermetic and order-independent. + _clearAppStartEndData(); + _clearRootComponentCreationTimestampMs(); + mockAppStart({ cold: true }); + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent as unknown).toMatchSnapshot({ + start_timestamp: expect.any(Number), + spans: [ + {}, + { + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }, + { + span_id: expect.any(String), + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }, + ], + }); + }); + it('Does not add app start span when marked as fetched from the native layer', async () => { mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue({ type: 'cold', From e0c384eb4122fa18370e32a625305c3728241e8f Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 14:32:47 +0200 Subject: [PATCH 4/7] fix(tracing): Skip app start age check for standalone transactions The MAX_APP_START_AGE_MS bounds check compared the native app start time against `event.start_timestamp`, which for a standalone transaction still holds the span creation time at that point (it is corrected to the native app start time later). On slow devices that gap can exceed the threshold and discard a valid app start. The age check only makes sense for the non-standalone path (where app start is attached to a later navigation transaction), so skip it for standalone; the duration check still filters genuinely bogus app starts. Surfaced by automated review on #6359. Co-Authored-By: Claude Opus 4.8 --- .../src/js/tracing/integrations/appStart.ts | 8 +++++++- .../test/tracing/integrations/appStart.test.ts | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 8b8e35f8bd..97901a1896 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -549,9 +549,15 @@ export const appStartIntegration = ({ return; } + // The age check guards against attaching a stale app start to a much-later navigation + // transaction. It is meaningless for standalone, where the transaction *is* the app start + // and `event.start_timestamp` still reflects the span creation time at this point (it is + // corrected to the native app start time further below). Applying it to standalone would + // discard valid app starts on slow devices, so skip it there — the duration check below + // still filters genuinely bogus (too long) app starts. const isAppStartWithinBounds = !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp * 1_000 - MAX_APP_START_AGE_MS; - if (!__DEV__ && !isAppStartWithinBounds) { + if (!standalone && !__DEV__ && !isAppStartWithinBounds) { debug.warn('[AppStart] App start timestamp is too far in the past to be used for app start span.'); appStartDataFlushed = true; return; diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index be4029c29b..5e5ae097fe 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -165,12 +165,24 @@ describe('App Start Integration', () => { ); }); - it('Does not add App Start Span older than threshold', async () => { + // The age threshold (`MAX_APP_START_AGE_MS`) only makes sense for the non-standalone path, + // where app start is attached to a later navigation transaction. For standalone the + // transaction *is* the app start, and at the bounds check `event.start_timestamp` still holds + // the span creation time (corrected to the native app start time afterwards). On slow devices + // that gap can exceed the threshold, so the age check is skipped for standalone — a valid + // (short-duration) app start must still be sent in production builds. + it('Sends standalone app start whose timestamp is older than the age threshold (slow device)', async () => { set__DEV__(false); - mockTooOldAppStart(); + const [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds] = mockTooOldAppStart(); const actualEvent = await captureStandAloneAppStart(); - expect(actualEvent).toStrictEqual(undefined); + expect(actualEvent).toEqual( + expectEventWithStandaloneWarmAppStart(actualEvent, { + timeOriginMilliseconds, + appStartTimeMilliseconds, + appStartDurationMilliseconds, + }), + ); }); it('Does add App Start Span older than threshold in development builds', async () => { From 5b0cf1166acb4b16e2b537eb130ec24ff95bfe5a Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 14:44:23 +0200 Subject: [PATCH 5/7] fix(tracing): Prevent duplicate standalone app start transactions In standalone mode the deferred auto-capture (`setTimeout(0)`) is meant to be cancelled by a later `appLoaded()` call. But once the deferred send has fired, `cancelDeferredStandaloneCapture()` is a no-op and `_appLoaded()` reset the flushed flag and sent a second `app.start` transaction for the same app run (the common case, since apps signal readiness in a later macrotask). Track whether a standalone transaction has already been sent and skip re-sending; reset the flag on `runApplication` so a new app run can send again. Surfaced by automated review on #6359. Co-Authored-By: Claude Opus 4.8 --- .../src/js/tracing/integrations/appStart.ts | 13 +++++++ .../tracing/integrations/appStart.test.ts | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 97901a1896..55a505ef88 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -300,6 +300,10 @@ export const appStartIntegration = ({ let firstStartedActiveRootSpan: Span | undefined = undefined; let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined; let deferredStandaloneTimeout: ReturnType | undefined = undefined; + // Whether a standalone `app.start` transaction has already been sent for this app run. + // Guards against a duplicate send when `appLoaded()` arrives after the deferred auto-capture + // has already fired (the cancel-based dedup only works while the deferred send is still pending). + let standaloneAppStartSent = false; const setup = (client: Client): void => { _client = client; @@ -329,6 +333,7 @@ export const appStartIntegration = ({ firstStartedActiveRootSpan = undefined; isAppLoadedManuallyInvoked = false; cachedNativeAppStart = undefined; + standaloneAppStartSent = false; if (deferredStandaloneTimeout !== undefined) { clearTimeout(deferredStandaloneTimeout); deferredStandaloneTimeout = undefined; @@ -421,6 +426,13 @@ export const appStartIntegration = ({ return; } + if (standaloneAppStartSent) { + // A standalone transaction was already sent for this app run (e.g. the deferred auto-capture + // fired before a late appLoaded() call). Don't send a duplicate. + debug.log('[AppStart] Standalone app start transaction already sent. Skipping.'); + return; + } + debug.log('[AppStart] App start tracking standalone root span (transaction).'); if (!appStartEndData?.endFrames && NATIVE.enableNative) { @@ -468,6 +480,7 @@ export const appStartIntegration = ({ const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); scope.captureEvent(event); + standaloneAppStartSent = true; } async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 5e5ae097fe..85a282e5b5 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1456,6 +1456,44 @@ describe('appLoaded() standalone mode', () => { expect(autoEvent?.start_timestamp).toBeCloseTo(appStartTimeMilliseconds / 1000, 1); }); + it('does not send a second standalone transaction when appLoaded() arrives after the deferred auto-capture fired', async () => { + jest.useFakeTimers(); + + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + mockAppStart({ cold: true }); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + // Auto-capture defers the send. + const autoTimeSeconds = Date.now() / 1000; + mockFunction(timestampInSeconds).mockReturnValue(autoTimeSeconds); + await _captureAppStart({ isManual: false }); + expect(standaloneClient.eventQueue.length).toBe(0); + + // The deferred macrotask fires first — transaction #1 is sent. + jest.runAllTimers(); + jest.useRealTimers(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(standaloneClient.eventQueue.length).toBe(1); + + // appLoaded() arrives later (a separate macrotask). The cancel is a no-op since the deferred + // already fired, so without the idempotency guard this would send a duplicate. + await _appLoaded(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(standaloneClient.eventQueue.length).toBe(1); + }); + it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => { getCurrentScope().clear(); getIsolationScope().clear(); From 4991d820423ba5aae54ece2543639c87c2d99adc Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 15:14:53 +0200 Subject: [PATCH 6/7] fix(tracing): Guard against racing in-flight standalone app start capture `captureStandaloneAppStart` awaits native work before it marks the transaction as sent, so a late `appLoaded()` racing an in-flight deferred capture could pass the sent-guard and emit a second `app.start` transaction. Add an in-flight flag set synchronously at entry (no await before it, so the check-and-set is atomic in JS) and cleared in a `finally`, alongside the existing already-sent guard. Surfaced by automated review on #6359. Co-Authored-By: Claude Opus 4.8 --- .../src/js/tracing/integrations/appStart.ts | 102 ++++++++++-------- .../tracing/integrations/appStart.test.ts | 54 ++++++++++ 2 files changed, 111 insertions(+), 45 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 55a505ef88..0ff2f6a609 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -304,6 +304,11 @@ export const appStartIntegration = ({ // Guards against a duplicate send when `appLoaded()` arrives after the deferred auto-capture // has already fired (the cancel-based dedup only works while the deferred send is still pending). let standaloneAppStartSent = false; + // Whether a standalone capture is currently in flight. `captureStandaloneAppStart` awaits native + // work before it sets `standaloneAppStartSent`, so without this an `appLoaded()` call racing an + // in-flight deferred capture could pass the guard and emit a second transaction. Set synchronously + // at entry (no `await` before it) so the check-and-set is atomic in the single-threaded JS model. + let standaloneCaptureInProgress = false; const setup = (client: Client): void => { _client = client; @@ -426,61 +431,68 @@ export const appStartIntegration = ({ return; } - if (standaloneAppStartSent) { - // A standalone transaction was already sent for this app run (e.g. the deferred auto-capture - // fired before a late appLoaded() call). Don't send a duplicate. - debug.log('[AppStart] Standalone app start transaction already sent. Skipping.'); + if (standaloneAppStartSent || standaloneCaptureInProgress) { + // A standalone transaction was already sent for this app run, or a capture is already in + // flight (e.g. the deferred auto-capture is awaiting native work when a late appLoaded() + // arrives). Either way, don't start a second send. + debug.log('[AppStart] Standalone app start capture already sent or in progress. Skipping.'); return; } - debug.log('[AppStart] App start tracking standalone root span (transaction).'); + // Marked synchronously (no await before this point) so a racing appLoaded() observes it. + standaloneCaptureInProgress = true; + try { + debug.log('[AppStart] App start tracking standalone root span (transaction).'); + + if (!appStartEndData?.endFrames && NATIVE.enableNative) { + try { + const endFrames = await NATIVE.fetchNativeFrames(); + debug.log('[AppStart] Captured end frames for standalone app start.', endFrames); + + const currentTimestamp = appStartEndData?.timestampMs || timestampInSeconds() * 1000; + _setAppStartEndData({ + timestampMs: currentTimestamp, + endFrames, + }); + } catch (error) { + debug.log('[AppStart] Failed to capture frames for standalone app start.', error); + } + } - if (!appStartEndData?.endFrames && NATIVE.enableNative) { - try { - const endFrames = await NATIVE.fetchNativeFrames(); - debug.log('[AppStart] Captured end frames for standalone app start.', endFrames); - - const currentTimestamp = appStartEndData?.timestampMs || timestampInSeconds() * 1000; - _setAppStartEndData({ - timestampMs: currentTimestamp, - endFrames, - }); - } catch (error) { - debug.log('[AppStart] Failed to capture frames for standalone app start.', error); + const span = startInactiveSpan({ + forceTransaction: true, + name: APP_START_TX_NAME, + op: APP_START_OP, + }); + if (span instanceof SentryNonRecordingSpan) { + // Tracing is disabled or the transaction was sampled + return; } - } - const span = startInactiveSpan({ - forceTransaction: true, - name: APP_START_TX_NAME, - op: APP_START_OP, - }); - if (span instanceof SentryNonRecordingSpan) { - // Tracing is disabled or the transaction was sampled - return; - } + setEndTimeValue(span, timestampInSeconds()); + _client.emit('spanEnd', span); - setEndTimeValue(span, timestampInSeconds()); - _client.emit('spanEnd', span); + const event = convertSpanToTransaction(span); + if (!event) { + debug.warn('[AppStart] Failed to convert App Start span to transaction.'); + return; + } - const event = convertSpanToTransaction(span); - if (!event) { - debug.warn('[AppStart] Failed to convert App Start span to transaction.'); - return; - } + await attachAppStartToTransactionEvent(event); + // App start data is carried as Span V2 attributes on the root transaction, so the standalone + // transaction is meaningful even without breakdown child spans. If attachment was skipped + // (e.g. already flushed, or native data unavailable) the vitals attribute is absent — skip send. + if (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE] === undefined) { + debug.log('[AppStart] No app start data attached to the standalone transaction. Skipping send.'); + return; + } - await attachAppStartToTransactionEvent(event); - // App start data is carried as Span V2 attributes on the root transaction, so the standalone - // transaction is meaningful even without breakdown child spans. If attachment was skipped - // (e.g. already flushed, or native data unavailable) the vitals attribute is absent — skip send. - if (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE] === undefined) { - debug.log('[AppStart] No app start data attached to the standalone transaction. Skipping send.'); - return; + const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); + scope.captureEvent(event); + standaloneAppStartSent = true; + } finally { + standaloneCaptureInProgress = false; } - - const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); - scope.captureEvent(event); - standaloneAppStartSent = true; } async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 85a282e5b5..54ae704d02 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1494,6 +1494,60 @@ describe('appLoaded() standalone mode', () => { expect(standaloneClient.eventQueue.length).toBe(1); }); + it('does not send a second standalone transaction when appLoaded() races an in-flight capture', async () => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + // Reset module-level state so the test is hermetic (clears isAppLoadedManuallyInvoked so + // appLoaded() actually runs its capture, and any leaked app start end data). + _clearAppStartEndData(); + _clearRootComponentCreationTimestampMs(); + + const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); + // Pin the clock so appLoaded()'s app-start-end stays within bounds of the native app start. + mockFunction(timestampInSeconds).mockReturnValue(timeOriginMilliseconds / 1000); + + // Gate the native app start fetch so the first capture stays in flight (suspended at an + // await) while appLoaded() races in. + let releaseFetch: () => void = () => {}; + const gate = new Promise(resolve => { + releaseFetch = resolve; + }); + const gatedResponse: NativeAppStartResponse = { + type: 'cold', + app_start_timestamp_ms: appStartTimeMilliseconds, + has_fetched: false, + spans: [], + }; + mockFunction(NATIVE.fetchNativeAppStart).mockReturnValue(gate.then(() => gatedResponse)); + + const integration = appStartIntegration({ standalone: true }) as AppStartIntegrationTest; + const standaloneClient = new TestClient({ + ...getDefaultTestClientOptions(), + enableAppStartTracking: true, + tracesSampleRate: 1.0, + }); + setCurrentClient(standaloneClient); + integration.setup(standaloneClient); + standaloneClient.addIntegration(integration); + + // First capture starts and suspends awaiting the gated native fetch. + const firstCapture = integration.captureStandaloneAppStart(); + // appLoaded() races in while the first capture is still in flight. + const appLoadedCall = _appLoaded(); + + // Flush microtasks/macrotasks: the racing appLoaded() capture must observe the in-flight + // guard and bail. The first capture is still gated, so nothing has been sent yet. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(standaloneClient.eventQueue.length).toBe(0); + + // Release the gate and let both calls settle — only the first capture sends. + releaseFetch(); + await Promise.all([firstCapture, appLoadedCall]); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(standaloneClient.eventQueue.length).toBe(1); + }); + it('allows auto-capture again after isAppLoadedManuallyInvoked is reset', async () => { getCurrentScope().clear(); getIsolationScope().clear(); From 3ddd44cb3b53063f1b1850dc78a932a48eaae928 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 26 Jun 2026 15:26:03 +0200 Subject: [PATCH 7/7] refactor(tracing): Tidy standalone app start implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trim the synthetic breakdown-parent to the fields the span helpers actually read (op/origin/span_id/trace_id/start_timestamp/data); drop the inert ones. - Gate the carrier-transaction end-timestamp clamp to non-standalone, since the standalone path sets its end timestamp explicitly. No behavior change — the default and standalone event snapshots are unchanged. Co-Authored-By: Claude Opus 4.8 --- .../core/src/js/tracing/integrations/appStart.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 0ff2f6a609..4ba050c026 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -643,7 +643,9 @@ export const appStartIntegration = ({ setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_full_display', maybeTtfdSpan); } - if (event.timestamp && event.timestamp < appStartEndTimestampSeconds) { + // Non-standalone only: extend the carrier transaction to cover the app start window if it ended + // earlier. Standalone sets its end timestamp explicitly below. + if (!standalone && event.timestamp && event.timestamp < appStartEndTimestampSeconds) { debug.log( '[AppStart] Transaction event timestamp is before app start end. Adjusting transaction event timestamp.', ); @@ -663,19 +665,15 @@ export const appStartIntegration = ({ event.contexts.trace.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_VALUE] = appStartDurationMs; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_APP_VITALS_START_TYPE] = appStart.type; - // Synthetic parent referencing the root transaction span, so breakdown spans attach - // directly under it. `data` is shared with the root trace context so frame data lands - // on the root span. + // Minimal parent referencing the root transaction span, so the breakdown spans attach + // directly under it (the helpers only read op/origin/span_id/trace_id/start_timestamp). + // `data` is shared with the root trace context so frame data lands on the root span. breakdownParent = { - status: 'ok', - description: APP_START_TX_NAME, op: traceOp, origin, span_id: event.contexts.trace.span_id, trace_id: event.contexts.trace.trace_id, - parent_span_id: event.contexts.trace.parent_span_id, start_timestamp: appStartTimestampSeconds, - timestamp: appStartEndTimestampSeconds, data: event.contexts.trace.data, }; } else {