Skip to content
Draft
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
4 changes: 3 additions & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ export {
createTimeToFullDisplay,
createTimeToInitialDisplay,
wrapExpoRouter,
wrapExpoImage,
wrapExpoAsset,
} from './tracing';

export type { TimeToDisplayProps, ExpoRouter } from './tracing';
export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset, ExpoAssetInstance } from './tracing';

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

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

/**
* 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);

const span = startInactiveSpan({
op: 'resource.asset',
name: `Asset load ${description}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET,
'asset.count': assetCount,
},
});

return originalLoadAsync(moduleId)
.then(result => {
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
})
.catch((error: unknown) => {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
throw error;
});
}) 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`;
}

function describeUrl(url: string): string {
try {
// Remove query string and fragment
const withoutQuery = url.split('?')[0] || url;
const withoutFragment = withoutQuery.split('#')[0] || withoutQuery;
const filename = withoutFragment.split('/').pop();
return filename || url;
} catch {
return url;
}
}
184 changes: 184 additions & 0 deletions packages/core/src/js/tracing/expoImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin';

/**
* 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),
},
});

return originalPrefetch(urls, cachePolicyOrOptions)
.then(result => {
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
})
.catch((error: unknown) => {
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;

const span = startInactiveSpan({
op: 'resource.image.load',
name: `Image load ${description}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
...(imageUrl ? { 'image.url': imageUrl } : undefined),
},
});

return originalLoadAsync(source, options)
.then(result => {
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
})
.catch((error: unknown) => {
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
throw error;
});
}) as T['loadAsync'];
}

function describeUrl(url: string): string {
try {
// Remove query string and fragment
const withoutQuery = url.split('?')[0] || url;
const withoutFragment = withoutQuery.split('#')[0] || withoutQuery;
const filename = withoutFragment.split('/').pop();
return filename || url;
} catch {
return url;
}
}

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