From 2b27409d4978cef8bf4445e250c2f8ee88c702fd Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 25 Feb 2026 08:48:18 +0100 Subject: [PATCH 01/10] Wrappers + auto tracing for expo-image and expo-assets --- packages/core/src/js/index.ts | 4 +- packages/core/src/js/tracing/expoAsset.ts | 118 +++++++++ packages/core/src/js/tracing/expoImage.ts | 184 +++++++++++++ packages/core/src/js/tracing/index.ts | 6 + packages/core/src/js/tracing/origin.ts | 2 + packages/core/test/tracing/expoAsset.test.ts | 203 +++++++++++++++ packages/core/test/tracing/expoImage.test.ts | 261 +++++++++++++++++++ 7 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/js/tracing/expoAsset.ts create mode 100644 packages/core/src/js/tracing/expoImage.ts create mode 100644 packages/core/test/tracing/expoAsset.test.ts create mode 100644 packages/core/test/tracing/expoImage.test.ts diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 19ba331003..6798471285 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -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'; diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts new file mode 100644 index 0000000000..9030237af9 --- /dev/null +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -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; +} + +/** + * 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; + 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(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(assetClass: T): void { + if (!assetClass.loadAsync) { + return; + } + + const originalLoadAsync = assetClass.loadAsync.bind(assetClass); + + assetClass.loadAsync = ((moduleId: number | number[] | string | string[]): Promise => { + 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; + } +} diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts new file mode 100644 index 0000000000..f9a10c2268 --- /dev/null +++ b/packages/core/src/js/tracing/expoImage.ts @@ -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; + 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; + loadAsync(source: ExpoImageSource | string | number, options?: ExpoImageLoadOptions): Promise; + clearMemoryCache?(): Promise; + clearDiskCache?(): Promise; +} + +/** + * 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(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(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 => { + 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(imageClass: T): void { + if (!imageClass.loadAsync) { + return; + } + + const originalLoadAsync = imageClass.loadAsync.bind(imageClass); + + imageClass.loadAsync = (( + source: ExpoImageSource | string | number, + options?: ExpoImageLoadOptions, + ): Promise => { + 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'; +} diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index 4a0e3f27d2..9e1db904a4 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -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'; diff --git a/packages/core/src/js/tracing/origin.ts b/packages/core/src/js/tracing/origin.ts index 858dbfa2cc..3b2fd4ca32 100644 --- a/packages/core/src/js/tracing/origin.ts +++ b/packages/core/src/js/tracing/origin.ts @@ -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'; diff --git a/packages/core/test/tracing/expoAsset.test.ts b/packages/core/test/tracing/expoAsset.test.ts new file mode 100644 index 0000000000..f9904849ff --- /dev/null +++ b/packages/core/test/tracing/expoAsset.test.ts @@ -0,0 +1,203 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoAsset, wrapExpoAsset } from '../../src/js/tracing'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from '../../src/js/tracing/origin'; + +const mockStartInactiveSpan = jest.fn(); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + }; +}); + +describe('wrapExpoAsset', () => { + let mockSpan: { + setStatus: jest.Mock; + end: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpan = { + setStatus: jest.fn(), + end: jest.fn(), + }; + mockStartInactiveSpan.mockReturnValue(mockSpan); + }); + + it('returns the class unchanged if null or undefined', () => { + expect(wrapExpoAsset(null as unknown as ExpoAsset)).toBeNull(); + expect(wrapExpoAsset(undefined as unknown as ExpoAsset)).toBeUndefined(); + }); + + it('does not double-wrap the same class', () => { + const assetClass = { + loadAsync: jest.fn().mockResolvedValue([]), + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + const wrapped1 = wrapExpoAsset(assetClass); + const wrapped2 = wrapExpoAsset(wrapped1); + + expect(wrapped1).toBe(wrapped2); + }); + + describe('loadAsync', () => { + it('creates a span for loading a single numeric module ID', async () => { + const mockAsset = { name: 'icon', type: 'png', downloaded: true }; + const mockLoadAsync = jest.fn().mockResolvedValue([mockAsset]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + const result = await assetClass.loadAsync(42); + + expect(result).toEqual([mockAsset]); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load asset #42', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 1, + }, + }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for loading a single string URL', async () => { + const mockAsset = { name: 'photo', type: 'jpg', downloaded: true }; + const mockLoadAsync = jest.fn().mockResolvedValue([mockAsset]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync('https://example.com/photo.jpg'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load photo.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 1, + }, + }); + }); + + it('creates a span for loading multiple numeric module IDs', async () => { + const mockAssets = [ + { name: 'icon', type: 'png', downloaded: true }, + { name: 'splash', type: 'png', downloaded: true }, + { name: 'logo', type: 'svg', downloaded: true }, + ]; + const mockLoadAsync = jest.fn().mockResolvedValue(mockAssets); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync([1, 2, 3]); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load 3 assets', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 3, + }, + }); + }); + + it('creates a span for loading multiple string URLs', async () => { + const mockAssets = [ + { name: 'a', type: 'png', downloaded: true }, + { name: 'b', type: 'png', downloaded: true }, + ]; + const mockLoadAsync = jest.fn().mockResolvedValue(mockAssets); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync(['https://example.com/a.png', 'https://example.com/b.png']); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.asset', + name: 'Asset load 2 assets', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': 2, + }, + }); + }); + + it('handles loadAsync failure', async () => { + const error = new Error('Asset not found'); + const mockLoadAsync = jest.fn().mockRejectedValue(error); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + + await expect(assetClass.loadAsync(99)).rejects.toThrow('Asset not found'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Asset not found', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('passes the original moduleId argument through', async () => { + const mockLoadAsync = jest.fn().mockResolvedValue([]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync([10, 20]); + + expect(mockLoadAsync).toHaveBeenCalledWith([10, 20]); + }); + + it('handles non-URL string gracefully', async () => { + const mockLoadAsync = jest.fn().mockResolvedValue([]); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + await assetClass.loadAsync('not-a-url'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Asset load not-a-url', + }), + ); + }); + }); + + it('preserves fromModule method', () => { + const mockFromModule = jest.fn(); + const assetClass = { + loadAsync: jest.fn().mockResolvedValue([]), + fromModule: mockFromModule, + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + + expect(assetClass.fromModule).toBe(mockFromModule); + }); +}); diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts new file mode 100644 index 0000000000..d113bfc5bf --- /dev/null +++ b/packages/core/test/tracing/expoImage.test.ts @@ -0,0 +1,261 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoImage, wrapExpoImage } from '../../src/js/tracing'; +import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from '../../src/js/tracing/origin'; + +const mockStartInactiveSpan = jest.fn(); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + }; +}); + +describe('wrapExpoImage', () => { + let mockSpan: { + setStatus: jest.Mock; + end: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpan = { + setStatus: jest.fn(), + end: jest.fn(), + }; + mockStartInactiveSpan.mockReturnValue(mockSpan); + }); + + it('returns the class unchanged if null or undefined', () => { + expect(wrapExpoImage(null as unknown as ExpoImage)).toBeNull(); + expect(wrapExpoImage(undefined as unknown as ExpoImage)).toBeUndefined(); + }); + + it('does not double-wrap the same class', () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn().mockResolvedValue({}) } as unknown as ExpoImage; + + const wrapped1 = wrapExpoImage(imageClass); + const wrapped2 = wrapExpoImage(wrapped1); + + expect(wrapped1).toBe(wrapped2); + }); + + describe('prefetch', () => { + it('creates a span for single URL prefetch', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com/image.png'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.prefetch', + name: 'Image prefetch image.png', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url_count': 1, + 'image.url': 'https://example.com/image.png', + }, + }); + + expect(mockPrefetch).toHaveBeenCalledWith('https://example.com/image.png', undefined); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for multiple URL prefetch', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const urls = ['https://example.com/a.png', 'https://example.com/b.png', 'https://example.com/c.png']; + await imageClass.prefetch(urls); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.prefetch', + name: 'Image prefetch 3 images', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url_count': 3, + }, + }); + }); + + it('passes cache policy option through', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com/image.png', 'memory-disk'); + + expect(mockPrefetch).toHaveBeenCalledWith('https://example.com/image.png', 'memory-disk'); + }); + + it('handles prefetch failure', async () => { + const error = new Error('Network error'); + const mockPrefetch = jest.fn().mockRejectedValue(error); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + await expect(imageClass.prefetch('https://example.com/image.png')).rejects.toThrow('Network error'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Network error', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('handles URL without path correctly', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://example.com'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Image prefetch'), + }), + ); + }); + + it('handles non-URL string gracefully', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('not-a-url'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Image prefetch not-a-url', + }), + ); + }); + }); + + describe('loadAsync', () => { + it('creates a span for loading by URL string', async () => { + const mockResult = { width: 100, height: 100, scale: 1, mediaType: 'image/png' }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const result = await imageClass.loadAsync('https://example.com/photo.jpg'); + + expect(result).toBe(mockResult); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load photo.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url': 'https://example.com/photo.jpg', + }, + }); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('creates a span for loading by ImageSource object', async () => { + const mockResult = { width: 200, height: 200, scale: 2, mediaType: 'image/jpeg' }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const source = { uri: 'https://example.com/avatar.jpg', width: 200, height: 200 }; + await imageClass.loadAsync(source); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load avatar.jpg', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + 'image.url': 'https://example.com/avatar.jpg', + }, + }); + }); + + it('creates a span for loading by module ID (number)', async () => { + const mockResult = { width: 50, height: 50, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.loadAsync(42); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load asset #42', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + }, + }); + }); + + it('creates a span for ImageSource without uri', async () => { + const mockResult = { width: 10, height: 10, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.loadAsync({ width: 10, height: 10 }); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'resource.image.load', + name: 'Image load unknown source', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + }, + }); + }); + + it('handles loadAsync failure', async () => { + const error = new Error('Load failed'); + const mockLoadAsync = jest.fn().mockRejectedValue(error); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + await expect(imageClass.loadAsync('https://example.com/broken.png')).rejects.toThrow('Load failed'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Load failed', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('passes options through to original loadAsync', async () => { + const mockResult = { width: 100, height: 100, scale: 1, mediaType: null }; + const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + const onError = jest.fn(); + + wrapExpoImage(imageClass); + await imageClass.loadAsync('https://example.com/img.png', { maxWidth: 800, onError }); + + expect(mockLoadAsync).toHaveBeenCalledWith('https://example.com/img.png', { maxWidth: 800, onError }); + }); + }); + + it('preserves other static methods', () => { + const mockClearMemoryCache = jest.fn().mockResolvedValue(true); + const mockClearDiskCache = jest.fn().mockResolvedValue(true); + const imageClass = { + prefetch: jest.fn().mockResolvedValue(true), + loadAsync: jest.fn().mockResolvedValue({}), + clearMemoryCache: mockClearMemoryCache, + clearDiskCache: mockClearDiskCache, + } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + expect((imageClass as ExpoImage & { clearMemoryCache: jest.Mock }).clearMemoryCache).toBe(mockClearMemoryCache); + expect((imageClass as ExpoImage & { clearDiskCache: jest.Mock }).clearDiskCache).toBe(mockClearDiskCache); + }); +}); From 47436b8af0fd302bd1a9993672db6bf292509e50 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 5 Mar 2026 10:59:06 +0100 Subject: [PATCH 02/10] Changelog entry --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05485e4d6c..f8509a92ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ ## Unreleased +### 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)) From 033ca74c657e4ae010045996c0366d73334c66ac Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 13:21:03 +0100 Subject: [PATCH 03/10] Fixes --- packages/core/src/js/tracing/expoAsset.ts | 13 ++----------- packages/core/src/js/tracing/expoImage.ts | 19 ++++++------------- packages/core/src/js/tracing/utils.ts | 16 ++++++++++++++++ packages/core/test/tracing/expoImage.test.ts | 15 +++++++++++++++ 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts index 9030237af9..5b3ddac22e 100644 --- a/packages/core/src/js/tracing/expoAsset.ts +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -1,5 +1,6 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin'; +import { describeUrl } from './utils'; /** * Internal interface for expo-asset's Asset instance. @@ -105,14 +106,4 @@ function describeModuleIds(moduleIds: (number | string)[]): string { 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; - } -} + diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts index f9a10c2268..a1ef3d797e 100644 --- a/packages/core/src/js/tracing/expoImage.ts +++ b/packages/core/src/js/tracing/expoImage.ts @@ -1,5 +1,6 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin'; +import { describeUrl } from './utils'; /** * Internal interface for expo-image's ImageSource. @@ -107,7 +108,11 @@ function wrapPrefetch(imageClass: T): void { return originalPrefetch(urls, cachePolicyOrOptions) .then(result => { - span?.setStatus({ code: SPAN_STATUS_OK }); + if (result) { + span?.setStatus({ code: SPAN_STATUS_OK }); + } else { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'prefetch_failed' }); + } span?.end(); return result; }) @@ -158,18 +163,6 @@ function wrapLoadAsync(imageClass: T): void { }) 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}`; diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index ecd3e14128..e69ffd9e9b 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -130,6 +130,22 @@ export function createSpanJSON( }); } +/** + * Extracts a short, human-readable description from a URL by stripping + * the query string, fragment, and path — returning only the filename. + */ +export 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; + } +} + const SENTRY_DEFAULT_ORIGIN = 'manual'; /** diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts index d113bfc5bf..9ee5292171 100644 --- a/packages/core/test/tracing/expoImage.test.ts +++ b/packages/core/test/tracing/expoImage.test.ts @@ -109,6 +109,21 @@ describe('wrapExpoImage', () => { expect(mockSpan.end).toHaveBeenCalled(); }); + it('marks span as error when prefetch resolves to false', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(false); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + const result = await imageClass.prefetch('https://example.com/missing.png'); + + expect(result).toBe(false); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'prefetch_failed', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + it('handles URL without path correctly', async () => { const mockPrefetch = jest.fn().mockResolvedValue(true); const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; From c5fedcafbd5b9852b35663b3c4c7a2f6e499f7bf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 13:23:48 +0100 Subject: [PATCH 04/10] Try catch fixes --- packages/core/src/js/tracing/expoAsset.ts | 28 +++++---- packages/core/src/js/tracing/expoImage.ts | 64 ++++++++++++-------- packages/core/test/tracing/expoAsset.test.ts | 21 +++++++ packages/core/test/tracing/expoImage.test.ts | 36 +++++++++++ 4 files changed, 112 insertions(+), 37 deletions(-) diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts index 5b3ddac22e..c449f4ff65 100644 --- a/packages/core/src/js/tracing/expoAsset.ts +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -81,17 +81,23 @@ function wrapLoadAsync(assetClass: T): void { }, }); - 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; - }); + try { + 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; + }); + } catch (error) { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + } }) as T['loadAsync']; } diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts index a1ef3d797e..7cc1f432dd 100644 --- a/packages/core/src/js/tracing/expoImage.ts +++ b/packages/core/src/js/tracing/expoImage.ts @@ -106,21 +106,27 @@ function wrapPrefetch(imageClass: T): void { }, }); - 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; - }); + 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']; } @@ -149,17 +155,23 @@ function wrapLoadAsync(imageClass: T): void { }, }); - 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; - }); + try { + 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; + }); + } catch (error) { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + } }) as T['loadAsync']; } diff --git a/packages/core/test/tracing/expoAsset.test.ts b/packages/core/test/tracing/expoAsset.test.ts index f9904849ff..9d6fef8359 100644 --- a/packages/core/test/tracing/expoAsset.test.ts +++ b/packages/core/test/tracing/expoAsset.test.ts @@ -171,6 +171,27 @@ describe('wrapExpoAsset', () => { expect(mockLoadAsync).toHaveBeenCalledWith([10, 20]); }); + it('ends span when loadAsync throws synchronously', () => { + const error = new Error('Invalid module ID'); + const mockLoadAsync = jest.fn().mockImplementation(() => { + throw error; + }); + const assetClass = { + loadAsync: mockLoadAsync, + fromModule: jest.fn(), + } as unknown as ExpoAsset; + + wrapExpoAsset(assetClass); + + expect(() => assetClass.loadAsync(99)).toThrow('Invalid module ID'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Invalid module ID', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + it('handles non-URL string gracefully', async () => { const mockLoadAsync = jest.fn().mockResolvedValue([]); const assetClass = { diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts index 9ee5292171..b5dffbfa22 100644 --- a/packages/core/test/tracing/expoImage.test.ts +++ b/packages/core/test/tracing/expoImage.test.ts @@ -109,6 +109,24 @@ describe('wrapExpoImage', () => { expect(mockSpan.end).toHaveBeenCalled(); }); + it('ends span when prefetch throws synchronously', async () => { + const error = new Error('Invalid argument'); + const mockPrefetch = jest.fn().mockImplementation(() => { + throw error; + }); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + expect(() => imageClass.prefetch('https://example.com/image.png')).toThrow('Invalid argument'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Invalid argument', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + it('marks span as error when prefetch resolves to false', async () => { const mockPrefetch = jest.fn().mockResolvedValue(false); const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; @@ -245,6 +263,24 @@ describe('wrapExpoImage', () => { expect(mockSpan.end).toHaveBeenCalled(); }); + it('ends span when loadAsync throws synchronously', () => { + const error = new Error('Invalid source'); + const mockLoadAsync = jest.fn().mockImplementation(() => { + throw error; + }); + const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + + expect(() => imageClass.loadAsync('bad-source')).toThrow('Invalid source'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Invalid source', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + it('passes options through to original loadAsync', async () => { const mockResult = { width: 100, height: 100, scale: 1, mediaType: null }; const mockLoadAsync = jest.fn().mockResolvedValue(mockResult); From 687393c0c070a975224653c325a07cc3f54e4598 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 13:30:58 +0100 Subject: [PATCH 05/10] Moving traceAsyncOperation to a separate function --- packages/core/src/js/tracing/expoAsset.ts | 38 ++++++-------------- packages/core/src/js/tracing/expoImage.ts | 37 ++++++------------- packages/core/src/js/tracing/utils.ts | 44 ++++++++++++++++++++++- 3 files changed, 65 insertions(+), 54 deletions(-) diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts index c449f4ff65..8b7106815c 100644 --- a/packages/core/src/js/tracing/expoAsset.ts +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -1,6 +1,5 @@ -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin'; -import { describeUrl } from './utils'; +import { describeUrl, traceAsyncOperation } from './utils'; /** * Internal interface for expo-asset's Asset instance. @@ -72,32 +71,17 @@ function wrapLoadAsync(assetClass: T): void { 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 traceAsyncOperation( + { + op: 'resource.asset', + name: `Asset load ${description}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET, + 'asset.count': assetCount, + }, }, - }); - - try { - 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; - }); - } catch (error) { - span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); - span?.end(); - throw error; - } + () => originalLoadAsync(moduleId), + ); }) as T['loadAsync']; } diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts index 7cc1f432dd..afdf2f20c3 100644 --- a/packages/core/src/js/tracing/expoImage.ts +++ b/packages/core/src/js/tracing/expoImage.ts @@ -1,6 +1,6 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin'; -import { describeUrl } from './utils'; +import { describeUrl, traceAsyncOperation } from './utils'; /** * Internal interface for expo-image's ImageSource. @@ -146,32 +146,17 @@ function wrapLoadAsync(imageClass: T): void { 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 traceAsyncOperation( + { + op: 'resource.image.load', + name: `Image load ${description}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, + ...(imageUrl ? { 'image.url': imageUrl } : undefined), + }, }, - }); - - try { - 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; - }); - } catch (error) { - span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); - span?.end(); - throw error; - } + () => originalLoadAsync(source, options), + ); }) as T['loadAsync']; } diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index e69ffd9e9b..ef7b9230ac 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -1,4 +1,4 @@ -import type { MeasurementUnit, Span, SpanJSON, TransactionSource } from '@sentry/core'; +import type { MeasurementUnit, Span, SpanJSON, StartSpanOptions, TransactionSource } from '@sentry/core'; import { debug, dropUndefinedKeys, @@ -8,7 +8,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, spanToJSON, + startInactiveSpan, timestampInSeconds, uuid4, } from '@sentry/core'; @@ -130,6 +133,45 @@ export function createSpanJSON( }); } +/** + * Wraps a function call that returns a `Promise` with an inactive span that + * is automatically ended on success or failure (both sync throws and async + * rejections). + * + * This is the standard pattern for instrumenting async SDK operations such as + * `Image.prefetch`, `Image.loadAsync`, and `Asset.loadAsync`. + * + * @param spanOptions Options forwarded to `startInactiveSpan`. + * @param fn The function to call. Receives the created span (or + * `undefined` when span creation is suppressed) so callers + * can customise status handling in the `.then` callback. + * @returns Whatever `fn` returns (the original `Promise`). + */ +export function traceAsyncOperation( + spanOptions: StartSpanOptions, + fn: (span: Span | undefined) => Promise, +): Promise { + const span = startInactiveSpan(spanOptions); + + try { + return fn(span) + .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; + }); + } catch (error) { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + } +} + /** * Extracts a short, human-readable description from a URL by stripping * the query string, fragment, and path — returning only the filename. From d8020a6a32305933851179944e172955faecbc60 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 13:43:06 +0100 Subject: [PATCH 06/10] removing Span from there --- packages/core/src/js/tracing/utils.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index ef7b9230ac..3b1bb01617 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -139,22 +139,24 @@ export function createSpanJSON( * rejections). * * This is the standard pattern for instrumenting async SDK operations such as - * `Image.prefetch`, `Image.loadAsync`, and `Asset.loadAsync`. + * `Image.loadAsync` and `Asset.loadAsync`. + * + * The span status is always set by this utility (`ok` on resolve, `error` on + * reject or sync throw). If you need custom status logic (e.g. inspecting the + * resolved value), handle span lifecycle manually instead. * * @param spanOptions Options forwarded to `startInactiveSpan`. - * @param fn The function to call. Receives the created span (or - * `undefined` when span creation is suppressed) so callers - * can customise status handling in the `.then` callback. + * @param fn The function to call. * @returns Whatever `fn` returns (the original `Promise`). */ export function traceAsyncOperation( spanOptions: StartSpanOptions, - fn: (span: Span | undefined) => Promise, + fn: () => Promise, ): Promise { const span = startInactiveSpan(spanOptions); try { - return fn(span) + return fn() .then(result => { span?.setStatus({ code: SPAN_STATUS_OK }); span?.end(); From 19a624f091fb9317cae3928ba8fb83486790db83 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 14:11:49 +0100 Subject: [PATCH 07/10] smallish fix + an AI generated test --- packages/core/src/js/tracing/utils.ts | 2 +- packages/core/test/tracing/expoImage.test.ts | 42 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index 3b1bb01617..0ea6baf6af 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -184,7 +184,7 @@ export function describeUrl(url: string): string { const withoutQuery = url.split('?')[0] || url; const withoutFragment = withoutQuery.split('#')[0] || withoutQuery; const filename = withoutFragment.split('/').pop(); - return filename || url; + return filename || withoutFragment; } catch { return url; } diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts index b5dffbfa22..a55b2d9acc 100644 --- a/packages/core/test/tracing/expoImage.test.ts +++ b/packages/core/test/tracing/expoImage.test.ts @@ -169,6 +169,48 @@ describe('wrapExpoImage', () => { }), ); }); + + it('strips query string from URL in span name', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://cdn.example.com/images/photo.jpg?token=SECRET&size=large'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Image prefetch photo.jpg', + }), + ); + }); + + it('strips fragment from URL in span name', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://cdn.example.com/images/photo.jpg#section'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Image prefetch photo.jpg', + }), + ); + }); + + it('does not leak query string for URL ending with trailing slash', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(true); + const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; + + wrapExpoImage(imageClass); + await imageClass.prefetch('https://cdn.example.com/images/?token=SECRET'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.not.stringContaining('SECRET'), + }), + ); + }); }); describe('loadAsync', () => { From d916fe8392801663a92792bac85c738f1db1f835 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 14:25:34 +0100 Subject: [PATCH 08/10] lint fix --- packages/core/src/js/tracing/expoAsset.ts | 2 -- packages/core/src/js/tracing/utils.ts | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts index 8b7106815c..294ec7cbb1 100644 --- a/packages/core/src/js/tracing/expoAsset.ts +++ b/packages/core/src/js/tracing/expoAsset.ts @@ -95,5 +95,3 @@ function describeModuleIds(moduleIds: (number | string)[]): string { } return `${moduleIds.length} assets`; } - - diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index 0ea6baf6af..ef0ac9c125 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -149,10 +149,7 @@ export function createSpanJSON( * @param fn The function to call. * @returns Whatever `fn` returns (the original `Promise`). */ -export function traceAsyncOperation( - spanOptions: StartSpanOptions, - fn: () => Promise, -): Promise { +export function traceAsyncOperation(spanOptions: StartSpanOptions, fn: () => Promise): Promise { const span = startInactiveSpan(spanOptions); try { From f735b4e7c6f488e52fcb0a80d574acba48ae048e Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 9 Mar 2026 15:06:04 +0100 Subject: [PATCH 09/10] Fix for position of the entry in CHANGELOG.md --- CHANGELOG.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aff466806..cb191d2dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### 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); + ``` + ## 8.3.0 ### Features @@ -72,20 +88,6 @@ - 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)) From 0e0a0f1f909b3a9b0ef6749c90ab5175058fa04f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 10 Mar 2026 11:34:51 +0100 Subject: [PATCH 10/10] Fixes --- packages/core/src/js/index.ts | 2 +- packages/core/src/js/tracing/expoImage.ts | 6 +++--- packages/core/src/js/tracing/index.ts | 2 +- packages/core/src/js/tracing/utils.ts | 12 ++++++++++++ packages/core/test/tracing/expoImage.test.ts | 20 ++++++++++++-------- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index a7de27f627..0ef9632203 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -97,7 +97,7 @@ export { wrapExpoAsset, } from './tracing'; -export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset, ExpoAssetInstance } from './tracing'; +export type { TimeToDisplayProps, ExpoRouter, ExpoImage, ExpoAsset } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; diff --git a/packages/core/src/js/tracing/expoImage.ts b/packages/core/src/js/tracing/expoImage.ts index afdf2f20c3..8ad0a2fbac 100644 --- a/packages/core/src/js/tracing/expoImage.ts +++ b/packages/core/src/js/tracing/expoImage.ts @@ -1,6 +1,6 @@ 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'; +import { describeUrl, sanitizeUrl, traceAsyncOperation } from './utils'; /** * Internal interface for expo-image's ImageSource. @@ -102,7 +102,7 @@ function wrapPrefetch(imageClass: T): void { attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, 'image.url_count': urlCount, - ...(urlCount === 1 ? { 'image.url': firstUrl } : undefined), + ...(urlCount === 1 ? { 'image.url': sanitizeUrl(firstUrl) } : undefined), }, }); @@ -152,7 +152,7 @@ function wrapLoadAsync(imageClass: T): void { name: `Image load ${description}`, attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE, - ...(imageUrl ? { 'image.url': imageUrl } : undefined), + ...(imageUrl ? { 'image.url': sanitizeUrl(imageUrl) } : undefined), }, }, () => originalLoadAsync(source, options), diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index 9e1db904a4..d366885cd1 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -16,7 +16,7 @@ export { wrapExpoImage } from './expoImage'; export type { ExpoImage } from './expoImage'; export { wrapExpoAsset } from './expoAsset'; -export type { ExpoAsset, ExpoAssetInstance } from './expoAsset'; +export type { ExpoAsset } from './expoAsset'; export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; diff --git a/packages/core/src/js/tracing/utils.ts b/packages/core/src/js/tracing/utils.ts index ef0ac9c125..fe36d19a87 100644 --- a/packages/core/src/js/tracing/utils.ts +++ b/packages/core/src/js/tracing/utils.ts @@ -171,6 +171,18 @@ export function traceAsyncOperation(spanOptions: StartSpanOptions, fn: () => } } +/** + * Strips query string and fragment from a URL, preserving the scheme, host, and path. + */ +export function sanitizeUrl(url: string): string { + try { + const withoutQuery = url.split('?')[0] || url; + return withoutQuery.split('#')[0] || withoutQuery; + } catch { + return url; + } +} + /** * Extracts a short, human-readable description from a URL by stripping * the query string, fragment, and path — returning only the filename. diff --git a/packages/core/test/tracing/expoImage.test.ts b/packages/core/test/tracing/expoImage.test.ts index a55b2d9acc..6dd1fc9ad1 100644 --- a/packages/core/test/tracing/expoImage.test.ts +++ b/packages/core/test/tracing/expoImage.test.ts @@ -170,7 +170,7 @@ describe('wrapExpoImage', () => { ); }); - it('strips query string from URL in span name', async () => { + it('strips query string from URL in span name and attribute', async () => { const mockPrefetch = jest.fn().mockResolvedValue(true); const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; @@ -180,11 +180,14 @@ describe('wrapExpoImage', () => { expect(mockStartInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Image prefetch photo.jpg', + attributes: expect.objectContaining({ + 'image.url': 'https://cdn.example.com/images/photo.jpg', + }), }), ); }); - it('strips fragment from URL in span name', async () => { + it('strips fragment from URL in span name and attribute', async () => { const mockPrefetch = jest.fn().mockResolvedValue(true); const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; @@ -194,22 +197,23 @@ describe('wrapExpoImage', () => { expect(mockStartInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ name: 'Image prefetch photo.jpg', + attributes: expect.objectContaining({ + 'image.url': 'https://cdn.example.com/images/photo.jpg', + }), }), ); }); - it('does not leak query string for URL ending with trailing slash', async () => { + it('does not leak query string in span name or attributes', async () => { const mockPrefetch = jest.fn().mockResolvedValue(true); const imageClass = { prefetch: mockPrefetch, loadAsync: jest.fn() } as unknown as ExpoImage; wrapExpoImage(imageClass); await imageClass.prefetch('https://cdn.example.com/images/?token=SECRET'); - expect(mockStartInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.not.stringContaining('SECRET'), - }), - ); + const call = mockStartInactiveSpan.mock.calls[0][0]; + expect(call.name).not.toContain('SECRET'); + expect(JSON.stringify(call.attributes)).not.toContain('SECRET'); }); });