Skip to content
Merged
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 check and download timing spans to Expo Updates listener integration ([#6430](https://github.com/getsentry/sentry-react-native/pull/6430))
- Attach a per-`(module, method)` TurboModule breakdown to active spans on `spanEnd`, plus `native.turbo_module` breadcrumbs for slow async calls ([#6478](https://github.com/getsentry/sentry-react-native/pull/6478))

When a root span ends (idle nav spans from `reactNavigationIntegration` / `expoRouterIntegration`, or a user's own `Sentry.startSpan(...)`), the integration writes `turbo_module.<name>.<method>.{call_count,duration_ms,error_count}` attributes plus summary keys (`turbo_module.total_call_count`, `turbo_module.total_duration_ms`, `turbo_module.top_module`). Async calls above `slowCallThresholdMs` (default 500ms) additionally record a `native.turbo_module` breadcrumb. Both surfaces enabled by default; new knobs `enableSpanAttribution`, `slowCallThresholdMs`, `maxTopModulesPerSpan` on `turboModuleContextIntegration`.
Expand Down
107 changes: 103 additions & 4 deletions packages/core/src/js/integrations/expoupdateslistener.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { addBreadcrumb, debug, type Integration, type SeverityLevel } from '@sentry/core';
import {
addBreadcrumb,
debug,
type Integration,
type SeverityLevel,
type Span,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
startInactiveSpan,
} from '@sentry/core';

import type { ReactNativeClient } from '../client';

import { SPAN_ORIGIN_AUTO_EXPO_UPDATES } from '../tracing/origin';
import { isExpo, isExpoGo } from '../utils/environment';

const INTEGRATION_NAME = 'ExpoUpdatesListener';
Expand Down Expand Up @@ -113,11 +123,13 @@ const STATE_TRANSITIONS: StateTransition[] = [

/**
* Listens to Expo Updates native state machine changes and records
* breadcrumbs for meaningful transitions such as checking for updates,
* downloading updates, errors, rollbacks, and restarts.
* breadcrumbs and spans for meaningful transitions such as checking
* for updates, downloading updates, errors, rollbacks, and restarts.
*/
export const expoUpdatesListenerIntegration = (): Integration => {
let subscription: UpdatesStateChangeSubscription | undefined;
let checkSpan: Span | undefined;
let downloadSpan: Span | undefined;

function setup(client: ReactNativeClient): void {
client.on('afterInit', () => {
Expand All @@ -131,16 +143,23 @@ export const expoUpdatesListenerIntegration = (): Integration => {
return;
}

// Remove any previous subscription to prevent duplicate breadcrumbs
// Clean up any previous state to prevent duplicates
// if Sentry.init() is called multiple times.
subscription?.remove();
endSpanAsCancelled(checkSpan);
checkSpan = undefined;
endSpanAsCancelled(downloadSpan);
downloadSpan = undefined;

// Seed with the current state so that the first event does not
// generate spurious breadcrumbs for already-truthy fields.
let previousContext: Partial<UpdatesNativeStateMachineContext> = expoUpdates.latestContext ?? {};

subscription = expoUpdates.addUpdatesStateChangeListener((event: UpdatesNativeStateChangeEvent) => {
const ctx = event.context;
const spanResult = handleSpanLifecycle(previousContext, ctx, checkSpan, downloadSpan);
checkSpan = spanResult.checkSpan;
downloadSpan = spanResult.downloadSpan;
handleStateChange(previousContext, ctx);
previousContext = ctx;
});
Expand All @@ -149,6 +168,10 @@ export const expoUpdatesListenerIntegration = (): Integration => {
client.on('close', () => {
subscription?.remove();
subscription = undefined;
endSpanAsCancelled(checkSpan);
checkSpan = undefined;
endSpanAsCancelled(downloadSpan);
downloadSpan = undefined;
});
}

Expand All @@ -158,6 +181,82 @@ export const expoUpdatesListenerIntegration = (): Integration => {
};
};

function endSpanAsCancelled(span: Span | undefined): void {
if (span) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' });
span.end();
}
}

/**
* Manages span lifecycle for check and download operations.
* Starts spans when operations begin, ends them when they complete or fail.
*
* @internal Exposed for testing purposes
*/
export function handleSpanLifecycle(
previous: Partial<UpdatesNativeStateMachineContext>,
current: UpdatesNativeStateMachineContext,
currentCheckSpan: Span | undefined,
currentDownloadSpan: Span | undefined,
): { checkSpan: Span | undefined; downloadSpan: Span | undefined } {
let checkSpan = currentCheckSpan;
let downloadSpan = currentDownloadSpan;

if (!previous.isChecking && current.isChecking) {
checkSpan = startInactiveSpan({
name: 'expo-updates check',
op: 'app.update.check',
forceTransaction: true,
attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_UPDATES },
});
Comment thread
antonis marked this conversation as resolved.
} else if (previous.isChecking && !current.isChecking && checkSpan) {
endSpanWithResult(
checkSpan,
current.checkError,
'check_error',
current.isUpdateAvailable ? current.latestManifest?.id : undefined,
);
checkSpan = undefined;
}

if (!previous.isDownloading && current.isDownloading) {
downloadSpan = startInactiveSpan({
name: 'expo-updates download',
op: 'app.update.download',
forceTransaction: true,
attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_UPDATES },
});
} else if (previous.isDownloading && !current.isDownloading && downloadSpan) {
endSpanWithResult(
downloadSpan,
current.downloadError,
'download_error',
current.isUpdatePending ? current.downloadedManifest?.id : undefined,
);
downloadSpan = undefined;
}

return { checkSpan, downloadSpan };
}

function endSpanWithResult(
span: Span,
error: Error | undefined,
fallbackMessage: string,
updateId: string | undefined,
): void {
if (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message || fallbackMessage });
} else {
span.setStatus({ code: SPAN_STATUS_OK });
if (updateId) {
span.setAttribute('expo.update.id', updateId);
}
}
span.end();
}

/**
* Compares previous and current state machine contexts and emits
* breadcrumbs for meaningful transitions (falsyโ†’truthy).
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/tracing/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch'
export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION = 'auto.navigation.expo_router';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE = 'auto.resource.expo_image';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET = 'auto.resource.expo_asset';

export const SPAN_ORIGIN_AUTO_EXPO_UPDATES = 'auto.expo.updates';
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { TransactionEvent } from '@sentry/core';

import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';

import { handleSpanLifecycle } from '../../src/js/integrations/expoupdateslistener';
import { SPAN_ORIGIN_AUTO_EXPO_UPDATES } from '../../src/js/tracing/origin';
import { setupTestClient } from '../mocks/client';

jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper'));

// No @sentry/core mock โ€” uses real startInactiveSpan to verify
// spans flow through the SDK pipeline as real transactions.

describe('ExpoUpdatesListener Integration (real spans)', () => {
const baseContext = {
isChecking: false,
isDownloading: false,
isUpdateAvailable: false,
isUpdatePending: false,
isRestarting: false,
};

afterEach(() => {
getCurrentScope().clear();
getIsolationScope().clear();
getGlobalScope().clear();
});

it('check span produces a transaction with correct op, origin, and ok status', () => {
const client = setupTestClient({ tracesSampleRate: 1.0 });

const { checkSpan } = handleSpanLifecycle(
{ ...baseContext },
{ ...baseContext, isChecking: true },
undefined,
undefined,
);

handleSpanLifecycle(
{ ...baseContext, isChecking: true },
{ ...baseContext, isChecking: false, isUpdateAvailable: true, latestManifest: { id: 'update-abc' } },
checkSpan,
undefined,
);

const event = client.event as TransactionEvent | undefined;
expect(event).toBeDefined();
expect(event?.contexts?.trace?.op).toBe('app.update.check');
expect(event?.contexts?.trace?.status).toBe('ok');
expect(event?.contexts?.trace?.origin).toBe(SPAN_ORIGIN_AUTO_EXPO_UPDATES);
expect(event?.contexts?.trace?.data?.['expo.update.id']).toBe('update-abc');
});

it('check span produces a transaction with error status on failure', () => {
const client = setupTestClient({ tracesSampleRate: 1.0 });

const { checkSpan } = handleSpanLifecycle(
{ ...baseContext },
{ ...baseContext, isChecking: true },
undefined,
undefined,
);

handleSpanLifecycle(
{ ...baseContext, isChecking: true },
{ ...baseContext, isChecking: false, checkError: new Error('Network failed') },
checkSpan,
undefined,
);

const event = client.event as TransactionEvent | undefined;
expect(event).toBeDefined();
expect(event?.contexts?.trace?.op).toBe('app.update.check');
expect(event?.contexts?.trace?.status).toBe('Network failed');
});

it('download span produces a transaction with correct op and update id', () => {
const client = setupTestClient({ tracesSampleRate: 1.0 });

const { downloadSpan } = handleSpanLifecycle(
{ ...baseContext },
{ ...baseContext, isDownloading: true },
undefined,
undefined,
);

handleSpanLifecycle(
{ ...baseContext, isDownloading: true },
{ ...baseContext, isDownloading: false, isUpdatePending: true, downloadedManifest: { id: 'dl-xyz' } },
undefined,
downloadSpan,
);

const event = client.event as TransactionEvent | undefined;
expect(event).toBeDefined();
expect(event?.contexts?.trace?.op).toBe('app.update.download');
expect(event?.contexts?.trace?.status).toBe('ok');
expect(event?.contexts?.trace?.origin).toBe(SPAN_ORIGIN_AUTO_EXPO_UPDATES);
expect(event?.contexts?.trace?.data?.['expo.update.id']).toBe('dl-xyz');
});

it('full check-then-download lifecycle produces two transactions', () => {
const client = setupTestClient({ tracesSampleRate: 1.0 });

// Start check
let result = handleSpanLifecycle({ ...baseContext }, { ...baseContext, isChecking: true }, undefined, undefined);

// End check with update available
result = handleSpanLifecycle(
{ ...baseContext, isChecking: true },
{ ...baseContext, isUpdateAvailable: true, latestManifest: { id: 'u-1' } },
result.checkSpan,
result.downloadSpan,
);

const checkEvent = client.eventQueue[0] as TransactionEvent | undefined;
expect(checkEvent?.contexts?.trace?.op).toBe('app.update.check');

// Start download
result = handleSpanLifecycle(
{ ...baseContext, isUpdateAvailable: true },
{ ...baseContext, isUpdateAvailable: true, isDownloading: true },
result.checkSpan,
result.downloadSpan,
);

// End download
handleSpanLifecycle(
{ ...baseContext, isUpdateAvailable: true, isDownloading: true },
{ ...baseContext, isUpdateAvailable: true, isUpdatePending: true, downloadedManifest: { id: 'u-1' } },
result.checkSpan,
result.downloadSpan,
);

expect(client.eventQueue).toHaveLength(2);

const downloadEvent = client.eventQueue[1] as TransactionEvent | undefined;
expect(downloadEvent?.contexts?.trace?.op).toBe('app.update.download');
expect(downloadEvent?.contexts?.trace?.status).toBe('ok');
});
});
Loading
Loading