Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
antonis marked this conversation as resolved.
integrations.push(appStartIntegration());
integrations.push(appStartIntegration({ standalone: !!options._experiments?.enableStandaloneAppStartTracing }));
}
const nativeFramesIntegrationInstance = createNativeFramesIntegrations(
hasTracingEnabled && options.enableNativeFramesTracking && options.enableNative,
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/js/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down
219 changes: 147 additions & 72 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -324,7 +338,8 @@
firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
cachedNativeAppStart = undefined;
standaloneAppStartSent = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remount blocks standalone app start

Medium Severity

With standalone tracing, standaloneAppStartSent is cleared only inside the runApplication handler when appStartDataFlushed is already true. A second runApplication before the first standalone capture finishes leaves that flag set after the first send, so the remountโ€™s _captureAppStart hits the guard and never emits an app.start transaction for the new run.

Additional Locations (1)
Fix in Cursorย Fix in Web

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

View check run for this annotation

@sentry/warden / warden: find-bugs

`standaloneCaptureInProgress` not reset in `onRunApplication`, permanently blocking new-run captures

Add `standaloneCaptureInProgress = false` alongside `standaloneAppStartSent = false` in the `onRunApplication` reset block; if a capture is in-flight when `runApplication` fires, the new run's deferred `captureStandaloneAppStart` hits the `standaloneCaptureInProgress` guard and returns early with no retry, silently dropping the standalone app-start transaction for the new run.
clearTimeout(deferredStandaloneTimeout);
deferredStandaloneTimeout = undefined;
Comment on lines 338 to 344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 standaloneCaptureInProgress flag is not reset, causing the new app start transaction to be silently missed.
Severity: MEDIUM

Suggested Fix

Ensure the standaloneCaptureInProgress flag is reset in all relevant state-clearing paths within onRunApplication. Specifically, add standaloneCaptureInProgress = false; to the else block that handles cases where appStartDataFlushed is false to prevent the race condition on app restart.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/js/tracing/integrations/appStart.ts#L338-L344

Potential issue: A race condition can occur if the application restarts, for example via
hot reload, while a standalone app start capture is in progress and awaiting
`fetchNativeFramesDelay`. The `onRunApplication` callback for the new run will execute
while `appStartDataFlushed` is `false`, taking a path that fails to reset the
`standaloneCaptureInProgress` flag. Consequently, the new app run's attempt to capture
its own app start will see the flag is still `true` from the previous run and will
return early, permanently and silently missing the app start transaction for the new
run.

}
Expand Down Expand Up @@ -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

View check run for this annotation

@sentry/warden / warden: find-bugs

`resetAppStartDataFlushed` does not reset `standaloneAppStartSent`, silently dropping the `appLoaded()` manual timestamp

When `appLoaded()` calls `resetAppStartDataFlushed()` to allow a re-send with the manual timestamp, the guard `standaloneAppStartSent` is not cleared, so `captureStandaloneAppStart()` immediately returns early and the manual end timestamp is silently lost.
Comment on lines +434 to +438

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resetAppStartDataFlushed does not reset standaloneAppStartSent, silently dropping the appLoaded() manual timestamp

When appLoaded() calls resetAppStartDataFlushed() to allow a re-send with the manual timestamp, the guard standaloneAppStartSent is not cleared, so captureStandaloneAppStart() immediately returns early and the manual end timestamp is silently lost.

Evidence
  • appLoaded() at line 128 calls integration.resetAppStartDataFlushed() with the comment "Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp".
  • resetAppStartDataFlushed (line 736) only resets appStartDataFlushed = false; it does not touch standaloneAppStartSent.
  • If the deferred auto-capture timeout (set to 0 ms in scheduleDeferredStandaloneCapture) fired before appLoaded() reached line 125, standaloneAppStartSent is already true.
  • The guard at lines 434โ€“438 returns early when standaloneAppStartSent is true, so the manual-timestamp transaction is never sent despite the documented intent.
  • The onRunApplication reset at line 341 does clear standaloneAppStartSent, but that only runs on the next app launch, not during the current appLoaded() call.

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> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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.',
);
Comment thread
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),
);
}
Comment thread
antonis marked this conversation as resolved.
}

const resetAppStartDataFlushed = (): void => {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/js/tracing/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 4 additions & 0 deletions packages/core/src/js/tracing/semanticAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading