Skip to content

Auto tracing for expo-image and expo-asset#5718

Merged
alwx merged 12 commits intomainfrom
alex/feature/expo-asset
Mar 10, 2026
Merged

Auto tracing for expo-image and expo-asset#5718
alwx merged 12 commits intomainfrom
alex/feature/expo-asset

Conversation

@alwx
Copy link
Contributor

@alwx alwx commented Feb 25, 2026

📢 Type of change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring

📜 Description

Fixes #5427

💡 Motivation and Context

💚 How did you test it?

📝 Checklist

  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • All tests passing
  • No breaking changes

🔮 Next steps

@alwx alwx self-assigned this Feb 25, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


  • Auto tracing for expo-image and expo-asset by alwx in #5718
  • Expo Updates: add tags with Expo Updates context vars to make them searchable/filterable by alwx in #5788
  • chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 by dependabot in #5784
  • chore(deps): bump github/codeql-action from 4.32.4 to 4.32.6 by dependabot in #5781
  • chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.23.1 to 2.23.2 by dependabot in #5782
  • chore(deps): bump getsentry/craft from 2.21.7 to 2.23.2 by dependabot in #5783
  • chore(deps): bump tar to ^7.5.10 by antonis in #5777

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

Fails
🚫 Please consider adding a changelog entry for the next release.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

## Unreleased

### Features

- Auto tracing for expo-image and expo-asset ([#5718](https://github.com/getsentry/sentry-react-native/pull/5718))

If none of the above apply, you can opt out of this check by adding #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against cd229f0

@alwx alwx changed the title WIP: Auto tracing for expo-image and expo-assets WIP: Auto tracing for expo-image and expo-asset Feb 27, 2026
@alwx alwx marked this pull request as ready for review March 9, 2026 12:04
@alwx alwx changed the title WIP: Auto tracing for expo-image and expo-asset Auto tracing for expo-image and expo-asset Mar 9, 2026
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Identical describeUrl function duplicated across two new files
    • I extracted describeUrl into a shared tracing/describeUrl.ts utility and updated both wrappers to import it.
  • ✅ Fixed: Missing try/catch leaves spans unclosed on synchronous throw
    • I wrapped each original wrapper call in try/catch to set error status and end spans on synchronous throws, and added tests for these paths.

Create PR

Or push these changes by commenting:

@cursor push 9eed63c488
Preview (9eed63c488)
diff --git a/packages/core/src/js/tracing/describeUrl.ts b/packages/core/src/js/tracing/describeUrl.ts
new file mode 100644
--- /dev/null
+++ b/packages/core/src/js/tracing/describeUrl.ts
@@ -1,0 +1,14 @@
+/**
+ * Extracts a human-readable URL identifier for span names.
+ */
+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;
+  }
+}

diff --git a/packages/core/src/js/tracing/expoAsset.ts b/packages/core/src/js/tracing/expoAsset.ts
--- a/packages/core/src/js/tracing/expoAsset.ts
+++ b/packages/core/src/js/tracing/expoAsset.ts
@@ -1,4 +1,5 @@
 import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
