diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 0000000..799b30c --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,68 @@ +# Environment Configuration + +This document describes the environment variables used across TeachLink's build profiles and how they control runtime behavior. + +## Build Profiles + +TeachLink uses three EAS build profiles defined in `eas.json`: + +| Profile | Channel | Audience | Sentry | +|---------|---------|----------|--------| +| `development` | development | Local dev machines | Disabled | +| `preview` | preview | Internal QA / staging | Enabled | +| `production` | production | App store releases | Always enabled | + +## Environment Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `EXPO_PUBLIC_API_BASE_URL` | Base URL for the REST API (must be `https://`) | +| `EXPO_PUBLIC_SOCKET_URL` | WebSocket server URL (`ws://` or `wss://`) | + +### Optional + +| Variable | Values | Default | Description | +|----------|--------|---------|-------------| +| `EXPO_PUBLIC_APP_ENV` | `development`, `production` | unset | Overrides the detected runtime environment label | +| `EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS` | `true`, `false` | unset | Enables or disables push notification registration | +| `EXPO_PUBLIC_SENTRY_ENABLED` | `true`, `false` | unset | Controls Sentry error reporting (see below) | +| `EXPO_PUBLIC_STORYBOOK` | `true`, `false` | unset | Renders the Storybook UI instead of the app | +| `EXPO_PUBLIC_SENTRY_DSN` | DSN string | unset | Sentry project DSN used when Sentry is enabled | + +## Sentry Initialization + +Sentry is initialized according to the following logic in `src/config/logging.ts`: + +``` +isSentryEnabled = (EXPO_PUBLIC_SENTRY_ENABLED === 'true') OR (not a dev build) +``` + +In practice this means: + +- **Development builds** (`__DEV__ === true`, no env var set): Sentry is **off**. Exceptions are logged locally only and never sent to Sentry. This is the default for `expo start` and the `development` EAS profile. +- **Staging / preview builds** (`EXPO_PUBLIC_SENTRY_ENABLED=true`): Sentry is **on** even though `__DEV__` may be true. Use this for QA builds distributed via the `preview` EAS channel so the QA team captures real exceptions before a production release. +- **Production builds** (`__DEV__ === false`, env var unset or `true`): Sentry is **always on**. Setting `EXPO_PUBLIC_SENTRY_ENABLED=false` in production is intentionally ignored. + +The Sentry `environment` tag is set to `'staging'` for dev builds that opt in and `'production'` for release builds, letting you filter events in the Sentry dashboard. + +## Setting Up Local Development + +Create a `.env.local` file at the project root (never commit this file): + +``` +EXPO_PUBLIC_API_BASE_URL=https://api.dev.teachlink.com +EXPO_PUBLIC_SOCKET_URL=wss://ws.dev.teachlink.com +``` + +To opt in to Sentry during local development (e.g. debugging a crash reporter issue): + +``` +EXPO_PUBLIC_SENTRY_ENABLED=true +EXPO_PUBLIC_SENTRY_DSN= +``` + +## CI/CD + +The `preview` and `production` EAS profiles set `EXPO_PUBLIC_SENTRY_ENABLED=true` in `eas.json`. Secret variables (`EXPO_PUBLIC_SENTRY_DSN`, API keys) are stored in EAS Secrets and injected at build time - they are never committed to the repository. diff --git a/eas.json b/eas.json index 76e891e..3e35f99 100644 --- a/eas.json +++ b/eas.json @@ -10,6 +10,9 @@ "cache": { "key": "development-cache", "paths": ["node_modules", ".expo", "android/app/build", "ios/build"] + }, + "env": { + "EXPO_PUBLIC_SENTRY_ENABLED": "false" } }, "preview": { @@ -32,7 +35,8 @@ "paths": ["node_modules", ".expo"] }, "env": { - "NODE_ENV": "production" + "NODE_ENV": "production", + "EXPO_PUBLIC_SENTRY_ENABLED": "true" } }, "production": { @@ -74,7 +78,8 @@ "cacheDefaultPaths": true }, "env": { - "NODE_ENV": "production" + "NODE_ENV": "production", + "EXPO_PUBLIC_SENTRY_ENABLED": "true" } } }, diff --git a/src/__tests__/config/logging.test.ts b/src/__tests__/config/logging.test.ts index 63da51e..188bf5c 100644 --- a/src/__tests__/config/logging.test.ts +++ b/src/__tests__/config/logging.test.ts @@ -29,16 +29,18 @@ jest.mock('../../utils/storage', () => ({ safeStorageWrite: jest.fn(), })); -// Capture the beforeBreadcrumb callback passed to Sentry.init +// ─── beforeBreadcrumb PII scrubbing ─────────────────────────────────────── + let capturedBeforeBreadcrumb: ((b: Sentry.Breadcrumb) => Sentry.Breadcrumb | null) | null = null; -(Sentry.init as jest.Mock).mockImplementation((options: { beforeBreadcrumb?: (b: Sentry.Breadcrumb) => Sentry.Breadcrumb | null }) => { - capturedBeforeBreadcrumb = options.beforeBreadcrumb ?? null; -}); +(Sentry.init as jest.Mock).mockImplementation( + (options: { beforeBreadcrumb?: (b: Sentry.Breadcrumb) => Sentry.Breadcrumb | null }) => { + capturedBeforeBreadcrumb = options.beforeBreadcrumb ?? null; + } +); -describe('beforeBreadcrumb — PII scrubbing', () => { +describe('beforeBreadcrumb - PII scrubbing', () => { beforeAll(async () => { - // Force production mode so Sentry.init is called jest.resetModules(); jest.doMock('@sentry/react-native', () => ({ init: (opts: { beforeBreadcrumb?: (b: Sentry.Breadcrumb) => Sentry.Breadcrumb | null }) => { @@ -49,7 +51,6 @@ describe('beforeBreadcrumb — PII scrubbing', () => { captureMessage: jest.fn(), })); - // Patch __DEV__ to false so initializeLogging runs Sentry.init const original = (global as Record).__DEV__; (global as Record).__DEV__ = false; @@ -136,3 +137,107 @@ describe('beforeBreadcrumb — PII scrubbing', () => { expect(result?.data?.body).toEqual({ courseId: '42', page: 3 }); }); }); + +// ─── initializeLogging Sentry init gating ───────────────────────────────── + +function resetLoggingModule() { + jest.resetModules(); + jest.mock('@sentry/react-native', () => ({ + init: jest.fn(), + captureException: jest.fn(), + captureMessage: jest.fn(), + addBreadcrumb: jest.fn(), + setTag: jest.fn(), + setUser: jest.fn(), + configureScope: jest.fn(), + withScope: jest.fn(), + })); + jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), + multiRemove: jest.fn(() => Promise.resolve()), + })); + jest.mock('../../services/sentryContext', () => ({ + sentryContextService: { + buildCaptureContext: jest.fn(() => ({})), + getCurrentScreen: jest.fn(() => null), + }, + })); + jest.mock('../../utils/storage', () => ({ + safeStorageWrite: jest.fn(), + })); +} + +async function importAndInit() { + const mod = await import('../../config/logging'); + await mod.initializeLogging(); + return mod; +} + +describe('initializeLogging - Sentry init gating', () => { + const originalDev = (global as any).__DEV__; + + afterEach(() => { + (global as any).__DEV__ = originalDev; + delete process.env.EXPO_PUBLIC_SENTRY_ENABLED; + }); + + it('does NOT call Sentry.init in a dev build without the env var', async () => { + (global as any).__DEV__ = true; + delete process.env.EXPO_PUBLIC_SENTRY_ENABLED; + + resetLoggingModule(); + await importAndInit(); + + const { init } = require('@sentry/react-native'); + expect(init).not.toHaveBeenCalled(); + }); + + it('DOES call Sentry.init in a dev build when EXPO_PUBLIC_SENTRY_ENABLED=true', async () => { + (global as any).__DEV__ = true; + process.env.EXPO_PUBLIC_SENTRY_ENABLED = 'true'; + + resetLoggingModule(); + await importAndInit(); + + const { init } = require('@sentry/react-native'); + expect(init).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledWith(expect.objectContaining({ environment: 'staging' })); + }); + + it('DOES call Sentry.init in a production build regardless of env var', async () => { + (global as any).__DEV__ = false; + delete process.env.EXPO_PUBLIC_SENTRY_ENABLED; + + resetLoggingModule(); + await importAndInit(); + + const { init } = require('@sentry/react-native'); + expect(init).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledWith(expect.objectContaining({ environment: 'production' })); + }); + + it('DOES call Sentry.init in production even when env var is explicitly false', async () => { + (global as any).__DEV__ = false; + process.env.EXPO_PUBLIC_SENTRY_ENABLED = 'false'; + + resetLoggingModule(); + await importAndInit(); + + const { init } = require('@sentry/react-native'); + expect(init).toHaveBeenCalledTimes(1); + }); + + it('does NOT call Sentry.init in dev when env var is explicitly false', async () => { + (global as any).__DEV__ = true; + process.env.EXPO_PUBLIC_SENTRY_ENABLED = 'false'; + + resetLoggingModule(); + await importAndInit(); + + const { init } = require('@sentry/react-native'); + expect(init).not.toHaveBeenCalled(); + }); +}); diff --git a/src/config/env.ts b/src/config/env.ts index d3a052a..6d68ef8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -6,6 +6,7 @@ export interface EnvConfig { EXPO_PUBLIC_APP_ENV?: 'development' | 'production'; EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS?: 'true' | 'false'; EXPO_PUBLIC_STORYBOOK?: 'true' | 'false'; + EXPO_PUBLIC_SENTRY_ENABLED?: 'true' | 'false'; } const REQUIRED_VARIABLES: (keyof EnvConfig)[] = [ @@ -91,6 +92,16 @@ export function validateEnvVariables(): ValidationResult { } } + if (process.env.EXPO_PUBLIC_SENTRY_ENABLED) { + const sentryValue = process.env.EXPO_PUBLIC_SENTRY_ENABLED; + if (sentryValue !== 'true' && sentryValue !== 'false') { + errors.push( + `Invalid value for EXPO_PUBLIC_SENTRY_ENABLED: ${sentryValue}. ` + + `Allowed values are 'true' or 'false'.` + ); + } + } + return { valid: missing.length === 0 && errors.length === 0, message: errors.length > 0 ? errors.join(' ') : undefined, @@ -113,6 +124,7 @@ export function requireEnvVariables(): EnvConfig { process.env.EXPO_PUBLIC_APP_ENV === 'production' ? 'production' : 'development', EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS, EXPO_PUBLIC_STORYBOOK: process.env.EXPO_PUBLIC_STORYBOOK, + EXPO_PUBLIC_SENTRY_ENABLED: process.env.EXPO_PUBLIC_SENTRY_ENABLED as 'true' | 'false' | undefined, }; } diff --git a/src/config/logging.ts b/src/config/logging.ts index 61a7c79..006b2f4 100644 --- a/src/config/logging.ts +++ b/src/config/logging.ts @@ -92,6 +92,14 @@ function scrubSensitiveFields(obj: unknown): unknown { return result; } +// Sentry is enabled when explicitly opted in via env var OR when running a +// production build. Setting EXPO_PUBLIC_SENTRY_ENABLED=true in a dev/staging +// build (e.g. EAS preview channel) lets QA capture exceptions without needing +// a full production binary. Setting it to 'false' in production is ignored so +// that release builds always report to Sentry. +const isSentryEnabled = + process.env.EXPO_PUBLIC_SENTRY_ENABLED === 'true' || !isDev; + export enum LogLevel { ERROR = 0, WARN = 1, @@ -372,12 +380,13 @@ export async function initializeLogging(): Promise { } try { - // Initialize Sentry - if (!isDev) { + // Initialize Sentry — controlled by isSentryEnabled, not isDev directly. + // isDev still governs log verbosity below. + if (isSentryEnabled) { await Sentry.init({ dsn: process.env.EXPO_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.1, - environment: 'production', + environment: isDev ? 'staging' : 'production', // Capture 100% of sessions so replay / breadcrumb trails are always available replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0,