diff --git a/packages/core/package.json b/packages/core/package.json index b3f6bea829..320ffd3766 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,7 +45,10 @@ "lint:prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"{src,test,scripts,plugin/src}/**/**.ts\"" }, "bin": { - "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js", + "sentry-eas-build-on-error": "scripts/eas-build-on-error.js", + "sentry-eas-build-on-success": "scripts/eas-build-on-success.js", + "sentry-eas-build-on-complete": "scripts/eas-build-on-complete.js" }, "keywords": [ "react-native", diff --git a/packages/core/scripts/eas-build-on-complete.js b/packages/core/scripts/eas-build-on-complete.js new file mode 100755 index 0000000000..9f8753ee54 --- /dev/null +++ b/packages/core/scripts/eas-build-on-complete.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-complete + * + * This script captures EAS build completion events and reports them to Sentry. + * It uses the EAS_BUILD_STATUS environment variable to determine whether + * the build succeeded or failed. + * + * Add it to your package.json scripts: + * + * "eas-build-on-complete": "sentry-eas-build-on-complete" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to also capture successful builds + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message for failed builds + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message for successful builds + * + * EAS Build provides: + * - EAS_BUILD_STATUS: 'finished' or 'errored' + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildComplete; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildComplete = hooks.captureEASBuildComplete; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildComplete(options); + console.log('[Sentry] EAS build complete hook finished.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-complete hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-error.js b/packages/core/scripts/eas-build-on-error.js new file mode 100755 index 0000000000..6c0a1d12e0 --- /dev/null +++ b/packages/core/scripts/eas-build-on-error.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-error + * + * This script captures EAS build failures and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-error": "sentry-eas-build-on-error" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_ERROR_MESSAGE: Custom error message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildError; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildError = hooks.captureEASBuildError; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + errorMessage: process.env.SENTRY_EAS_BUILD_ERROR_MESSAGE, + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildError(options); + console.log('[Sentry] EAS build error hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-error hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/scripts/eas-build-on-success.js b/packages/core/scripts/eas-build-on-success.js new file mode 100755 index 0000000000..af6790c807 --- /dev/null +++ b/packages/core/scripts/eas-build-on-success.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +/** + * EAS Build Hook: on-success + * + * This script captures EAS build successes and reports them to Sentry. + * Add it to your package.json scripts: + * + * "eas-build-on-success": "sentry-eas-build-on-success" + * + * Required environment variables: + * - SENTRY_DSN: Your Sentry DSN + * - SENTRY_EAS_BUILD_CAPTURE_SUCCESS: Set to 'true' to capture successful builds + * + * Optional environment variables: + * - SENTRY_EAS_BUILD_TAGS: JSON string of additional tags + * - SENTRY_EAS_BUILD_SUCCESS_MESSAGE: Custom success message + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + * @see https://docs.sentry.io/platforms/react-native/ + */ + +const path = require('path'); +const fs = require('fs'); + +// Try to load environment variables +function loadEnv() { + // Try @expo/env first + try { + require('@expo/env').load('.'); + } catch (_e) { + // Fallback to dotenv if available + try { + const dotenvPath = path.join(process.cwd(), '.env'); + if (fs.existsSync(dotenvPath)) { + const dotenvFile = fs.readFileSync(dotenvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e2) { + // No dotenv available, continue with existing env vars + } + } + + // Also load .env.sentry-build-plugin if it exists + try { + const sentryEnvPath = path.join(process.cwd(), '.env.sentry-build-plugin'); + if (fs.existsSync(sentryEnvPath)) { + const dotenvFile = fs.readFileSync(sentryEnvPath, 'utf-8'); + const dotenv = require('dotenv'); + Object.assign(process.env, dotenv.parse(dotenvFile)); + } + } catch (_e) { + // Continue without .env.sentry-build-plugin + } +} + +async function main() { + loadEnv(); + + // Dynamically import the hooks module (it's compiled to dist/) + let captureEASBuildSuccess; + try { + // Try the compiled output first + const hooks = require('../dist/js/tools/easBuildHooks.js'); + captureEASBuildSuccess = hooks.captureEASBuildSuccess; + } catch (_e) { + console.error('[Sentry] Could not load EAS build hooks module. Make sure @sentry/react-native is properly installed.'); + process.exit(1); + } + + // Parse options from environment variables + const options = { + dsn: process.env.SENTRY_DSN, + successMessage: process.env.SENTRY_EAS_BUILD_SUCCESS_MESSAGE, + captureSuccessfulBuilds: process.env.SENTRY_EAS_BUILD_CAPTURE_SUCCESS === 'true', + }; + + // Parse additional tags if provided + if (process.env.SENTRY_EAS_BUILD_TAGS) { + try { + options.tags = JSON.parse(process.env.SENTRY_EAS_BUILD_TAGS); + } catch (_e) { + console.warn('[Sentry] Could not parse SENTRY_EAS_BUILD_TAGS as JSON. Ignoring.'); + } + } + + try { + await captureEASBuildSuccess(options); + console.log('[Sentry] EAS build success hook completed.'); + } catch (error) { + console.error('[Sentry] Error in eas-build-on-success hook:', error); + // Don't fail the build hook itself + } +} + +main(); diff --git a/packages/core/src/js/tools/easBuildHooks.ts b/packages/core/src/js/tools/easBuildHooks.ts new file mode 100644 index 0000000000..20bbdffd87 --- /dev/null +++ b/packages/core/src/js/tools/easBuildHooks.ts @@ -0,0 +1,277 @@ +/** + * EAS Build Hooks for Sentry + * + * This module provides utilities for capturing EAS build lifecycle events + * and sending them to Sentry. It supports the following EAS npm hooks: + * - eas-build-on-error: Captures build failures + * - eas-build-on-success: Captures successful builds (optional) + * - eas-build-on-complete: Captures build completion with metrics + * + * @see https://docs.expo.dev/build-reference/npm-hooks/ + */ + +/* eslint-disable no-console */ +/* eslint-disable no-bitwise */ + +const SENTRY_DSN_ENV = 'SENTRY_DSN'; +const EAS_BUILD_ENV = 'EAS_BUILD'; + +/** + * Environment variables provided by EAS Build. + * @see https://docs.expo.dev/build-reference/variables/ + */ +export interface EASBuildEnv { + EAS_BUILD?: string; + EAS_BUILD_ID?: string; + EAS_BUILD_PLATFORM?: string; + EAS_BUILD_PROFILE?: string; + EAS_BUILD_PROJECT_ID?: string; + EAS_BUILD_GIT_COMMIT_HASH?: string; + EAS_BUILD_RUN_FROM_CI?: string; + EAS_BUILD_STATUS?: string; + EAS_BUILD_APP_VERSION?: string; + EAS_BUILD_APP_BUILD_VERSION?: string; + EAS_BUILD_USERNAME?: string; + EAS_BUILD_WORKINGDIR?: string; +} + +/** Options for configuring EAS build hook behavior. */ +export interface EASBuildHookOptions { + dsn?: string; + tags?: Record; + captureSuccessfulBuilds?: boolean; + errorMessage?: string; + successMessage?: string; +} + +interface ParsedDsn { + protocol: string; + host: string; + projectId: string; + publicKey: string; +} + +interface SentryEvent { + event_id: string; + timestamp: number; + platform: string; + level: 'error' | 'info' | 'warning'; + logger: string; + environment: string; + release?: string; + tags: Record; + contexts: Record>; + message?: { formatted: string }; + exception?: { + values: Array<{ type: string; value: string; mechanism: { type: string; handled: boolean } }>; + }; + fingerprint?: string[]; + sdk: { name: string; version: string }; +} + +/** Checks if the current environment is an EAS Build. */ +export function isEASBuild(): boolean { + return process.env[EAS_BUILD_ENV] === 'true'; +} + +/** Gets the EAS build environment variables. */ +export function getEASBuildEnv(): EASBuildEnv { + return { + EAS_BUILD: process.env.EAS_BUILD, + EAS_BUILD_ID: process.env.EAS_BUILD_ID, + EAS_BUILD_PLATFORM: process.env.EAS_BUILD_PLATFORM, + EAS_BUILD_PROFILE: process.env.EAS_BUILD_PROFILE, + EAS_BUILD_PROJECT_ID: process.env.EAS_BUILD_PROJECT_ID, + EAS_BUILD_GIT_COMMIT_HASH: process.env.EAS_BUILD_GIT_COMMIT_HASH, + EAS_BUILD_RUN_FROM_CI: process.env.EAS_BUILD_RUN_FROM_CI, + EAS_BUILD_STATUS: process.env.EAS_BUILD_STATUS, + EAS_BUILD_APP_VERSION: process.env.EAS_BUILD_APP_VERSION, + EAS_BUILD_APP_BUILD_VERSION: process.env.EAS_BUILD_APP_BUILD_VERSION, + EAS_BUILD_USERNAME: process.env.EAS_BUILD_USERNAME, + EAS_BUILD_WORKINGDIR: process.env.EAS_BUILD_WORKINGDIR, + }; +} + +function parseDsn(dsn: string): ParsedDsn | undefined { + try { + const url = new URL(dsn); + const projectId = url.pathname.replace('/', ''); + return { protocol: url.protocol.replace(':', ''), host: url.host, projectId, publicKey: url.username }; + } catch { + return undefined; + } +} + +function getEnvelopeEndpoint(dsn: ParsedDsn): string { + return `${dsn.protocol}://${dsn.host}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`; +} + +function generateEventId(): string { + const bytes = new Uint8Array(16); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < 16; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + const byte6 = bytes[6]; + const byte8 = bytes[8]; + if (byte6 !== undefined && byte8 !== undefined) { + bytes[6] = (byte6 & 0x0f) | 0x40; + bytes[8] = (byte8 & 0x3f) | 0x80; + } + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +function createEASBuildTags(env: EASBuildEnv): Record { + const tags: Record = {}; + if (env.EAS_BUILD_PLATFORM) tags['eas.platform'] = env.EAS_BUILD_PLATFORM; + if (env.EAS_BUILD_PROFILE) tags['eas.profile'] = env.EAS_BUILD_PROFILE; + if (env.EAS_BUILD_ID) tags['eas.build_id'] = env.EAS_BUILD_ID; + if (env.EAS_BUILD_PROJECT_ID) tags['eas.project_id'] = env.EAS_BUILD_PROJECT_ID; + if (env.EAS_BUILD_RUN_FROM_CI) tags['eas.from_ci'] = env.EAS_BUILD_RUN_FROM_CI; + if (env.EAS_BUILD_STATUS) tags['eas.status'] = env.EAS_BUILD_STATUS; + if (env.EAS_BUILD_USERNAME) tags['eas.username'] = env.EAS_BUILD_USERNAME; + return tags; +} + +function createEASBuildContext(env: EASBuildEnv): Record { + return { + build_id: env.EAS_BUILD_ID, + platform: env.EAS_BUILD_PLATFORM, + profile: env.EAS_BUILD_PROFILE, + project_id: env.EAS_BUILD_PROJECT_ID, + git_commit: env.EAS_BUILD_GIT_COMMIT_HASH, + from_ci: env.EAS_BUILD_RUN_FROM_CI === 'true', + status: env.EAS_BUILD_STATUS, + app_version: env.EAS_BUILD_APP_VERSION, + build_version: env.EAS_BUILD_APP_BUILD_VERSION, + username: env.EAS_BUILD_USERNAME, + working_dir: env.EAS_BUILD_WORKINGDIR, + }; +} + +function createEnvelope(event: SentryEvent, dsn: ParsedDsn): string { + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + dsn: `${dsn.protocol}://${dsn.publicKey}@${dsn.host}/${dsn.projectId}`, + sdk: event.sdk, + }); + const itemHeaders = JSON.stringify({ type: 'event', content_type: 'application/json' }); + const itemPayload = JSON.stringify(event); + return `${envelopeHeaders}\n${itemHeaders}\n${itemPayload}`; +} + +async function sendEvent(event: SentryEvent, dsn: ParsedDsn): Promise { + const endpoint = getEnvelopeEndpoint(dsn); + const envelope = createEnvelope(event, dsn); + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + body: envelope, + }); + if (response.status >= 200 && response.status < 300) return true; + console.warn(`[Sentry] Failed to send event: HTTP ${response.status}`); + return false; + } catch (error) { + console.error('[Sentry] Failed to send event:', error); + return false; + } +} + +function createBaseEvent( + level: 'error' | 'info' | 'warning', + env: EASBuildEnv, + customTags?: Record, +): SentryEvent { + return { + event_id: generateEventId(), + timestamp: Date.now() / 1000, + platform: 'node', + level, + logger: 'eas-build-hook', + environment: 'eas-build', + release: env.EAS_BUILD_APP_VERSION, + tags: { ...createEASBuildTags(env), ...customTags }, + contexts: { eas_build: createEASBuildContext(env), runtime: { name: 'node', version: process.version } }, + sdk: { name: 'sentry.javascript.react-native.eas-build-hooks', version: '1.0.0' }, + }; +} + +/** Captures an EAS build error event. Call this from the eas-build-on-error hook. */ +export async function captureEASBuildError(options: EASBuildHookOptions = {}): Promise { + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping error capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const errorMessage = + options.errorMessage ?? `EAS Build Failed: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('error', env, { ...options.tags, 'eas.hook': 'on-error' }); + event.exception = { + values: [{ type: 'EASBuildError', value: errorMessage, mechanism: { type: 'eas-build-hook', handled: true } }], + }; + event.fingerprint = ['eas-build-error', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build error captured.'); +} + +/** Captures an EAS build success event. Call this from the eas-build-on-success hook. */ +export async function captureEASBuildSuccess(options: EASBuildHookOptions = {}): Promise { + if (!options.captureSuccessfulBuilds) { + console.log('[Sentry] Skipping successful build capture (captureSuccessfulBuilds is false).'); + return; + } + const dsn = options.dsn ?? process.env[SENTRY_DSN_ENV]; + if (!dsn) { + console.warn('[Sentry] No DSN provided. Set SENTRY_DSN environment variable or pass dsn option.'); + return; + } + if (!isEASBuild()) { + console.warn('[Sentry] Not running in EAS Build environment. Skipping success capture.'); + return; + } + const parsedDsn = parseDsn(dsn); + if (!parsedDsn) { + console.error('[Sentry] Invalid DSN format.'); + return; + } + const env = getEASBuildEnv(); + const successMessage = + options.successMessage ?? `EAS Build Succeeded: ${env.EAS_BUILD_PLATFORM ?? 'unknown'} (${env.EAS_BUILD_PROFILE ?? 'unknown'})`; + const event = createBaseEvent('info', env, { ...options.tags, 'eas.hook': 'on-success' }); + event.message = { formatted: successMessage }; + event.fingerprint = ['eas-build-success', env.EAS_BUILD_PLATFORM ?? 'unknown', env.EAS_BUILD_PROFILE ?? 'unknown']; + const success = await sendEvent(event, parsedDsn); + if (success) console.log('[Sentry] Build success captured.'); +} + +/** Captures an EAS build completion event with status. Call this from the eas-build-on-complete hook. */ +export async function captureEASBuildComplete(options: EASBuildHookOptions = {}): Promise { + const env = getEASBuildEnv(); + const status = env.EAS_BUILD_STATUS; + if (status === 'errored') { + await captureEASBuildError(options); + return; + } + if (status === 'finished' && options.captureSuccessfulBuilds) { + await captureEASBuildSuccess({ ...options, captureSuccessfulBuilds: true }); + return; + } + console.log(`[Sentry] Build completed with status: ${status ?? 'unknown'}. No event captured.`); +} diff --git a/samples/expo/package.json b/samples/expo/package.json index be1155082e..4e13b1c1e9 100644 --- a/samples/expo/package.json +++ b/samples/expo/package.json @@ -17,6 +17,8 @@ "prebuild": "expo prebuild --clean --no-install", "set-version": "npx react-native-version --skip-tag --never-amend", "eas-build-pre-install": "npm i -g corepack && yarn install --no-immutable --inline-builds && yarn workspace @sentry/react-native build", + "eas-build-on-error": "sentry-eas-build-on-error", + "eas-build-on-complete": "sentry-eas-build-on-complete", "eas-update-configure": "eas update:configure", "eas-update-publish-development": "eas update --channel development --message 'Development update'", "eas-build-development-android": "eas build --profile development --platform android"