diff --git a/CHANGELOG.md b/CHANGELOG.md index a83db4e18d..5cfd788cdc 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 ([#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 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..4ba050c026 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'; @@ -295,6 +300,15 @@ 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; + // 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; @@ -324,6 +338,7 @@ export const appStartIntegration = ({ firstStartedActiveRootSpan = undefined; isAppLoadedManuallyInvoked = false; cachedNativeAppStart = undefined; + standaloneAppStartSent = false; if (deferredStandaloneTimeout !== undefined) { clearTimeout(deferredStandaloneTimeout); deferredStandaloneTimeout = undefined; @@ -416,50 +431,68 @@ export const appStartIntegration = ({ return; } - debug.log('[AppStart] App start tracking standalone root span (transaction).'); + 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; + } - 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); + // 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); + } } - } - const span = startInactiveSpan({ - forceTransaction: true, - name: APP_START_TX_NAME, - op: UI_LOAD_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); - if (!event.spans || event.spans.length === 0) { - // No spans were added to the transaction, so we don't need to send it - 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); + const scope = getCapturedScopesOnSpan(span).scope || getCurrentScope(); + scope.captureEvent(event); + standaloneAppStartSent = true; + } finally { + standaloneCaptureInProgress = false; + } } async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { @@ -541,9 +574,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; @@ -571,21 +610,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,27 +643,53 @@ export const appStartIntegration = ({ setSpanDurationAsMeasurementOnTransactionEvent(event, 'time_to_full_display', maybeTtfdSpan); } - const appStartEndTimestampSeconds = appStartEndTimestampMs / 1000; - 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.', ); 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; + + // 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 = { + op: traceOp, + origin, + span_id: event.contexts.trace.span_id, + trace_id: event.contexts.trace.trace_id, + start_timestamp: appStartTimestampSeconds, + 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 +697,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/__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 54677da33f..54ae704d02 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'; @@ -109,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(); @@ -127,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 () => { @@ -170,31 +220,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 +250,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 +279,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 +460,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 +471,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 }); + + 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(); - expect(actualEvent?.measurements?.[APP_START_COLD_MEASUREMENT]).toBeDefined(); + 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); }); }); @@ -991,6 +1036,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', @@ -1292,11 +1366,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 +1410,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 +1451,101 @@ 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('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('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 () => { @@ -1443,15 +1608,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 +1633,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 +1739,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 +1760,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 +1789,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 +1810,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 +1994,7 @@ function expectEventWithAttachedWarmAppStart({ } function expectEventWithStandaloneColdAppStart( - actualEvent: Event, + _actualEvent: Event, { timeOriginMilliseconds, appStartTimeMilliseconds, @@ -1855,47 +2003,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 +2041,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, - }, - }, - ]), }); }