Skip to content

Commit 9725d40

Browse files
authored
Auto tracing for expo-image and expo-asset (#5718)
* Wrappers + auto tracing for expo-image and expo-assets * Changelog entry * Fixes * Try catch fixes * Moving traceAsyncOperation to a separate function * removing Span from there * smallish fix + an AI generated test * lint fix * Fix for position of the entry in CHANGELOG.md * Fixes
1 parent 700ed6c commit 9725d40

File tree

9 files changed

+945
-2
lines changed

9 files changed

+945
-2
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010

1111
### Features
1212

13+
- Add `wrapExpoImage` and `wrapExpoAsset` for Expo performance monitoring ([#5427](https://github.com/getsentry/sentry-react-native/issues/5427))
14+
- `wrapExpoImage` instruments `Image.prefetch` and `Image.loadAsync` from `expo-image`
15+
- `wrapExpoAsset` instruments `Asset.loadAsync` from `expo-asset`
16+
```js
17+
import { Image } from 'expo-image';
18+
import { Asset } from 'expo-asset';
19+
import * as Sentry from '@sentry/react-native';
20+
21+
Sentry.wrapExpoImage(Image);
22+
Sentry.wrapExpoAsset(Asset);
23+
```
1324
- Adds tags with Expo Updates context variables to make them searchable and filterable ([#5788](https://github.com/getsentry/sentry-react-native/pull/5788))
1425

1526
## 8.3.0

packages/core/src/js/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ export {
9393
createTimeToFullDisplay,
9494
createTimeToInitialDisplay,
9595
wrapExpoRouter,
96+
wrapExpoImage,
97+
wrapExpoAsset,
9698
} from './tracing';
9799

98-
export type { TimeToDisplayProps, ExpoRouter } from './tracing';
100+
export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset } from './tracing';
99101

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin';
2+
import { describeUrl, traceAsyncOperation } from './utils';
3+
4+
/**
5+
* Internal interface for expo-asset's Asset instance.
6+
* We define this to avoid a hard dependency on expo-asset.
7+
*/
8+
export interface ExpoAssetInstance {
9+
name: string;
10+
type: string;
11+
hash: string | null;
12+
uri: string;
13+
localUri: string | null;
14+
width: number | null;
15+
height: number | null;
16+
downloaded: boolean;
17+
downloadAsync(): Promise<ExpoAssetInstance>;
18+
}
19+
20+
/**
21+
* Represents the expo-asset `Asset` class with its static methods.
22+
* We only describe the methods that we instrument.
23+
*/
24+
export interface ExpoAsset {
25+
loadAsync(moduleId: number | number[] | string | string[]): Promise<ExpoAssetInstance[]>;
26+
fromModule(virtualAssetModule: number | string): ExpoAssetInstance;
27+
}
28+
29+
/**
30+
* Wraps expo-asset's `Asset` class to add automated performance monitoring.
31+
*
32+
* This function instruments `Asset.loadAsync` static method
33+
* to create performance spans that measure how long asset loading takes.
34+
*
35+
* @param assetClass - The `Asset` class from `expo-asset`
36+
* @returns The same class with instrumented static methods
37+
*
38+
* @example
39+
* ```typescript
40+
* import { Asset } from 'expo-asset';
41+
* import * as Sentry from '@sentry/react-native';
42+
*
43+
* Sentry.wrapExpoAsset(Asset);
44+
* ```
45+
*/
46+
export function wrapExpoAsset<T extends ExpoAsset>(assetClass: T): T {
47+
if (!assetClass) {
48+
return assetClass;
49+
}
50+
51+
if ((assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) {
52+
return assetClass;
53+
}
54+
55+
wrapLoadAsync(assetClass);
56+
57+
(assetClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true;
58+
59+
return assetClass;
60+
}
61+
62+
function wrapLoadAsync<T extends ExpoAsset>(assetClass: T): void {
63+
if (!assetClass.loadAsync) {
64+
return;
65+
}
66+
67+
const originalLoadAsync = assetClass.loadAsync.bind(assetClass);
68+
69+
assetClass.loadAsync = ((moduleId: number | number[] | string | string[]): Promise<ExpoAssetInstance[]> => {
70+
const moduleIds = Array.isArray(moduleId) ? moduleId : [moduleId];
71+
const assetCount = moduleIds.length;
72+
const description = describeModuleIds(moduleIds);
73+
74+
return traceAsyncOperation(
75+
{
76+
op: 'resource.asset',
77+
name: `Asset load ${description}`,
78+
attributes: {
79+
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET,
80+
'asset.count': assetCount,
81+
},
82+
},
83+
() => originalLoadAsync(moduleId),
84+
);
85+
}) as T['loadAsync'];
86+
}
87+
88+
function describeModuleIds(moduleIds: (number | string)[]): string {
89+
if (moduleIds.length === 1) {
90+
const id = moduleIds[0];
91+
if (typeof id === 'string') {
92+
return describeUrl(id);
93+
}
94+
return `asset #${id}`;
95+
}
96+
return `${moduleIds.length} assets`;
97+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
2+
import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin';
3+
import { describeUrl, sanitizeUrl, traceAsyncOperation } from './utils';
4+
5+
/**
6+
* Internal interface for expo-image's ImageSource.
7+
* We define this to avoid a hard dependency on expo-image.
8+
*/
9+
interface ExpoImageSource {
10+
uri?: string;
11+
headers?: Record<string, string>;
12+
width?: number | null;
13+
height?: number | null;
14+
cacheKey?: string;
15+
}
16+
17+
/**
18+
* Internal interface for expo-image's ImageLoadOptions.
19+
* We define this to avoid a hard dependency on expo-image.
20+
*/
21+
interface ExpoImageLoadOptions {
22+
maxWidth?: number;
23+
maxHeight?: number;
24+
onError?(error: Error, retry: () => void): void;
25+
}
26+
27+
/**
28+
* Internal interface for expo-image's ImageRef.
29+
* We define this to avoid a hard dependency on expo-image.
30+
*/
31+
interface ExpoImageRef {
32+
readonly width: number;
33+
readonly height: number;
34+
readonly scale: number;
35+
readonly mediaType: string | null;
36+
readonly isAnimated?: boolean;
37+
}
38+
39+
/**
40+
* Represents the expo-image `Image` class with its static methods.
41+
* We only describe the methods that we instrument.
42+
*/
43+
export interface ExpoImage {
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
prefetch(urls: string | string[], cachePolicyOrOptions?: any): Promise<boolean>;
46+
loadAsync(source: ExpoImageSource | string | number, options?: ExpoImageLoadOptions): Promise<ExpoImageRef>;
47+
clearMemoryCache?(): Promise<boolean>;
48+
clearDiskCache?(): Promise<boolean>;
49+
}
50+
51+
/**
52+
* Wraps expo-image's `Image` class to add automated performance monitoring.
53+
*
54+
* This function instruments `Image.prefetch` and `Image.loadAsync` static methods
55+
* to create performance spans that measure how long image prefetching and loading take.
56+
*
57+
* @param imageClass - The `Image` class from `expo-image`
58+
* @returns The same class with instrumented static methods
59+
*
60+
* @example
61+
* ```typescript
62+
* import { Image } from 'expo-image';
63+
* import * as Sentry from '@sentry/react-native';
64+
*
65+
* Sentry.wrapExpoImage(Image);
66+
* ```
67+
*/
68+
export function wrapExpoImage<T extends ExpoImage>(imageClass: T): T {
69+
if (!imageClass) {
70+
return imageClass;
71+
}
72+
73+
if ((imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped) {
74+
return imageClass;
75+
}
76+
77+
wrapPrefetch(imageClass);
78+
wrapLoadAsync(imageClass);
79+
80+
(imageClass as T & { __sentryWrapped?: boolean }).__sentryWrapped = true;
81+
82+
return imageClass;
83+
}
84+
85+
function wrapPrefetch<T extends ExpoImage>(imageClass: T): void {
86+
if (!imageClass.prefetch) {
87+
return;
88+
}
89+
90+
const originalPrefetch = imageClass.prefetch.bind(imageClass);
91+
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
imageClass.prefetch = ((urls: string | string[], cachePolicyOrOptions?: any): Promise<boolean> => {
94+
const urlList = Array.isArray(urls) ? urls : [urls];
95+
const urlCount = urlList.length;
96+
const firstUrl = urlList[0] || 'unknown';
97+
const description = urlCount === 1 ? describeUrl(firstUrl) : `${urlCount} images`;
98+
99+
const span = startInactiveSpan({
100+
op: 'resource.image.prefetch',
101+
name: `Image prefetch ${description}`,
102+
attributes: {
103+
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
104+
'image.url_count': urlCount,
105+
...(urlCount === 1 ? { 'image.url': sanitizeUrl(firstUrl) } : undefined),
106+
},
107+
});
108+
109+
try {
110+
return originalPrefetch(urls, cachePolicyOrOptions)
111+
.then(result => {
112+
if (result) {
113+
span?.setStatus({ code: SPAN_STATUS_OK });
114+
} else {
115+
span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'prefetch_failed' });
116+
}
117+
span?.end();
118+
return result;
119+
})
120+
.catch((error: unknown) => {
121+
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
122+
span?.end();
123+
throw error;
124+
});
125+
} catch (error) {
126+
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
127+
span?.end();
128+
throw error;
129+
}
130+
}) as T['prefetch'];
131+
}
132+
133+
function wrapLoadAsync<T extends ExpoImage>(imageClass: T): void {
134+
if (!imageClass.loadAsync) {
135+
return;
136+
}
137+
138+
const originalLoadAsync = imageClass.loadAsync.bind(imageClass);
139+
140+
imageClass.loadAsync = ((
141+
source: ExpoImageSource | string | number,
142+
options?: ExpoImageLoadOptions,
143+
): Promise<ExpoImageRef> => {
144+
const description = describeSource(source);
145+
146+
const imageUrl =
147+
typeof source === 'string' ? source : typeof source === 'object' && source.uri ? source.uri : undefined;
148+
149+
return traceAsyncOperation(
150+
{
151+
op: 'resource.image.load',
152+
name: `Image load ${description}`,
153+
attributes: {
154+
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
155+
...(imageUrl ? { 'image.url': sanitizeUrl(imageUrl) } : undefined),
156+
},
157+
},
158+
() => originalLoadAsync(source, options),
159+
);
160+
}) as T['loadAsync'];
161+
}
162+
163+
function describeSource(source: ExpoImageSource | string | number): string {
164+
if (typeof source === 'number') {
165+
return `asset #${source}`;
166+
}
167+
if (typeof source === 'string') {
168+
return describeUrl(source);
169+
}
170+
if (source.uri) {
171+
return describeUrl(source.uri);
172+
}
173+
return 'unknown source';
174+
}

packages/core/src/js/tracing/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export { reactNativeNavigationIntegration } from './reactnativenavigation';
1212
export { wrapExpoRouter } from './expoRouter';
1313
export type { ExpoRouter } from './expoRouter';
1414

15+
export { wrapExpoImage } from './expoImage';
16+
export type { ExpoImage } from './expoImage';
17+
18+
export { wrapExpoAsset } from './expoAsset';
19+
export type { ExpoAsset } from './expoAsset';
20+
1521
export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span';
1622

1723
export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types';

packages/core/src/js/tracing/origin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ export const SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY = 'auto.ui.time_to_display';
1212
export const SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY = 'manual.ui.time_to_display';
1313

1414
export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch';
15+
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE = 'auto.resource.expo_image';
16+
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET = 'auto.resource.expo_asset';

0 commit comments

Comments
 (0)