Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
68 changes: 68 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
@@ -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=<your-dev-project-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.
9 changes: 7 additions & 2 deletions eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"cache": {
"key": "development-cache",
"paths": ["node_modules", ".expo", "android/app/build", "ios/build"]
},
"env": {
"EXPO_PUBLIC_SENTRY_ENABLED": "false"
}
},
"preview": {
Expand All @@ -32,7 +35,8 @@
"paths": ["node_modules", ".expo"]
},
"env": {
"NODE_ENV": "production"
"NODE_ENV": "production",
"EXPO_PUBLIC_SENTRY_ENABLED": "true"
}
},
"production": {
Expand Down Expand Up @@ -74,7 +78,8 @@
"cacheDefaultPaths": true
},
"env": {
"NODE_ENV": "production"
"NODE_ENV": "production",
"EXPO_PUBLIC_SENTRY_ENABLED": "true"
}
}
},
Expand Down
119 changes: 112 additions & 7 deletions src/__tests__/config/logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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<string, unknown>).__DEV__;
(global as Record<string, unknown>).__DEV__ = false;

Expand Down Expand Up @@ -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();
});
});
12 changes: 12 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[] = [
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}

Expand Down
15 changes: 12 additions & 3 deletions src/config/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -372,12 +380,13 @@ export async function initializeLogging(): Promise<void> {
}

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,
Expand Down
Loading