Skip to content
Open
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))
- Expose top-level `Sentry.setAttribute` and `Sentry.setAttributes` APIs ([#6354](https://github.com/getsentry/sentry-react-native/pull/6354)).
- Add `enableTurboModuleTracking` opt-in experimental option to enable Turbo Module performance tracking in the New Architecture ([#6307](https://github.com/getsentry/sentry-react-native/pull/6307))
- 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)).
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 @@ -405,6 +405,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
166 changes: 115 additions & 51 deletions packages/core/src/js/tracing/integrations/appStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,24 @@
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';

const INTEGRATION_NAME = 'AppStart';

export type AppStartIntegration = Integration & {
captureStandaloneAppStart: () => Promise<void>;
resetAppStartDataFlushed: () => void;
cancelDeferredStandaloneCapture: () => void;
scheduleDeferredStandaloneCapture: () => void;
};
Expand Down Expand Up @@ -115,12 +119,11 @@

const integration = client.getIntegrationByName<AppStartIntegration>(INTEGRATION_NAME);
if (integration) {
// Cancel any deferred standalone send from auto-capture โ€” we'll send our own
// with the correct manual timestamp instead of sending two transactions.
// appLoaded() overrides the auto-detected end timestamp by cancelling the deferred standalone
// send (if it hasn't fired yet) and sending a single transaction with the manual timestamp.
// If the deferred send already fired, the standalone capture is already done, so the call
// below bails โ€” we keep the auto timestamp rather than emitting a duplicate.
integration.cancelDeferredStandaloneCapture();
// In standalone mode, auto-capture may have already flushed the transaction.
// Reset the flag so captureStandaloneAppStart can re-send with the manual timestamp.
integration.resetAppStartDataFlushed();
await integration.captureStandaloneAppStart();
}
}
Expand Down Expand Up @@ -295,6 +298,11 @@
let firstStartedActiveRootSpan: Span | undefined = undefined;
let cachedNativeAppStart: NativeAppStartResponse | null | undefined = undefined;
let deferredStandaloneTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
// Ensures at most one standalone `app.start` transaction per app run. Set synchronously at the
// start of `captureStandaloneAppStart` (before any `await`), so a second trigger for the same run
// โ€” a late `appLoaded()`, or one racing the in-flight deferred auto-capture โ€” observes it and
// bails. Reset on `runApplication` so the next app run captures again.
let standaloneAppStartCaptured = false;

const setup = (client: Client): void => {
_client = client;
Expand All @@ -317,13 +325,19 @@
// TODO: automatically set standalone based on the presence of the native layer navigation integration

getAppRegistryIntegration(client)?.onRunApplication(() => {
if (appStartDataFlushed) {
debug.log('[AppStartIntegration] Resetting app start data flushed flag based on runApplication call.');
// Reset once the current run's app start has begun capturing (flushed, or a standalone capture
// started) so a remount / hot reload starts fresh for the new run. The initial runApplication
// (root mount) fires before the first capture starts, so both are false then and we correctly
// wait. For the non-standalone path `standaloneAppStartCaptured` is always false, so this
// reduces to the original `appStartDataFlushed` check.
if (appStartDataFlushed || standaloneAppStartCaptured) {
debug.log('[AppStartIntegration] Resetting app start state based on runApplication call.');
appStartDataFlushed = false;
firstStartedActiveRootSpanId = undefined;
firstStartedActiveRootSpan = undefined;
isAppLoadedManuallyInvoked = false;
cachedNativeAppStart = undefined;
standaloneAppStartCaptured = false;

Check warning on line 340 in packages/core/src/js/tracing/integrations/appStart.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Standalone app start lost on new run when `runApplication` resets state mid-capture

When `captureStandaloneAppStart` is suspended at an `await` while a `runApplication` (remount/hot reload) fires, the `appStartDataFlushed || standaloneAppStartCaptured` reset block runs because `standaloneAppStartCaptured` was set to `true` synchronously before the await. The reset clears `appStartDataFlushed`, `standaloneAppStartCaptured`, and `cachedNativeAppStart`. The in-flight capture then resumes, completes attachment, and sets `appStartDataFlushed = true` (and re-sets `cachedNativeAppStart`). The new app run's scheduled `captureStandaloneAppStart` proceeds past the `standaloneAppStartCaptured` guard (now `false`) but `attachAppStartToTransactionEvent` returns immediately at the `appStartDataFlushed` guard, so the new run's `app.start` transaction is never sent (no vitals attribute โ†’ send skipped at line 484).
if (deferredStandaloneTimeout !== undefined) {
clearTimeout(deferredStandaloneTimeout);
deferredStandaloneTimeout = undefined;
Comment thread
antonis marked this conversation as resolved.
Expand Down Expand Up @@ -416,6 +430,16 @@
return;
}

if (standaloneAppStartCaptured) {
// At most one standalone transaction per app run. Set synchronously below, so a second
// trigger for the same run โ€” a late appLoaded(), or one racing the in-flight deferred
// auto-capture โ€” bails here instead of emitting a duplicate.
debug.log('[AppStart] Standalone app start already captured for this app run. Skipping.');
return;
}
// Claimed synchronously (no await before this point) so a racing trigger observes it.
standaloneAppStartCaptured = true;

debug.log('[AppStart] App start tracking standalone root span (transaction).');

if (!appStartEndData?.endFrames && NATIVE.enableNative) {
Expand All @@ -436,7 +460,7 @@
const span = startInactiveSpan({
forceTransaction: true,
name: APP_START_TX_NAME,
op: UI_LOAD_OP,
op: APP_START_OP,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This could be a break change.
Not on the app perspective but on the Sentry.io website (dashboards or existing filters may stop working)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point @lucas-zimerman ๐Ÿ‘ I'm not sure how to avoid this since a standalone app-start transaction must carry the app.start op to cover the requirement. Do you think it's ok to ship as is given that this is opt-in/experimental and shouldn't break users without the setting. This would be a breaking change in v9 when we turn this on by default #6304

});
if (span instanceof SentryNonRecordingSpan) {
// Tracing is disabled or the transaction was sampled
Expand All @@ -453,8 +477,11 @@
}

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.');
Comment thread
antonis marked this conversation as resolved.
return;
}

Expand Down Expand Up @@ -541,9 +568,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 +604,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,70 +637,96 @@
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 => {
appStartDataFlushed = false;
};

const cancelDeferredStandaloneCapture = (): void => {
if (deferredStandaloneTimeout !== undefined) {
clearTimeout(deferredStandaloneTimeout);
Expand All @@ -687,7 +752,6 @@
afterAllSetup,
processEvent,
captureStandaloneAppStart,
resetAppStartDataFlushed,
cancelDeferredStandaloneCapture,
scheduleDeferredStandaloneCapture,
setFirstStartedActiveRootSpanId,
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