Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@

### 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);
```
- Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788))

## 8.3.0
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 } from './tracing';

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

Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/js/tracing/expoAsset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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, sanitizeUrl, 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': sanitizeUrl(firstUrl) } : undefined),
},
});

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': sanitizeUrl(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 } 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