Skip to content
Merged
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@
- Fix AGP Artifacts API conflict caused by eager task realization in `sentry.gradle` ([#5714](https://github.com/getsentry/sentry-react-native/pull/5714))
- Fix Android crash on app launch caused by version mismatch between Sentry Android SDK and Sentry Android Gradle Plugin ([#5726](https://github.com/getsentry/sentry-react-native/pull/5726))

### Features

- Add `wrapExpoImage` and `wrapExpoAsset` for Expo performance monitoring ([#5427](https://github.com/getsentry/sentry-react-native/issues/5427))
- `wrapExpoImage` instruments `Image.prefetch` and `Image.loadAsync` from `expo-image`
- `wrapExpoAsset` instruments `Asset.loadAsync` from `expo-asset`
```js
import { Image } from 'expo-image';
import { Asset } from 'expo-asset';
import * as Sentry from '@sentry/react-native';

Sentry.wrapExpoImage(Image);
Sentry.wrapExpoAsset(Asset);
```

### Dependencies

- Bump Android SDK from v8.32.0 to v8.33.0 ([#5684](https://github.com/getsentry/sentry-react-native/pull/5684))
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ export {
createTimeToFullDisplay,
createTimeToInitialDisplay,
wrapExpoRouter,
wrapExpoImage,
wrapExpoAsset,
} from './tracing';

export type { TimeToDisplayProps, ExpoRouter } from './tracing';
export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset, ExpoAssetInstance } from './tracing';
Comment thread
alwx marked this conversation as resolved.
Outdated

export { Mask, Unmask } from './replay/CustomMask';

Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/js/tracing/expoAsset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin';
import { describeUrl, traceAsyncOperation } from './utils';

/**
* Internal interface for expo-asset's Asset instance.
* We define this to avoid a hard dependency on expo-asset.
*/
export interface ExpoAssetInstance {
name: string;
type: string;
hash: string | null;
uri: string;
localUri: string | null;
width: number | null;
height: number | null;
downloaded: boolean;
downloadAsync(): Promise<ExpoAssetInstance>;
}

/**
* Represents the expo-asset `Asset` class with its static methods.
* We only describe the methods that we instrument.
*/
export interface ExpoAsset {
loadAsync(moduleId: number | number[] | string | string[]): Promise<ExpoAssetInstance[]>;
fromModule(virtualAssetModule: number | string): ExpoAssetInstance;
}

/**
* Wraps expo-asset's `Asset` class to add automated performance monitoring.
*
* This function instruments `Asset.loadAsync` static method
* to create performance spans that measure how long asset loading takes.
*
* @param assetClass - The `Asset` class from `expo-asset`
* @returns The same class with instrumented static methods
*
* @example
* ```typescript
* import { Asset } from 'expo-asset';
* import * as Sentry from '@sentry/react-native';
*
* Sentry.wrapExpoAsset(Asset);
* ```
*/
export function wrapExpoAsset<T extends ExpoAsset>(assetClass: T): T {
if (!assetClass) {
return assetClass;
}

if ((assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) {
return assetClass;
}

wrapLoadAsync(assetClass);

(assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true;

return assetClass;
}

function wrapLoadAsync<T extends ExpoAsset>(assetClass: T): void {
if (!assetClass.loadAsync) {
return;
}

const originalLoadAsync = assetClass.loadAsync.bind(assetClass);

assetClass.loadAsync = ((moduleId: number | number[] | string | string[]): Promise<ExpoAssetInstance[]> => {
const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
const assetCount = moduleIds.length;
const description = describeModuleIds(moduleIds);

return traceAsyncOperation(
{
op: 'resource.asset',
name: `Asset load ${description}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET,
'asset.count': assetCount,
},
},
() => originalLoadAsync(moduleId),
);
}) as T['loadAsync'];
}

function describeModuleIds(moduleIds: (number | string)[]): string {
if (moduleIds.length === 1) {
const id = moduleIds[0];
if (typeof id === 'string') {
return describeUrl(id);
}
return `asset #${id}`;
}
return `${moduleIds.length} assets`;
}


174 changes: 174 additions & 0 deletions packages/core/src/js/tracing/expoImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin';
import { describeUrl, traceAsyncOperation } from './utils';

/**
* Internal interface for expo-image's ImageSource.
* We define this to avoid a hard dependency on expo-image.
*/
interface ExpoImageSource {
uri?: string;
headers?: Record<string, string>;
width?: number | null;
height?: number | null;
cacheKey?: string;
}

/**
* Internal interface for expo-image's ImageLoadOptions.
* We define this to avoid a hard dependency on expo-image.
*/
interface ExpoImageLoadOptions {
maxWidth?: number;
maxHeight?: number;
onError?(error: Error, retry: () => void): void;
}

/**
* Internal interface for expo-image's ImageRef.
* We define this to avoid a hard dependency on expo-image.
*/
interface ExpoImageRef {
readonly width: number;
readonly height: number;
readonly scale: number;
readonly mediaType: string | null;
readonly isAnimated?: boolean;
}

/**
* Represents the expo-image `Image` class with its static methods.
* We only describe the methods that we instrument.
*/
export interface ExpoImage {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prefetch(urls: string | string[], cachePolicyOrOptions?: any): Promise<boolean>;
loadAsync(source: ExpoImageSource | string | number, options?: ExpoImageLoadOptions): Promise<ExpoImageRef>;
clearMemoryCache?(): Promise<boolean>;
clearDiskCache?(): Promise<boolean>;
}

/**
* Wraps expo-image's `Image` class to add automated performance monitoring.
*
* This function instruments `Image.prefetch` and `Image.loadAsync` static methods
* to create performance spans that measure how long image prefetching and loading take.
*
* @param imageClass - The `Image` class from `expo-image`
* @returns The same class with instrumented static methods
*
* @example
* ```typescript
* import { Image } from 'expo-image';
* import * as Sentry from '@sentry/react-native';
*
* Sentry.wrapExpoImage(Image);
* ```
*/
export function wrapExpoImage<T extends ExpoImage>(imageClass: T): T {
if (!imageClass) {
return imageClass;
}

if ((imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) {
return imageClass;
}

wrapPrefetch(imageClass);
wrapLoadAsync(imageClass);

(imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true;

return imageClass;
}

function wrapPrefetch<T extends ExpoImage>(imageClass: T): void {
if (!imageClass.prefetch) {
return;
}

const originalPrefetch = imageClass.prefetch.bind(imageClass);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
imageClass.prefetch = ((urls: string | string[], cachePolicyOrOptions?: any): Promise<boolean> => {
const urlList = Array.isArray(urls) ? urls : [urls];
const urlCount = urlList.length;
const firstUrl = urlList[0] || 'unknown';
const description = urlCount === 1 ? describeUrl(firstUrl) : `${urlCount} images`;

const span = startInactiveSpan({
op: 'resource.image.prefetch',
name: `Image prefetch ${description}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
'image.url_count': urlCount,
...(urlCount === 1 ? { 'image.url': firstUrl } : undefined),
Comment thread
alwx marked this conversation as resolved.
Outdated
},
});

try {
return originalPrefetch(urls, cachePolicyOrOptions)
.then(result => {
if (result) {
span?.setStatus({ code: SPAN_STATUS_OK });
} else {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'prefetch_failed' });
}
span?.end();
return result;
})
.catch((error: unknown) => {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
throw error;
});
} catch (error) {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
throw error;
}
}) as T['prefetch'];
}

function wrapLoadAsync<T extends ExpoImage>(imageClass: T): void {
if (!imageClass.loadAsync) {
return;
}

const originalLoadAsync = imageClass.loadAsync.bind(imageClass);

imageClass.loadAsync = ((
source: ExpoImageSource | string | number,
options?: ExpoImageLoadOptions,
): Promise<ExpoImageRef> => {
const description = describeSource(source);

const imageUrl =
typeof source === 'string' ? source : typeof source === 'object' && source.uri ? source.uri : undefined;

return traceAsyncOperation(
{
op: 'resource.image.load',
name: `Image load ${description}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
...(imageUrl ? { 'image.url': imageUrl } : undefined),
},
},
() => originalLoadAsync(source, options),
);
}) as T['loadAsync'];
}

function describeSource(source: ExpoImageSource | string | number): string {
if (typeof source === 'number') {
return `asset #${source}`;
}
if (typeof source === 'string') {
return describeUrl(source);
}
if (source.uri) {
return describeUrl(source.uri);
}
return 'unknown source';
}
6 changes: 6 additions & 0 deletions packages/core/src/js/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export { reactNativeNavigationIntegration } from './reactnativenavigation';
export { wrapExpoRouter } from './expoRouter';
export type { ExpoRouter } from './expoRouter';

export { wrapExpoImage } from './expoImage';
export type { ExpoImage } from './expoImage';

export { wrapExpoAsset } from './expoAsset';
export type { ExpoAsset, ExpoAssetInstance } from './expoAsset';

export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span';

export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types';
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 @@ -12,3 +12,5 @@ export const SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY = 'auto.ui.time_to_display';
export const SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY = 'manual.ui.time_to_display';

export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE = 'auto.resource.expo_image';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET = 'auto.resource.expo_asset';
Loading
Loading