Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->

## Unreleased

### Features

- Automatically detect Release name and version for Expo Web ([#4910](https://github.com/getsentry/sentry-react-native/pull/4910))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i: We should mention that it only works in development builds.
If you try it on a release build you'll not get the release number
https://sentry-sdks.sentry.io/issues/6673524305/events/04e3a3759a7c4cc5bd4a63038d0c2d9b/?project=5428561

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point Lucas 👍
I think this is something we should tackle for the enhancement to make sense.

Copy link
Copy Markdown
Contributor

@krystofwoldrich krystofwoldrich Jun 12, 2025

Choose a reason for hiding this comment

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

If the application name and version are not available in production, I would not added it at all, to avoid confusing users with differences between dev and prod.

If Expo doesn't include any of the information in production on web, we could include the release name during the build (bundling process), utilizing our Sentry Metro Plugin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hey @lucas-zimerman 👋
I've updated the PR with f2648ba to handle the release build case (example). The downside is that it depends on expo-constants and will fail if it is not available. I've tested other approaches like bundle injection, but either didn't work or they were overcomplicated for the task at hand.
I think this approach is simple and would work on most cases. As a fallback the user's can still set the release manually on the web as they do now. Let me know wdyt and I can clean up the PR and prepare it for review 🙇

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi @antonis and @lucas-zimerman,
I've taken a look at this again and I think the SDK should not depend on expo-constants, to keep compatibility with non-expo apps. I would also avoid the ENVs approach since it doesn't work both prod and dev.

I would propose to use a custom module to load a global variable with the release information. This will work for both prod and dev.

Similar to how Sentry JS Webpack Plugin is doing it, link.

This is, how it could be implemented: v7...krystofwoldrich:sentry-react-native:@krystofwoldrich/inject-expo-app-release#diff-de4a1259c31e8b44b35788d85ff5eb3d27eef51e73325b3ee41532d9095722cd

Let me know, if this would work for you, I can open a PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for taking the time to look at this @krystofwoldrich. Your feedback is always welcome :)

I've taken a look at this again and I think the SDK should not depend on expo-constants, to keep compatibility with non-expo apps.

I agree. This makes sense only as a best effort approach for expo web.

I would propose to use a custom module to load a global variable with the release information. This will work for both prod and dev.

Similar to how Sentry JS Webpack Plugin is doing it, link.

This is, how it could be implemented: v7...krystofwoldrich:sentry-react-native:@krystofwoldrich/inject-expo-app-release#diff-de4a1259c31e8b44b35788d85ff5eb3d27eef51e73325b3ee41532d9095722cd

Your approach looks great and of course a PR is more than welcome 🤗

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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


## 6.15.1

### Dependencies
Expand Down
69 changes: 67 additions & 2 deletions packages/core/src/js/integrations/release.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import type { BaseTransportOptions, Client, ClientOptions, Event, EventHint, Integration } from '@sentry/core';

import { isExpo, isWeb } from '../utils/environment';
import { NATIVE } from '../wrapper';

const INTEGRATION_NAME = 'Release';

interface ExpoConfig {
name?: string;
version?: string;
}

/** Release integration responsible to load release from file. */
export const nativeReleaseIntegration = (): Integration => {
return {
Expand All @@ -15,6 +21,48 @@ export const nativeReleaseIntegration = (): Integration => {
};
};

/**
* Get Expo Web configuration name and version from environment variables
*/
function getExpoWebConfig(): ExpoConfig | null {
if (!isWeb() || !isExpo()) {
return null;
}

try {
const processEnv = (globalThis as { process?: { env?: Record<string, string> } }).process?.env;

if (processEnv) {
const envConfig = {
name: processEnv.EXPO_PUBLIC_APP_NAME,
version: processEnv.EXPO_PUBLIC_APP_VERSION,
};

if (envConfig.name && envConfig.version) {
return envConfig;
}
}
} catch {
// Config detection failed, do nothing
}

return null;
}

/**
* Generates release string from Expo Web config
*/
function generateExpoWebRelease(config: ExpoConfig): string | null {
const name = config.name;
const version = config.version;

if (!name || !version) {
return null;
}

return `${name}@${version}`;
}

async function processEvent(
event: Event,
_: EventHint,
Expand All @@ -23,8 +71,11 @@ async function processEvent(
const options = client.getOptions();

/*
__sentry_release and __sentry_dist is set by the user with setRelease and setDist. If this is used then this is the strongest.
Otherwise we check for the release and dist in the options passed on init, as this is stronger than the release/dist from the native build.
Priority order:
1. __sentry_release and __sentry_dist set by user with setRelease and setDist (strongest)
2. release and dist in options passed on init
3. Native release (mobile)
4. Expo Web auto-detection (web only, no additional dependencies)
*/
if (typeof event.extra?.__sentry_release === 'string') {
event.release = `${event.extra.__sentry_release}`;
Expand Down Expand Up @@ -56,5 +107,19 @@ async function processEvent(
// Something went wrong, we just continue
}

if (!event.release && isExpo() && isWeb()) {
try {
const expoConfig = getExpoWebConfig();
if (expoConfig) {
const autoRelease = generateExpoWebRelease(expoConfig);
if (autoRelease) {
event.release = autoRelease;
}
}
} catch {
// Something went wrong, we just continue
}
}

return event;
}
40 changes: 40 additions & 0 deletions packages/core/src/js/tools/metroconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,44 @@ import { withSentryMiddleware } from './metroMiddleware';

enableLogger();

interface ExpoConfig {
name?: string;
version?: string;
}

interface ExpoConfigResult {
exp: ExpoConfig;
}

/**
* Try to inject Expo config name and version as environment variables.
* @param projectRoot The root directory of the Expo project.
*/
function injectExpoConfigAsEnvVars(projectRoot: string): void {
try {
let getConfigFunction: ((projectRoot: string) => ExpoConfigResult) | null = null;

try {
// eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies
const expoConfig = require('@expo/config') as { getConfig: (projectRoot: string) => ExpoConfigResult };
getConfigFunction = expoConfig.getConfig;
} catch {
// @expo/config not available, do nothing
}

if (getConfigFunction) {
const { exp } = getConfigFunction(projectRoot);

if (exp && typeof exp.name === 'string' && typeof exp.version === 'string') {
process.env.EXPO_PUBLIC_APP_NAME = exp.name;
process.env.EXPO_PUBLIC_APP_VERSION = exp.version;
}
}
} catch (error) {
logger.warn('Failed to inject Expo config as environment variables:', error);
}
}

export interface SentryMetroConfigOptions {
/**
* Annotates React components with Sentry data.
Expand Down Expand Up @@ -88,6 +126,8 @@ export function getSentryExpoConfig(
): MetroConfig {
setSentryMetroDevServerEnvFlag();

injectExpoConfigAsEnvVars(projectRoot);

const getDefaultConfig = options.getDefaultConfig || loadExpoMetroConfigModule().getDefaultConfig;
const config = getDefaultConfig(projectRoot, {
...options,
Expand Down
181 changes: 176 additions & 5 deletions packages/core/test/integrations/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,40 @@ import { nativeReleaseIntegration } from '../../src/js/integrations/release';

jest.mock('../../src/js/wrapper', () => ({
NATIVE: {
fetchNativeRelease: async () => ({
build: 'native_build',
id: 'native_id',
version: 'native_version',
}),
fetchNativeRelease: jest.fn(),
},
}));

jest.mock('../../src/js/utils/environment', () => ({
isExpo: jest.fn(),
isWeb: jest.fn(),
}));

import { isExpo, isWeb } from '../../src/js/utils/environment';
import { NATIVE } from '../../src/js/wrapper';

const mockIsExpo = isExpo as jest.MockedFunction<typeof isExpo>;
const mockIsWeb = isWeb as jest.MockedFunction<typeof isWeb>;
const mockFetchNativeRelease = NATIVE.fetchNativeRelease as jest.MockedFunction<typeof NATIVE.fetchNativeRelease>;

describe('Tests the Release integration', () => {
beforeEach(() => {
mockIsExpo.mockReturnValue(false);
mockIsWeb.mockReturnValue(false);

mockFetchNativeRelease.mockResolvedValue({
build: 'native_build',
id: 'native_id',
version: 'native_version',
});

delete (globalThis as any).process;
});

afterEach(() => {
jest.clearAllMocks();
});

test('Uses release from native SDK if release/dist are not present in options.', async () => {
const releaseIntegration = nativeReleaseIntegration();

Expand Down Expand Up @@ -77,4 +102,150 @@ describe('Tests the Release integration', () => {
expect(event?.release).toBe('sentry_release');
expect(event?.dist).toBe('sentry_dist');
});

describe('Expo Web Config Tests', () => {
beforeEach(() => {
// Mock native release to throw error so it falls back to Expo Web
mockFetchNativeRelease.mockRejectedValue(new Error('Native release failed'));
});

test('Uses Expo Web config when isExpo and isWeb are true and environment variables are set', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBe('MyExpoApp@1.2.3');
});

test('Does not use Expo Web config when not in Expo environment', async () => {
mockIsExpo.mockReturnValue(false);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBeUndefined();
});

test('Does not use Expo Web config when not in web environment', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(false);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBeUndefined();
});

test('Does not use Expo Web config when app name is missing', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBeUndefined();
});

test('Does not use Expo Web config when app version is missing', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBeUndefined();
});

test('Does not use Expo Web config when process.env is not available', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

delete (globalThis as any).process;

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, { getOptions: () => ({}) } as Client);

expect(event?.release).toBeUndefined();
});

test('Prefers options release over Expo Web config', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!({}, {}, {
getOptions: () => ({ release: 'options_release' }),
} as Client);

expect(event?.release).toBe('options_release');
});

test('Prefers __sentry_release over Expo Web config', async () => {
mockIsExpo.mockReturnValue(true);
mockIsWeb.mockReturnValue(true);

(globalThis as any).process = {
env: {
EXPO_PUBLIC_APP_NAME: 'MyExpoApp',
EXPO_PUBLIC_APP_VERSION: '1.2.3',
},
};

const releaseIntegration = nativeReleaseIntegration();
const event = await releaseIntegration.processEvent!(
{
extra: {
__sentry_release: 'sentry_release',
},
},
{},
{ getOptions: () => ({}) } as Client,
);

expect(event?.release).toBe('sentry_release');
});
});
});
Loading
Loading