-
-
Notifications
You must be signed in to change notification settings - Fork 361
feat(tracing): Add standalone app start transaction #6359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
03d06ac
24ce580
e5c98ae
e0c384e
5b0cf11
4991d82
3ddd44c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,12 +25,17 @@ | |
| 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 @@ | |
| let firstStartedActiveRootSpan: Span | undefined = undefined; | ||
| let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined; | ||
| let deferredStandaloneTimeout: ReturnType<typeof setTimeout> | 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,7 +338,8 @@ | |
| firstStartedActiveRootSpan = undefined; | ||
| isAppLoadedManuallyInvoked = false; | ||
| cachedNativeAppStart = undefined; | ||
| standaloneAppStartSent = false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remount blocks standalone app startMedium Severity With standalone tracing, Additional Locations (1)Reviewed by Cursor Bugbot for commit 3ddd44c. Configure here. |
||
| if (deferredStandaloneTimeout !== undefined) { | ||
|
Check warning on line 342 in packages/core/src/js/tracing/integrations/appStart.ts
|
||
| clearTimeout(deferredStandaloneTimeout); | ||
| deferredStandaloneTimeout = undefined; | ||
|
Comment on lines
338
to
344
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: During a hot reload, if a previous app start capture is in progress, the Suggested FixEnsure the Prompt for AI Agent |
||
| } | ||
|
|
@@ -416,50 +431,68 @@ | |
| 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.'); | ||
|
Check warning on line 438 in packages/core/src/js/tracing/integrations/appStart.ts
|
||
|
Comment on lines
+434
to
+438
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Evidence
Identified by Warden find-bugs ยท SEJ-EBM |
||
| 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<void> { | ||
|
|
@@ -541,9 +574,15 @@ | |
| 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 @@ | |
|
|
||
| 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,64 +643,94 @@ | |
| 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.', | ||
| ); | ||
|
antonis marked this conversation as resolved.
|
||
| 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([ | ||
| NATIVE.fetchNativeFramesDelay(appStartTimestampSeconds, appStartEndTimestampSeconds), | ||
| new Promise<null>(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), | ||
| ); | ||
| } | ||
|
antonis marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const resetAppStartDataFlushed = (): void => { | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.