+import { describeUrl } from './describeUrl';
 import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET } from './origin';
 
 /**
@@ -80,17 +81,23 @@
       },
     });
 
-    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'];
 }
 
@@ -104,15 +111,3 @@
   }
   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
--- a/packages/core/src/js/tracing/expoImage.ts
+++ b/packages/core/src/js/tracing/expoImage.ts
@@ -1,4 +1,5 @@
 import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
+import { describeUrl } from './describeUrl';
 import { SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE } from './origin';
 
 /**
@@ -105,17 +106,23 @@
       },
     });
 
-    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;
-      });
+    try {
+      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;
+        });
+    } catch (error) {
+      span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
+      span?.end();
+      throw error;
+    }
   }) as T['prefetch'];
 }
 
@@ -144,32 +151,26 @@
       },
     });
 
-    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'];
 }
 
-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/test/tracing/expoAsset.test.ts b/packages/core/test/tracing/expoAsset.test.ts
--- a/packages/core/test/tracing/expoAsset.test.ts
+++ b/packages/core/test/tracing/expoAsset.test.ts
@@ -158,6 +158,26 @@
       expect(mockSpan.end).toHaveBeenCalled();
     });
 
+    it('ends the span when loadAsync throws synchronously', () => {
+      const error = new Error('Invalid module id');
+      const mockLoadAsync = jest.fn(() => {
+        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('passes the original moduleId argument through', 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
--- a/packages/core/test/tracing/expoImage.test.ts
+++ b/packages/core/test/tracing/expoImage.test.ts
@@ -109,6 +109,23 @@
       expect(mockSpan.end).toHaveBeenCalled();
     });
 
+    it('ends the span when prefetch throws synchronously', () => {
+      const error = new Error('Invalid prefetch input');
+      const mockPrefetch = jest.fn(() => {
+        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 prefetch input');
+      expect(mockSpan.setStatus).toHaveBeenCalledWith({
+        code: SPAN_STATUS_ERROR,
+        message: 'Error: Invalid prefetch input',
+      });
+      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;
@@ -230,6 +247,23 @@
       expect(mockSpan.end).toHaveBeenCalled();
     });
 
+    it('ends the span when loadAsync throws synchronously', () => {
+      const error = new Error('Invalid image source');
+      const mockLoadAsync = jest.fn(() => {
+        throw error;
+      });
+      const imageClass = { prefetch: jest.fn(), loadAsync: mockLoadAsync } as unknown as ExpoImage;
+
+      wrapExpoImage(imageClass);
+
+      expect(() => imageClass.loadAsync('https://example.com/broken.png')).toThrow('Invalid image source');
+      expect(mockSpan.setStatus).toHaveBeenCalledWith({
+        code: SPAN_STATUS_ERROR,
+        message: 'Error: Invalid image 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);
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@alwx
Copy link
Contributor Author

alwx commented Mar 9, 2026

@antonis @lucas-zimerman this one is ready now!

@alwx alwx requested a review from antonis March 10, 2026 10:35
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

export function sanitizeUrl(url: string): string {
try {
const withoutQuery = url.split('?')[0] || url;
return withoutQuery.split('#')[0] || withoutQuery;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary || fallback defeats URL sanitization for edge-case inputs

Low Severity

sanitizeUrl and describeUrl use || url / || withoutQuery fallbacks after split('?')[0] and split('#')[0]. Since String.split() always returns a non-empty array, [0] is always a string — never null or undefined. The || fallback only activates when the result is an empty string (falsy), which happens when the URL starts with ? or #. In that case, the fallback restores the original unsanitized URL, defeating the purpose of stripping query parameters. Removing the || url / || withoutQuery fallback entirely fixes this — an empty string is the correct result when nothing precedes the delimiter.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@antonis antonis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀
Thank you for your work on this and iterating on the feedback 🙇

@alwx alwx added the ready-to-merge Triggers the full CI test suite label Mar 10, 2026
@github-actions
Copy link
Contributor

iOS (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1213.89 ms 1216.38 ms 2.48 ms
Size 3.38 MiB 4.81 MiB 1.43 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
ea3e26e+dirty 1229.13 ms 1228.46 ms -0.67 ms
80e4616+dirty 1221.32 ms 1225.64 ms 4.32 ms
818a608+dirty 1205.76 ms 1208.00 ms 2.24 ms
77061ed+dirty 1233.16 ms 1234.88 ms 1.71 ms
bef3709+dirty 1222.07 ms 1220.24 ms -1.83 ms
a206511+dirty 1185.00 ms 1186.35 ms 1.35 ms
74979ac+dirty 1210.49 ms 1213.31 ms 2.82 ms
a2bb688+dirty 1223.53 ms 1232.90 ms 9.37 ms
8a868fe+dirty 1221.50 ms 1230.78 ms 9.28 ms
d590428+dirty 1211.77 ms 1220.51 ms 8.75 ms

App size

Revision Plain With Sentry Diff
ea3e26e+dirty 3.41 MiB 4.58 MiB 1.17 MiB
80e4616+dirty 3.38 MiB 4.60 MiB 1.22 MiB
818a608+dirty 2.63 MiB 3.91 MiB 1.28 MiB
77061ed+dirty 2.63 MiB 3.98 MiB 1.34 MiB
bef3709+dirty 3.38 MiB 4.78 MiB 1.40 MiB
a206511+dirty 3.41 MiB 4.67 MiB 1.25 MiB
74979ac+dirty 3.38 MiB 4.60 MiB 1.22 MiB
a2bb688+dirty 2.63 MiB 3.99 MiB 1.36 MiB
8a868fe+dirty 3.38 MiB 4.60 MiB 1.22 MiB
d590428+dirty 3.38 MiB 4.78 MiB 1.39 MiB

@github-actions
Copy link
Contributor

Android (legacy) Performance metrics 🚀

  Plain With Sentry Diff
Startup time 399.16 ms 437.02 ms 37.86 ms
Size 43.75 MiB 48.50 MiB 4.75 MiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
86584b7+dirty 463.83 ms 500.31 ms 36.48 ms
9a81842+dirty 412.23 ms 416.56 ms 4.33 ms
c637fc7+dirty 433.70 ms 467.76 ms 34.06 ms
d73150f+dirty 411.21 ms 465.86 ms 54.65 ms
fa7bb7e+dirty 350.37 ms 377.02 ms 26.65 ms
3bd3f0d+dirty 447.21 ms 472.31 ms 25.10 ms
88890fe+dirty 350.94 ms 365.74 ms 14.80 ms
95aaf8a 437.89 ms 419.45 ms -18.44 ms
c0842e7+dirty 527.76 ms 566.69 ms 38.93 ms
1e7a472+dirty 348.80 ms 362.55 ms 13.75 ms

App size

Revision Plain With Sentry Diff
86584b7+dirty 43.75 MiB 48.08 MiB 4.33 MiB
9a81842+dirty 43.75 MiB 48.08 MiB 4.33 MiB
c637fc7+dirty 43.75 MiB 48.40 MiB 4.64 MiB
d73150f+dirty 43.75 MiB 48.55 MiB 4.80 MiB
fa7bb7e+dirty 17.75 MiB 19.75 MiB 2.00 MiB
3bd3f0d+dirty 17.75 MiB 19.70 MiB 1.95 MiB
88890fe+dirty 17.75 MiB 19.71 MiB 1.96 MiB
95aaf8a 17.75 MiB 19.68 MiB 1.93 MiB
c0842e7+dirty 43.75 MiB 48.41 MiB 4.66 MiB
1e7a472+dirty 17.75 MiB 19.70 MiB 1.96 MiB

@alwx alwx merged commit 9725d40 into main Mar 10, 2026
77 of 93 checks passed
@alwx alwx deleted the alex/feature/expo-asset branch March 10, 2026 11:20
alwx added a commit to getsentry/sentry-docs that referenced this pull request Mar 18, 2026
We've recently added a bunch of changes to Expo integrations in the
React Native SDK, this PR adds the missing information about those
changes to the docs.

Here is the list of PRs it covers:

- Expo Updates
- getsentry/sentry-react-native#5794
- getsentry/sentry-react-native#5788
- getsentry/sentry-react-native#5748
- Expo Config Plugin
- getsentry/sentry-react-native#5796 — 06d4e245
— Add environment option to Expo config plugin
- EAS Build Integration
- getsentry/sentry-react-native#5666
- getsentry/sentry-react-native#5773
- getsentry/sentry-react-native#5617
- Expo Router / Tracing
- getsentry/sentry-react-native#5606
- getsentry/sentry-react-native#5718
- Tooling / Fixes
- getsentry/sentry-react-native#5636
  - getsentry/sentry-react-native#5733
stephanie-anderson pushed a commit to getsentry/sentry-docs that referenced this pull request Mar 18, 2026
We've recently added a bunch of changes to Expo integrations in the
React Native SDK, this PR adds the missing information about those
changes to the docs.

Here is the list of PRs it covers:

- Expo Updates
- getsentry/sentry-react-native#5794
- getsentry/sentry-react-native#5788
- getsentry/sentry-react-native#5748
- Expo Config Plugin
- getsentry/sentry-react-native#5796 — 06d4e245
— Add environment option to Expo config plugin
- EAS Build Integration
- getsentry/sentry-react-native#5666
- getsentry/sentry-react-native#5773
- getsentry/sentry-react-native#5617
- Expo Router / Tracing
- getsentry/sentry-react-native#5606
- getsentry/sentry-react-native#5718
- Tooling / Fixes
- getsentry/sentry-react-native#5636
  - getsentry/sentry-react-native#5733
constantinius pushed a commit to getsentry/sentry-docs that referenced this pull request Mar 20, 2026
We've recently added a bunch of changes to Expo integrations in the
React Native SDK, this PR adds the missing information about those
changes to the docs.

Here is the list of PRs it covers:

- Expo Updates
- getsentry/sentry-react-native#5794
- getsentry/sentry-react-native#5788
- getsentry/sentry-react-native#5748
- Expo Config Plugin
- getsentry/sentry-react-native#5796 — 06d4e245
— Add environment option to Expo config plugin
- EAS Build Integration
- getsentry/sentry-react-native#5666
- getsentry/sentry-react-native#5773
- getsentry/sentry-react-native#5617
- Expo Router / Tracing
- getsentry/sentry-react-native#5606
- getsentry/sentry-react-native#5718
- Tooling / Fixes
- getsentry/sentry-react-native#5636
  - getsentry/sentry-react-native#5733
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Triggers the full CI test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Expo] Performance monitoring

2 participants