Skip to content

Commit 2b27409

Browse files
committed
Wrappers + auto tracing for expo-image and expo-assets
1 parent b05f8ad commit 2b27409

7 files changed

Lines changed: 777 additions & 1 deletion

File tree

packages/core/src/js/index.ts

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

97-
export type { TimeToDisplayProps, ExpoRouter } from './tracing';
99+
export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset, ExpoAssetInstance } from './tracing';
98100

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

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

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, ExpoAssetInstance } 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)