Skip to content

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

Open
alwx wants to merge 10 commits intomainfrom
alex/feature/expo-asset
Open

Auto tracing for expo-image and expo-asset#5718
alwx wants to merge 10 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
  • 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
🚫 Pull request is not ready for merge, please add the "ready-to-merge" label to the pull request
🚫 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 f735b4e

@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!

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.

attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE,
'image.url_count': urlCount,
...(urlCount === 1 ? { 'image.url': firstUrl } : undefined),
Copy link

Choose a reason for hiding this comment

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

Full URL with query secrets stored in span attribute

Medium Severity

The image.url span attribute stores the raw, unmodified URL including query parameters, which may contain tokens or secrets. The span name is correctly sanitized via describeUrl (stripping query strings), but the image.url attribute is not. The test "does not leak query string for URL ending with trailing slash" only asserts on the name field, missing that image.url still contains ?token=SECRET. This inconsistency suggests an oversight — the same sanitization applied to name likely needs to apply to image.url, or the attribute needs gating behind sendDefaultPii.

Additional Locations (1)

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Copy link
Contributor

Choose a reason for hiding this comment

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

@alwx this seems valid. Let's fix or document the behavior

} from './tracing';

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

Choose a reason for hiding this comment

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

q: Do we need to export ExpoAssetInstance?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Expo] Performance monitoring

2 participants