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
2 changes: 1 addition & 1 deletion src/lib/dsn/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ async function fullScanFirst(cwd: string): Promise<DetectedDsn | null> {
export function getDsnSourceDescription(dsn: DetectedDsn): string {
switch (dsn.source) {
case "env":
return `${SENTRY_DSN_ENV} environment variable`;
return `${dsn.sourcePath ?? SENTRY_DSN_ENV} environment variable`;
case "env_file":
return dsn.sourcePath ?? ".env file";
case "config":
Expand Down
20 changes: 16 additions & 4 deletions src/lib/dsn/env-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { opendir } from "node:fs/promises";
import { join } from "node:path";
import { withTracingSpan } from "../telemetry.js";
import { FRAMEWORK_ENV_PREFIXES } from "./env.js";
import { createDetectedDsn } from "./parser.js";
import { scanSpecificFiles } from "./scanner.js";
import type { DetectedDsn } from "./types.js";
Expand Down Expand Up @@ -44,11 +45,22 @@ export const ENV_FILES = [
] as const;

/**
* Pattern to match SENTRY_DSN in .env files.
* Handles: SENTRY_DSN=value, SENTRY_DSN="value", SENTRY_DSN='value'
* Also handles trailing comments: SENTRY_DSN=value # comment
* Pattern to match Sentry DSN variables in .env files.
* Matches the canonical `SENTRY_DSN` plus framework-prefixed variants
* from the shared {@link FRAMEWORK_ENV_PREFIXES} allowlist:
* NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, VITE_SENTRY_DSN,
* EXPO_PUBLIC_SENTRY_DSN, NUXT_PUBLIC_SENTRY_DSN.
*
* Handles: VAR=value, VAR="value", VAR='value'
* Also handles trailing comments: VAR=value # comment
*/
const ENV_DSN_PATTERN = /^SENTRY_DSN\s*=\s*(['"]?)(.+?)\1\s*(?:#.*)?$/;
const TRAILING_UNDERSCORE = /_$/;
const ENV_DSN_PREFIXES = FRAMEWORK_ENV_PREFIXES.map((p) =>
p.replace(TRAILING_UNDERSCORE, "")
).join("|");
const ENV_DSN_PATTERN = new RegExp(
`^(?:(?:${ENV_DSN_PREFIXES})_)?SENTRY_DSN\\s*=\\s*(['"]?)(.+?)\\1\\s*(?:#.*)?$`
);

/**
* Extract SENTRY_DSN value from .env file content.
Expand Down
42 changes: 37 additions & 5 deletions src/lib/dsn/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,31 @@ import type { DetectedDsn } from "./types.js";
export const SENTRY_DSN_ENV = "SENTRY_DSN";

/**
* Detect DSN from environment variable.
* Framework-specific env var prefixes that expose values to client-side code.
* Used to build both the runtime env var checklist and the .env file regex
* so that both detection paths match the same set of variable names.
*/
export const FRAMEWORK_ENV_PREFIXES = [
"NEXT_PUBLIC_",
"REACT_APP_",
"VITE_",
"EXPO_PUBLIC_",
"NUXT_PUBLIC_",
] as const;

/**
* Framework-prefixed env var names that commonly hold a Sentry DSN.
* Checked in order after `SENTRY_DSN` (canonical name has priority).
*/
const FRAMEWORK_DSN_VARS = FRAMEWORK_ENV_PREFIXES.map(
(prefix) => `${prefix}SENTRY_DSN`
);

/**
* Detect DSN from environment variables.
*
* Checks `SENTRY_DSN` first (canonical), then common framework-prefixed
* variants (NEXT_PUBLIC_SENTRY_DSN, REACT_APP_SENTRY_DSN, etc.).
*
* @returns Detected DSN or null if not set/invalid
*
Expand All @@ -23,10 +47,18 @@ export const SENTRY_DSN_ENV = "SENTRY_DSN";
* // { raw: "...", source: "env", projectId: "456", ... }
*/
export function detectFromEnv(): DetectedDsn | null {
const dsn = getEnv()[SENTRY_DSN_ENV];
if (!dsn) {
return null;
const env = getEnv();

const allVars = [SENTRY_DSN_ENV, ...FRAMEWORK_DSN_VARS];
for (const varName of allVars) {
const value = env[varName];
if (value) {
const detected = createDetectedDsn(value, "env", varName);
if (detected) {
return detected;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

return createDetectedDsn(dsn, "env");
return null;
}
11 changes: 8 additions & 3 deletions src/lib/dsn/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { z } from "zod";
/**
* Source where DSN was detected from
*
* - env: SENTRY_DSN environment variable
* - env: SENTRY_DSN or framework-prefixed environment variable (e.g., NEXT_PUBLIC_SENTRY_DSN)
* - env_file: .env file
* - config: Language-specific config file (e.g., sentry.properties)
* - code: Source code patterns (e.g., Sentry.init)
Expand Down Expand Up @@ -40,7 +40,12 @@ export type DetectedDsn = ParsedDsn & {
raw: string;
/** Where the DSN was found */
source: DsnSource;
/** File path (relative to cwd) if detected from file */
/**
* Context-dependent source identifier:
* - For `"env"`: the env var name (e.g., `"NEXT_PUBLIC_SENTRY_DSN"`)
* - For `"env_file"`, `"config"`, `"code"`: file path relative to cwd
* - For `"inferred"`: undefined
*/
sourcePath?: string;
/** Package/app directory path for monorepo grouping (e.g., "packages/frontend", "apps/web") */
packagePath?: string;
Expand Down Expand Up @@ -77,7 +82,7 @@ export type CachedDsnEntry = {
orgId?: string;
/** Where the DSN was found */
source: DsnSource;
/** Relative path to the source file */
/** Source identifier: env var name for `"env"`, relative file path for file-based sources */
sourcePath?: string;
/** Resolved project info (avoids API call on cache hit) */
resolved?: ResolvedProjectInfo;
Expand Down
17 changes: 17 additions & 0 deletions test/lib/dsn/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,23 @@ describe("DSN Detector (New Module)", () => {
);
});

test("describes env source with framework-prefixed var name", () => {
const dsn = {
raw: "https://key@o1.ingest.sentry.io/1",
source: "env" as const,
sourcePath: "NEXT_PUBLIC_SENTRY_DSN",
protocol: "https",
publicKey: "key",
host: "o1.ingest.sentry.io",
projectId: "1",
orgId: "1",
};

expect(getDsnSourceDescription(dsn)).toBe(
"NEXT_PUBLIC_SENTRY_DSN environment variable"
);
});

test("describes env_file source with path", () => {
const dsn = {
raw: "https://key@o1.ingest.sentry.io/1",
Expand Down
65 changes: 65 additions & 0 deletions test/lib/dsn/env-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,71 @@ SENTRY_DSN=https://correct@sentry.io/456`;
});
});

// ============================================================================
// Framework-Prefixed Env Var Tests
// ============================================================================

describe("extractDsnFromEnvContent framework-prefixed vars", () => {
test("extracts DSN from NEXT_PUBLIC_SENTRY_DSN", () => {
const content = "NEXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123");
});

test("extracts DSN from REACT_APP_SENTRY_DSN", () => {
const content = 'REACT_APP_SENTRY_DSN="https://key@sentry.io/123"';
expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123");
});

test("extracts DSN from VITE_SENTRY_DSN", () => {
const content = "VITE_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123");
});

test("extracts DSN from EXPO_PUBLIC_SENTRY_DSN", () => {
const content = "EXPO_PUBLIC_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123");
});

test("extracts DSN from NUXT_PUBLIC_SENTRY_DSN", () => {
const content = "NUXT_PUBLIC_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBe("https://key@sentry.io/123");
});

test("canonical SENTRY_DSN is returned when it appears first", () => {
const content = `SENTRY_DSN=https://canonical@sentry.io/1
NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2`;
expect(extractDsnFromEnvContent(content)).toBe(
"https://canonical@sentry.io/1"
);
});

test("framework-prefixed var returned when it appears before canonical", () => {
const content = `NEXT_PUBLIC_SENTRY_DSN=https://next@sentry.io/2
SENTRY_DSN=https://canonical@sentry.io/1`;
expect(extractDsnFromEnvContent(content)).toBe("https://next@sentry.io/2");
});

test("rejects unknown prefix MY_CUSTOM_SENTRY_DSN", () => {
const content = "MY_CUSTOM_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBeNull();
});

test("rejects unknown prefix GATSBY_SENTRY_DSN", () => {
const content = "GATSBY_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBeNull();
});

test("rejects bare underscore prefix _SENTRY_DSN", () => {
const content = "_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBeNull();
});

test("rejects VUE_APP_SENTRY_DSN (not in allowlist)", () => {
const content = "VUE_APP_SENTRY_DSN=https://key@sentry.io/123";
expect(extractDsnFromEnvContent(content)).toBeNull();
});
});

// ============================================================================
// Integration Tests for File-Based Detection
// ============================================================================
Expand Down
133 changes: 133 additions & 0 deletions test/lib/dsn/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Runtime Environment Variable Detection Tests
*
* Tests for detecting Sentry DSN from process.env, including
* framework-prefixed variants (NEXT_PUBLIC_SENTRY_DSN, etc.).
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { detectFromEnv, SENTRY_DSN_ENV } from "../../../src/lib/dsn/env.js";

const VALID_DSN = "https://abc123@o1.ingest.us.sentry.io/456";
const VALID_DSN_2 = "https://def456@o2.ingest.us.sentry.io/789";
const INVALID_DSN = "not-a-valid-dsn";

/** All env var names this module may read */
const ALL_DSN_VARS = [
"SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_DSN",
"REACT_APP_SENTRY_DSN",
"VITE_SENTRY_DSN",
"EXPO_PUBLIC_SENTRY_DSN",
"NUXT_PUBLIC_SENTRY_DSN",
] as const;

/** Saved env values for cleanup */
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
savedEnv = {};
for (const key of ALL_DSN_VARS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
for (const key of ALL_DSN_VARS) {
if (savedEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = savedEnv[key];
}
}
});

describe("detectFromEnv", () => {
test("returns null when no DSN env vars are set", () => {
expect(detectFromEnv()).toBeNull();
});

test("detects canonical SENTRY_DSN", () => {
process.env.SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result).not.toBeNull();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.source).toBe("env");
expect(result?.sourcePath).toBe(SENTRY_DSN_ENV);
});

test("detects NEXT_PUBLIC_SENTRY_DSN", () => {
process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result).not.toBeNull();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN");
});

test("detects REACT_APP_SENTRY_DSN", () => {
process.env.REACT_APP_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("REACT_APP_SENTRY_DSN");
});

test("detects VITE_SENTRY_DSN", () => {
process.env.VITE_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("VITE_SENTRY_DSN");
});

test("detects EXPO_PUBLIC_SENTRY_DSN", () => {
process.env.EXPO_PUBLIC_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("EXPO_PUBLIC_SENTRY_DSN");
});

test("detects NUXT_PUBLIC_SENTRY_DSN", () => {
process.env.NUXT_PUBLIC_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("NUXT_PUBLIC_SENTRY_DSN");
});

test("canonical SENTRY_DSN takes priority over framework-prefixed vars", () => {
process.env.SENTRY_DSN = VALID_DSN;
process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN_2;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe(SENTRY_DSN_ENV);
});

test("skips invalid DSN in SENTRY_DSN and falls through to framework var", () => {
process.env.SENTRY_DSN = INVALID_DSN;
process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result).not.toBeNull();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("NEXT_PUBLIC_SENTRY_DSN");
});

test("skips invalid DSN in framework var and continues to next", () => {
process.env.NEXT_PUBLIC_SENTRY_DSN = INVALID_DSN;
process.env.VITE_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
expect(result?.sourcePath).toBe("VITE_SENTRY_DSN");
});

test("returns null when all set vars contain invalid DSNs", () => {
process.env.SENTRY_DSN = INVALID_DSN;
process.env.NEXT_PUBLIC_SENTRY_DSN = "also-not-valid";
expect(detectFromEnv()).toBeNull();
});

test("ignores empty string values", () => {
process.env.SENTRY_DSN = "";
process.env.NEXT_PUBLIC_SENTRY_DSN = VALID_DSN;
const result = detectFromEnv();
expect(result?.raw).toBe(VALID_DSN);
});
});
Loading