From dc1c90442fd7b24af37925d4969aca64be363433 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:51:02 -0700 Subject: [PATCH] feat(discovery-index): instrument with Sentry error tracking and release/sourcemap upload (#4934) Reports route/process-level errors into the shared metagraphed Sentry project (same one the rest of the infra already flows into), with secret-field and secret-value redaction before anything leaves the process. Source maps and release/deploy tracking run via upload-sourcemaps.ts at container startup, so SENTRY_AUTH_TOKEN stays a runtime-only secret, never baked into an image layer. --- package-lock.json | 188 +++++++- packages/discovery-index/Dockerfile | 13 +- packages/discovery-index/OPERATIONS.md | 2 + packages/discovery-index/README.md | 11 + packages/discovery-index/package.json | 3 + .../scripts/validate-sentry-release.mjs | 244 ++++++++++ .../scripts/validate-sourcemaps.mjs | 66 +++ packages/discovery-index/src/app.ts | 2 + packages/discovery-index/src/env.d.ts | 11 + packages/discovery-index/src/sentry.ts | 197 ++++++++ packages/discovery-index/src/server.ts | 17 +- .../discovery-index/src/upload-sourcemaps.ts | 216 +++++++++ packages/discovery-index/src/worker.ts | 17 + packages/discovery-index/tsconfig.json | 6 + .../discovery-index/worker-configuration.d.ts | 6 +- packages/discovery-index/wrangler.jsonc | 19 +- test/unit/discovery-index/sentry.test.ts | 322 +++++++++++++ .../discovery-index/upload-sourcemaps.test.ts | 448 ++++++++++++++++++ 18 files changed, 1782 insertions(+), 6 deletions(-) create mode 100644 packages/discovery-index/scripts/validate-sentry-release.mjs create mode 100644 packages/discovery-index/scripts/validate-sourcemaps.mjs create mode 100644 packages/discovery-index/src/sentry.ts create mode 100644 packages/discovery-index/src/upload-sourcemaps.ts create mode 100644 test/unit/discovery-index/sentry.test.ts create mode 100644 test/unit/discovery-index/upload-sourcemaps.test.ts diff --git a/package-lock.json b/package-lock.json index 92c4d2934e..a45a58327e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6830,6 +6830,191 @@ "node": ">=18" } }, + "node_modules/@sentry/cli": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.6.1.tgz", + "integrity": "sha512-9KGZEe/o+vdHS69JhkNztPysqknnI3W5cceovi2G3yb5z29At6SAbGGOdFe3qxYDfC/c2Uw5MKYfev8K9xFI9Q==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.6.1", + "@sentry/cli-linux-arm": "3.6.1", + "@sentry/cli-linux-arm64": "3.6.1", + "@sentry/cli-linux-i686": "3.6.1", + "@sentry/cli-linux-x64": "3.6.1", + "@sentry/cli-win32-arm64": "3.6.1", + "@sentry/cli-win32-i686": "3.6.1", + "@sentry/cli-win32-x64": "3.6.1" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.6.1.tgz", + "integrity": "sha512-hit8SHXYTSZnKvT87/n/5RLwJnl0Nm24dewqEXwMzw7YkDFg7Ptq0bIhvGWINgyuBsmEfXGMnrbBZw5sueBtnQ==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.6.1.tgz", + "integrity": "sha512-FgfKPa4sXianCQxpujNE1JrJYbF6Ug+UPjwdEGAnCZtDeRRQdXbSIih7ikaocg4BcpHxwoRm0BHG1wYeizkCbQ==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.6.1.tgz", + "integrity": "sha512-+6UpbQo56wW5pf1TTBfNoYzf+WuG7c1fj9DAKchQwUzDN7886OwSYC326rDyh/PaafszYj5gAOElFhymNVA1WA==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.6.1.tgz", + "integrity": "sha512-xDzb2knLayJKv6FGggjrfvQiIpA4wvYvxeNV6B3plTlu1Qunkz5889A+Ayrp4wv19bgMVI6wLdKF9U2TpHPTbQ==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.6.1.tgz", + "integrity": "sha512-OJChq61ZoKFLAVC+LPqensvBcoToxnAfAZF19emTLNQNjBbGZBL6G/4P0uK4tlwlhB5wAeobu58xX2kfJEgkjA==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.6.1.tgz", + "integrity": "sha512-SR4JESQdr1+XmK3kAVa9BAUdOA9QYkreJkWzUlj1oXbTlD5OdRmzc1yMHlkrkbbKSu6vrlqYiEaox3Pgsc08sg==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.6.1.tgz", + "integrity": "sha512-EBOR2Yx5nYUcwbGXp5AHuQPjNZ9QLcoKlTKAt9ygQX4SF7S0pwnrAfXktkA4QOZqSXtzlKhrpwwnWNq0zPksNw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.6.1.tgz", + "integrity": "sha512-qQkVQOMoE5U6NtigezFNYSJncP1DOuauvp7JS+1DRpE5pXTKjHkOT6niWceGQc7wH4UGk6iwzOkNzx6klNd4Yw==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@sentry/cli/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@sentry/cloudflare": { "version": "10.65.0", "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.65.0.tgz", @@ -19749,7 +19934,6 @@ "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.17" @@ -20833,6 +21017,8 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^1.19.14", "@loopover/engine": "^3.4.0", + "@sentry/cli": "^3.6.0", + "@sentry/node": "^10.63.0", "hono": "^4.12.27" }, "devDependencies": { diff --git a/packages/discovery-index/Dockerfile b/packages/discovery-index/Dockerfile index e5fd5b83b6..52991a27de 100644 --- a/packages/discovery-index/Dockerfile +++ b/packages/discovery-index/Dockerfile @@ -14,6 +14,7 @@ COPY packages/loopover-engine packages/loopover-engine COPY packages/discovery-index packages/discovery-index RUN npm run build --workspace @loopover/engine \ && npm run build --workspace @loopover/discovery-index \ + && npm run validate:sourcemaps --workspace @loopover/discovery-index \ && npm prune --omit=dev --workspace @loopover/discovery-index FROM node:22-slim AS runtime @@ -32,5 +33,13 @@ USER discovery-index ENV PORT=8080 EXPOSE 8080 # Provide at runtime (NOT baked into the image): DISCOVERY_INDEX_SHARED_SECRET (bearer auth for callers), -# DISCOVERY_INDEX_GITHUB_TOKEN (this service's own GitHub token, isolated from any other component's). -CMD ["node", "dist/server.js"] +# DISCOVERY_INDEX_GITHUB_TOKEN (this service's own GitHub token, isolated from any other component's), +# SENTRY_DSN/SENTRY_ENVIRONMENT/SENTRY_RELEASE (error tracking, #4934 -- optional, no-op when SENTRY_DSN is +# unset), and for source-map upload specifically: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT (also +# optional -- upload-sourcemaps.ts skips itself with a log line, never fails the boot, when any is unset). +# +# upload-sourcemaps.js runs FIRST, at container startup (not baked into the image at build time, since +# SENTRY_AUTH_TOKEN is a runtime-only secret) -- deletes the .map files it just uploaded before the server +# starts, so they're never served or left on disk in the running container. Mirrors +# review-enrichment/Dockerfile's identical CMD shape. +CMD ["sh", "-c", "node dist/upload-sourcemaps.js && find dist -type f -name '*.map' -delete && node dist/server.js"] diff --git a/packages/discovery-index/OPERATIONS.md b/packages/discovery-index/OPERATIONS.md index d862e4b275..39cdabcb44 100644 --- a/packages/discovery-index/OPERATIONS.md +++ b/packages/discovery-index/OPERATIONS.md @@ -51,3 +51,5 @@ All opted-in miners authenticate with **one shared `DISCOVERY_INDEX_SHARED_SECRE ## Monitoring `/health` (liveness) and `/ready` (readiness — reports whether `DISCOVERY_INDEX_GITHUB_TOKEN` is configured) are the two routes an external uptime check should poll; both are exempt from the rate limit. `/metrics` exposes Prometheus-format counters/histograms for request outcomes and latency. This service is **not** wired into the self-host fleet's own Grafana/Alloy stack — it isn't a self-hosted instance, it's the one hosted plane, deployed and observed separately (Cloudflare's own Worker observability, per `wrangler.jsonc`) rather than folded into infrastructure that assumes a self-hoster's own box. + +**Error tracking (#4934):** route/process-level errors report into the shared `metagraphed` Sentry project (`jsonbored` org), the same project other infra/operational pieces already flow into — check it first when triaging a spike in `/metrics`' error counters, before digging into Cloudflare's own Worker logs. See `src/sentry.ts` for what gets redacted before an event ever leaves the process, and `README.md`'s "Error tracking & releases" section for the source-map/release-upload path that makes those Sentry stack traces point at real TypeScript instead of a minified bundle. diff --git a/packages/discovery-index/README.md b/packages/discovery-index/README.md index 235289b500..afc985f23e 100644 --- a/packages/discovery-index/README.md +++ b/packages/discovery-index/README.md @@ -27,6 +27,17 @@ Soft-claim design note: the shipped client payload never carries caller identity | `DISCOVERY_INDEX_CACHE_TTL_MS` | TTL for cached query results, per unique `(repos, orgs, searchTerms)` scope. Default `300000` (5 minutes). | | `DISCOVERY_INDEX_SOFT_CLAIM_TTL_MS` | TTL for a soft claim before it's reclaimable. Default `1800000` (30 minutes). | | `PORT` | HTTP port. Default `8080`. | +| `SENTRY_DSN` | Error tracking (#4934), reporting into the shared `metagraphed` Sentry project. Unset ⇒ Sentry stays inert (no-op) everywhere, matching this repo's other services. Non-sensitive (write-only ingest URL) — set as a plain `wrangler.jsonc` var, not a secret. | +| `SENTRY_ENVIRONMENT` | Sentry environment tag. Default `production`. | +| `SENTRY_RELEASE` | This deploy's release identifier (`loopover-discovery-index@`). Set per-deploy via `wrangler deploy --var`, not a static config value — see `wrangler.jsonc`'s header comment. Falls back to deriving one from `SENTRY_COMMIT_SHA` if unset. | +| `SENTRY_AUTH_TOKEN` | Full Sentry API token, used only by `upload-sourcemaps.ts` at container startup to upload this build's source maps and manage the release. Genuinely sensitive — always a `wrangler secret`. Unset ⇒ source-map upload skips itself with a log line; never fails the boot. | +| `SENTRY_ORG` / `SENTRY_PROJECT` | Sentry org/project slugs for source-map upload. Same non-sensitive/plain-var treatment as `SENTRY_DSN`. | + +## Error tracking & releases (#4934) + +Runtime errors (route handler failures, unhandled rejections/exceptions) report into the shared **`metagraphed`** Sentry project (`jsonbored` org) that this repo's other infra/operational pieces already flow into — not a separate project. See `src/sentry.ts` for the full redaction rules (secret-named fields and GitHub-token-shaped values are always filtered before anything leaves the process) and `OPERATIONS.md`'s Monitoring section for how this fits alongside `/metrics`. + +Source maps and release/deploy tracking are handled by `src/upload-sourcemaps.ts`, which runs as the **first step of the container's runtime `CMD`** (not at Docker build time — `SENTRY_AUTH_TOKEN` is a runtime-only secret, never baked into an image layer): it creates the Sentry release, associates commits, uploads and validates source maps, then records a deploy and finalizes the release, before deleting the `.map` files it just uploaded and starting `server.ts`. Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT=true` is set. Mirrors `review-enrichment/src/upload-sourcemaps.ts`'s identical design. ## Deployment diff --git a/packages/discovery-index/package.json b/packages/discovery-index/package.json index 9ca454b79e..e458ab47ae 100644 --- a/packages/discovery-index/package.json +++ b/packages/discovery-index/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", + "validate:sourcemaps": "node scripts/validate-sourcemaps.mjs", "start": "node dist/server.js", "dev": "node --experimental-strip-types --watch src/server.ts", "cf:dev": "wrangler dev", @@ -20,6 +21,8 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^1.19.14", "@loopover/engine": "^3.4.0", + "@sentry/cli": "^3.6.0", + "@sentry/node": "^10.63.0", "hono": "^4.12.27" }, "devDependencies": { diff --git a/packages/discovery-index/scripts/validate-sentry-release.mjs b/packages/discovery-index/scripts/validate-sentry-release.mjs new file mode 100644 index 0000000000..3e13bde70f --- /dev/null +++ b/packages/discovery-index/scripts/validate-sentry-release.mjs @@ -0,0 +1,244 @@ +// Generic Sentry release/deploy verification, mirroring review-enrichment/scripts/validate-sentry-release.mjs +// (org/project-agnostic -- adapted here only by dropping that copy's Railway-specific env-var fallbacks, +// since discovery-index deploys via Cloudflare Containers, not Railway). +import { pathToFileURL } from "node:url"; + +const DEFAULT_SENTRY_URL = "https://sentry.io"; + +const TRUE_VALUE = /^(1|true|yes|on)$/i; +const FALSE_VALUE = /^(0|false|no|off)$/i; + +export class SentryReleaseValidationError extends Error { + constructor(message, failures = []) { + super(message); + this.name = "SentryReleaseValidationError"; + this.failures = failures; + } +} + +function nonBlank(value) { + const text = typeof value === "string" ? value.trim() : undefined; + return text ? text : undefined; +} + +function boolEnv(value, fallback) { + const text = nonBlank(value); + if (!text) return fallback; + if (TRUE_VALUE.test(text)) return true; + if (FALSE_VALUE.test(text)) return false; + return fallback; +} + +function apiBaseUrl(value) { + return (nonBlank(value) ?? DEFAULT_SENTRY_URL).replace(/\/+$/, ""); +} + +export function loadSentryReleaseValidationConfig(env = process.env) { + return { + authToken: nonBlank(env.SENTRY_AUTH_TOKEN), + org: nonBlank(env.SENTRY_ORG), + project: nonBlank(env.SENTRY_PROJECT), + release: nonBlank(env.SENTRY_RELEASE), + baseUrl: apiBaseUrl(env.SENTRY_URL), + expectedCommitSha: nonBlank(env.SENTRY_EXPECT_COMMIT_SHA) ?? nonBlank(env.SENTRY_COMMIT_SHA), + expectedDeployName: nonBlank(env.SENTRY_DEPLOY_NAME), + expectedEnvironment: nonBlank(env.SENTRY_ENVIRONMENT) ?? "production", + requireCommits: boolEnv(env.SENTRY_REQUIRE_COMMITS, true), + requireDeploy: boolEnv(env.SENTRY_REQUIRE_DEPLOY, false), + requireFinalized: boolEnv(env.SENTRY_REQUIRE_FINALIZED, true), + requireReleaseFiles: boolEnv(env.SENTRY_REQUIRE_RELEASE_FILES, false), + }; +} + +function requireConfig(config) { + const missing = [ + ["SENTRY_AUTH_TOKEN", config.authToken], + ["SENTRY_ORG", config.org], + ["SENTRY_PROJECT", config.project], + ["SENTRY_RELEASE", config.release], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0) { + throw new SentryReleaseValidationError("missing Sentry release validation config", [`missing ${missing.join(", ")}`]); + } +} + +function apiUrl(config, segments) { + const encoded = segments.map((segment) => encodeURIComponent(segment)).join("/"); + return `${config.baseUrl}/api/0/${encoded}/`; +} + +async function sentryJson(config, segments, fetchImpl) { + const response = await fetchImpl(apiUrl(config, segments), { + headers: { accept: "application/json", authorization: `Bearer ${config.authToken}` }, + }); + if (!response.ok) { + let message = response.statusText; + try { + const body = await response.json(); + message = body?.detail ?? body?.error ?? body?.message ?? message; + } catch { + /* Keep the status text when the body is not JSON. */ + } + throw new SentryReleaseValidationError("Sentry API request failed", [`${segments.join("/")} returned HTTP ${response.status}${message ? ` (${message})` : ""}`]); + } + return response.json(); +} + +function asArray(value) { + if (Array.isArray(value)) return value; + if (value && Array.isArray(value.data)) return value.data; + return []; +} + +function stringField(value, keys) { + if (!value || typeof value !== "object") return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === "string" && field.trim()) return field.trim(); + } + return undefined; +} + +function releaseProjects(release) { + return asArray(release?.projects).map((project) => stringField(project, ["slug", "name"])).filter(Boolean); +} + +function isFinalized(release) { + return Boolean(stringField(release, ["dateReleased", "released", "releaseDate"])); +} + +function commitIdsFrom(value, results = []) { + if (!value || typeof value !== "object") return results; + for (const key of ["id", "sha", "commitId", "shortId"]) { + const id = value[key]; + if (typeof id === "string" && id.trim()) results.push(id.trim()); + } + for (const key of ["commit", "lastCommit", "previousCommit"]) commitIdsFrom(value[key], results); + return results; +} + +function commitMatches(expected, candidates) { + const wanted = expected.toLowerCase(); + return candidates.some((candidate) => { + const got = candidate.toLowerCase(); + return got === wanted || got.startsWith(wanted) || wanted.startsWith(got); + }); +} + +function deployField(deploy, keys) { + if (!deploy || typeof deploy !== "object") return undefined; + for (const key of keys) { + const value = deploy[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object") { + const nested = stringField(value, ["name", "slug", "id"]); + if (nested) return nested; + } + } + return undefined; +} + +function deployMatches(deploy, config) { + const name = deployField(deploy, ["name", "id"]); + const environment = deployField(deploy, ["environment", "env"]); + if (config.expectedDeployName && name !== config.expectedDeployName) return false; + if (config.expectedEnvironment && environment !== config.expectedEnvironment) return false; + return true; +} + +function log(event, fields = {}) { + console.log(JSON.stringify({ event, ...fields })); +} + +function logError(event, fields = {}) { + console.error(JSON.stringify({ level: "error", event, ...fields })); +} + +export async function validateSentryRelease(env = process.env, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== "function") { + throw new SentryReleaseValidationError("fetch is unavailable", ["Node 20+ fetch support is required"]); + } + + const config = loadSentryReleaseValidationConfig(env); + requireConfig(config); + + const release = await sentryJson(config, ["organizations", config.org, "releases", config.release], fetchImpl); + + const failures = []; + if (release?.version && release.version !== config.release) { + failures.push(`release version mismatch: expected ${config.release}, got ${release.version}`); + } + + const projects = releaseProjects(release); + if (projects.length > 0 && !projects.includes(config.project)) { + failures.push(`release is not associated with Sentry project ${config.project}`); + } + + if (config.requireFinalized && !isFinalized(release)) { + failures.push("release is not finalized"); + } + + let commits = []; + if (config.requireCommits) { + commits = asArray(await sentryJson(config, ["organizations", config.org, "releases", config.release, "commits"], fetchImpl)); + const commitCount = typeof release?.commitCount === "number" ? release.commitCount : commits.length; + const commitIds = [...commitIdsFrom(release), ...commits.flatMap((commit) => commitIdsFrom(commit))]; + if (commitCount <= 0 && commitIds.length === 0) { + failures.push("release has no associated commits"); + } + if (config.expectedCommitSha && !commitMatches(config.expectedCommitSha, commitIds)) { + failures.push(`release commits do not include expected commit ${config.expectedCommitSha}`); + } + } + + let deploys = []; + if (config.requireDeploy) { + deploys = asArray(await sentryJson(config, ["organizations", config.org, "releases", config.release, "deploys"], fetchImpl)); + const deployCount = typeof release?.deployCount === "number" ? release.deployCount : deploys.length; + const releaseDeploy = release?.lastDeploy ? [release.lastDeploy] : []; + const allDeploys = [...deploys, ...releaseDeploy]; + if (deployCount <= 0 && allDeploys.length === 0) { + failures.push("release has no associated deploys"); + } else if (!allDeploys.some((deploy) => deployMatches(deploy, config))) { + failures.push(`release deploys do not include ${config.expectedEnvironment}/${config.expectedDeployName ?? "any"}`); + } + } + + let releaseFiles = []; + if (config.requireReleaseFiles) { + releaseFiles = asArray(await sentryJson(config, ["projects", config.org, config.project, "releases", config.release, "files"], fetchImpl)); + if (releaseFiles.length === 0) { + failures.push("release has no release files"); + } + } + + if (failures.length > 0) { + throw new SentryReleaseValidationError("Sentry release validation failed", failures); + } + + return { + release: config.release, + project: config.project, + finalized: isFinalized(release), + commitCount: typeof release?.commitCount === "number" ? release.commitCount : commits.length, + deployCount: typeof release?.deployCount === "number" ? release.deployCount : deploys.length, + releaseFileCount: releaseFiles.length, + }; +} + +async function main() { + try { + const result = await validateSentryRelease(); + log("discovery_index_sentry_release_validation_complete", result); + } catch (error) { + const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; + logError("discovery_index_sentry_release_validation_failed", { release: nonBlank(process.env.SENTRY_RELEASE), failures }); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/packages/discovery-index/scripts/validate-sourcemaps.mjs b/packages/discovery-index/scripts/validate-sourcemaps.mjs new file mode 100644 index 0000000000..05019fbce0 --- /dev/null +++ b/packages/discovery-index/scripts/validate-sourcemaps.mjs @@ -0,0 +1,66 @@ +// Mirrors review-enrichment/scripts/validate-sourcemaps.mjs (a comparably-sized standalone service with the +// identical Sentry source-map setup) -- verifies the build actually produced usable source maps before +// they're relied on for upload/symbolication. +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = resolve(root, "dist"); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +if (!existsSync(distDir)) fail("discovery-index/dist is missing; run npm run build first"); + +const serverBundle = resolve(distDir, "server.js"); +const serverMap = resolve(distDir, "server.js.map"); +if (!existsSync(serverBundle)) fail("discovery-index/dist/server.js is missing"); +if (!existsSync(serverMap)) fail("discovery-index/dist/server.js.map is missing"); + +const bundle = readFileSync(serverBundle, "utf8"); +if (!bundle.includes("//# sourceMappingURL=server.js.map")) { + fail("discovery-index/dist/server.js is missing the server.js.map sourceMappingURL"); +} + +const maps = listFiles(distDir).filter((path) => path.endsWith(".js.map")); +if (maps.length === 0) fail("discovery-index/dist has no JavaScript source maps"); + +let sawServerSource = false; +for (const path of maps) { + let map; + try { + map = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`${relative(root, path)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!Array.isArray(map.sources) || map.sources.length === 0) { + fail(`${relative(root, path)} has no original sources`); + } + if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + fail(`${relative(root, path)} does not embed sourcesContent for every source`); + } + if (!map.sourcesContent.some((source) => typeof source === "string" && source.trim().length > 0)) { + fail(`${relative(root, path)} has empty sourcesContent`); + } + if (map.sources.some((source) => String(source).replaceAll("\\", "/").endsWith("src/server.ts"))) { + sawServerSource = true; + } +} + +if (!sawServerSource) fail("discovery-index source maps do not include src/server.ts"); + +console.log(`validated ${maps.length} discovery-index source map${maps.length === 1 ? "" : "s"}`); diff --git a/packages/discovery-index/src/app.ts b/packages/discovery-index/src/app.ts index 29f9c5e5d8..ccef3501fa 100644 --- a/packages/discovery-index/src/app.ts +++ b/packages/discovery-index/src/app.ts @@ -15,6 +15,7 @@ import { normalizeSharedSecret, verifyBearer } from "./auth.js"; import type { TtlCache } from "./cache.js"; import { runDiscoveryQuery, type GitHubClientLike } from "./discovery-query.js"; import { incr, observe, renderMetrics } from "./metrics.js"; +import { captureRouteError } from "./sentry.js"; import { parseSoftClaimRequest, softClaimKey, type SoftClaimStoreLike } from "./soft-claim.js"; export interface AppDeps { @@ -48,6 +49,7 @@ export function createApp(deps: AppDeps): Hono { // Hono's ErrorHandler type guarantees `error: Error | HTTPResponseError` -- both carry `.message` -- so // there is no non-Error case here to guard against, unlike a bare `catch (error: unknown)`. console.error(JSON.stringify({ event: "discovery_index_error", route: c.req.path, message: error.message })); + captureRouteError(error, { route: c.req.path, method: c.req.method }); return c.json({ error: "internal_error" }, 500); }); diff --git a/packages/discovery-index/src/env.d.ts b/packages/discovery-index/src/env.d.ts index 314680da93..71ea4eba6d 100644 --- a/packages/discovery-index/src/env.d.ts +++ b/packages/discovery-index/src/env.d.ts @@ -11,6 +11,15 @@ declare global { DISCOVERY_INDEX_SHARED_SECRET: string; /** This service's own GitHub token, isolated from any other component's. */ DISCOVERY_INDEX_GITHUB_TOKEN: string; + /** Full Sentry API access (releases, source-map upload) -- unlike SENTRY_DSN/ORG/PROJECT (wrangler.jsonc + * `vars`, non-sensitive), this is genuinely sensitive and stays a secret. Optional: upload-sourcemaps.ts + * no-ops with a log line when unset, never fails the boot. */ + SENTRY_AUTH_TOKEN?: string; + /** The deploy's release identifier (loopover-discovery-index@) -- deliberately NOT a static + * wrangler.jsonc var since it must change every deploy; set via `wrangler deploy --var + * SENTRY_RELEASE:...` (see wrangler.jsonc's own header comment). Optional: initSentry falls back to + * deriving one from SENTRY_COMMIT_SHA, else omits release tagging entirely. */ + SENTRY_RELEASE?: string; } // `import { env } from "cloudflare:workers"` (used in worker.ts's Container class field initializers, @@ -20,6 +29,8 @@ declare global { interface Env { DISCOVERY_INDEX_SHARED_SECRET: string; DISCOVERY_INDEX_GITHUB_TOKEN: string; + SENTRY_AUTH_TOKEN?: string; + SENTRY_RELEASE?: string; } } } diff --git a/packages/discovery-index/src/sentry.ts b/packages/discovery-index/src/sentry.ts new file mode 100644 index 0000000000..971a435952 --- /dev/null +++ b/packages/discovery-index/src/sentry.ts @@ -0,0 +1,197 @@ +// Error tracking for the discovery-index service (#4934), reporting into the shared "metagraphed" Sentry +// project other infra/operational pieces already flow into (jsonbored org) -- not a new, separate project. +// Deliberately mirrors review-enrichment/src/sentry.ts's shape (a comparably-sized standalone service), +// not the main app's much larger src/selfhost/sentry.ts (self-host-operator opt-in, dozens of redaction +// rules, cron monitors, OpenTelemetry bridging) -- this service has a much smaller secret surface +// (DISCOVERY_INDEX_SHARED_SECRET, DISCOVERY_INDEX_GITHUB_TOKEN) and no per-operator opt-in model to begin +// with (it's the one hosted plane, not self-hosted). +import type { ErrorEvent, EventHint } from "@sentry/node"; + +type SentryNs = typeof import("@sentry/node"); +type SentryClient = Pick; +type SentryScope = { + setContext(name: string, context: Record): unknown; + setFingerprint(fingerprint: string[]): unknown; + setLevel(level: "error" | "warning"): unknown; + setTag(key: string, value: string): unknown; +}; + +let Sentry: SentryClient | undefined; +let active = false; +let activeRelease: string | undefined; +let activeEnvironment = "production"; + +// Field-name-based redaction is the primary defense (DISCOVERY_INDEX_SHARED_SECRET is an arbitrary +// operator-set value with no fixed shape to pattern-match) -- these value patterns are a secondary net for +// the one secret that DOES have a recognizable shape, DISCOVERY_INDEX_GITHUB_TOKEN (GitHub's own token +// prefixes, same patterns review-enrichment's own SECRET_VALUE already covers). +const SECRET_FIELD = /(?:authorization|cookie|token|secret|password|private[_-]?key|shared[_-]?secret)/i; +const SECRET_VALUE = /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g; +const DISCOVERY_INDEX_SENTRY_TAG_KEYS = ["event", "route", "method", "release", "environment"] as const; + +type DiscoveryIndexSentryTagKey = (typeof DISCOVERY_INDEX_SENTRY_TAG_KEYS)[number]; +type DiscoveryIndexSentryTags = Partial>; +type CaptureOptions = { + contextName: string; + context: Record; + fingerprint: string[]; + level?: "error" | "warning"; + tags: DiscoveryIndexSentryTags; +}; + +function nonBlank(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +export function resolveDiscoveryIndexSentryRelease(env: NodeJS.ProcessEnv): string | undefined { + return nonBlank(env.SENTRY_RELEASE) ?? (nonBlank(env.SENTRY_COMMIT_SHA) ? `loopover-discovery-index@${nonBlank(env.SENTRY_COMMIT_SHA)}` : undefined); +} + +export function resolveSentryEnvironment(env: NodeJS.ProcessEnv): string { + return nonBlank(env.SENTRY_ENVIRONMENT) ?? "production"; +} + +export function resolveTracesSampleRate(env: NodeJS.ProcessEnv): number { + const rate = Number(env.SENTRY_TRACES_SAMPLE_RATE ?? "0"); + if (!Number.isFinite(rate)) return 0; + return Math.max(0, Math.min(1, rate)); +} + +function warn(event: string, fields: Record = {}): void { + console.error(JSON.stringify({ level: "warn", event, ...fields })); +} + +function scrubValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => scrubValue(entry)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [key, SECRET_FIELD.test(key) ? "[Filtered]" : scrubValue(entry)]), + ); + } + if (typeof value === "string") return value.replace(SECRET_VALUE, "[Filtered]"); + return value; +} + +function sentryTagValue(value: string | number | undefined): string | undefined { + if (value === undefined) return undefined; + const scrubbed = scrubValue(String(value)); + // scrubValue's string branch (the only one reachable here, since String(value) is always a string) always + // returns a string -- this check has no live "true" side through this call path. Retained defensively in + // case scrubValue's own shape ever changes; see safe-url.ts's identical "unreachable through the real + // entry point" pattern for the same reasoning. + /* v8 ignore next -- @preserve unreachable: scrubValue(string) always returns a string */ + if (typeof scrubbed !== "string") return undefined; + const text = nonBlank(scrubbed); + return text ? text.slice(0, 200) : undefined; +} + +function compactContext(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); +} + +function setAllowedTags(scope: Pick, tags: DiscoveryIndexSentryTags): void { + for (const key of DISCOVERY_INDEX_SENTRY_TAG_KEYS) { + const value = sentryTagValue(tags[key]); + if (value) scope.setTag(key, value); + } +} + +function setFingerprint(scope: Pick, parts: string[]): void { + const safeParts = parts.map((part) => sentryTagValue(part) ?? "unknown"); + scope.setFingerprint(safeParts); +} + +function captureScopedError(error: unknown, options: CaptureOptions): void { + if (!active || !Sentry) return; + const safeContext = scrubValue(compactContext(options.context)) as Record; + Sentry.withScope((scope) => { + scope.setLevel(options.level ?? "error"); + scope.setContext(options.contextName, safeContext); + setFingerprint(scope, options.fingerprint); + setAllowedTags(scope, { ...options.tags, release: options.tags.release ?? activeRelease, environment: options.tags.environment ?? activeEnvironment }); + Sentry!.captureException(error instanceof Error ? error : new Error(String(error))); + }); +} + +function scrubEvent(event: ErrorEvent): ErrorEvent { + return scrubValue(event) as ErrorEvent; +} + +export async function initSentry(env: NodeJS.ProcessEnv): Promise { + if (!nonBlank(env.SENTRY_DSN)) return false; + try { + Sentry = await import("@sentry/node"); + activeRelease = resolveDiscoveryIndexSentryRelease(env); + activeEnvironment = resolveSentryEnvironment(env); + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: activeEnvironment, + release: activeRelease, + tracesSampleRate: resolveTracesSampleRate(env), + beforeSend: (event: ErrorEvent, _hint: EventHint) => scrubEvent(event), + }); + active = true; + return true; + } catch (error) { + active = false; + Sentry = undefined; + activeRelease = undefined; + activeEnvironment = "production"; + warn("discovery_index_sentry_init_failed", { message: error instanceof Error ? error.message : String(error) }); + return false; + } +} + +export function captureRouteError(error: unknown, context: { route: string; method: string }): void { + captureScopedError(error, { + contextName: "discovery_index_route", + context: { event: "discovery_index_route_error", route: context.route, method: context.method, release: activeRelease, environment: activeEnvironment }, + fingerprint: ["discovery-index-route-error", context.route, context.method], + tags: { event: "discovery_index_route_error", route: context.route, method: context.method }, + }); +} + +export function captureUnhandledError(error: unknown, context: { event: "discovery_index_unhandled_rejection" | "discovery_index_uncaught_exception" }): void { + captureScopedError(error, { + contextName: "discovery_index_process", + context: { event: context.event, release: activeRelease, environment: activeEnvironment }, + fingerprint: ["discovery-index-process-error", context.event], + tags: { event: context.event }, + }); +} + +export function captureSourcemapUploadFailure(error: unknown, context: { release?: string | undefined; deploymentId?: string | undefined; strict?: boolean; sha?: string | undefined }): void { + captureScopedError(error, { + contextName: "discovery_index_sourcemap_upload", + context: { + event: "discovery_index_sourcemap_upload_failed", + release: context.release ?? activeRelease, + deploymentId: context.deploymentId, + strict: context.strict, + sha: context.sha, + environment: activeEnvironment, + }, + fingerprint: ["discovery-index-sourcemap-upload-failed"], + tags: { event: "discovery_index_sourcemap_upload_failed", release: context.release ?? activeRelease }, + }); +} + +export async function flushSentry(timeoutMs = 2000): Promise { + if (!active || !Sentry) return; + await Sentry.flush(timeoutMs).catch(() => undefined); +} + +export function resetSentryForTest(): void { + Sentry = undefined; + active = false; + activeRelease = undefined; + activeEnvironment = "production"; +} + +export function setSentryForTest(sentry: Pick, options: { release?: string; environment?: string } = {}): void { + Sentry = sentry as SentryClient; + active = true; + activeRelease = options.release; + activeEnvironment = options.environment ?? "production"; +} diff --git a/packages/discovery-index/src/server.ts b/packages/discovery-index/src/server.ts index c3f372202f..538536eed5 100644 --- a/packages/discovery-index/src/server.ts +++ b/packages/discovery-index/src/server.ts @@ -10,8 +10,14 @@ import { createApp } from "./app.js"; import { TtlCache } from "./cache.js"; import { DEFAULT_CACHE_TTL_MS } from "./discovery-query.js"; import { GitHubClient } from "./github-client.js"; +import { captureUnhandledError, flushSentry, initSentry, resolveSentryEnvironment } from "./sentry.js"; import { DEFAULT_SOFT_CLAIM_TTL_MS, SoftClaimStore } from "./soft-claim.js"; +const sentryEnabled = await initSentry(process.env); +if (sentryEnabled) { + console.log(JSON.stringify({ event: "discovery_index_sentry", environment: resolveSentryEnvironment(process.env) })); +} + const githubToken = process.env.DISCOVERY_INDEX_GITHUB_TOKEN ?? ""; const configuredCacheTtlMs = Number(process.env.DISCOVERY_INDEX_CACHE_TTL_MS); const cacheTtlMs = Number.isFinite(configuredCacheTtlMs) && configuredCacheTtlMs > 0 ? configuredCacheTtlMs : DEFAULT_CACHE_TTL_MS; @@ -33,8 +39,17 @@ serve({ fetch: app.fetch, port }, (info) => { console.log(JSON.stringify({ event: "discovery_index_listening", port: info.port })); }); +process.on("unhandledRejection", (reason) => { + captureUnhandledError(reason, { event: "discovery_index_unhandled_rejection" }); +}); + +process.on("uncaughtException", (error) => { + captureUnhandledError(error, { event: "discovery_index_uncaught_exception" }); + void flushSentry().finally(() => process.exit(1)); +}); + process.on("SIGTERM", () => { - process.exit(0); + void flushSentry().finally(() => process.exit(0)); }); export { app }; diff --git a/packages/discovery-index/src/upload-sourcemaps.ts b/packages/discovery-index/src/upload-sourcemaps.ts new file mode 100644 index 0000000000..34d3841042 --- /dev/null +++ b/packages/discovery-index/src/upload-sourcemaps.ts @@ -0,0 +1,216 @@ +// Uploads this build's source maps to Sentry at container startup, then deletes them before the real +// server starts (see the Dockerfile's runtime CMD) -- mirrors review-enrichment/src/upload-sourcemaps.ts +// (a comparably-sized standalone service with the identical Sentry setup), adapted only by dropping that +// copy's Railway-specific env vars (RAILWAY_GIT_COMMIT_SHA, RAILWAY_DEPLOYMENT_ID, RAILWAY_ENVIRONMENT_NAME) +// since discovery-index deploys via a Cloudflare Container (#7167), not Railway. +// +// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: SENTRY_AUTH_TOKEN is a +// real secret, injected the same way DISCOVERY_INDEX_SHARED_SECRET/DISCOVERY_INDEX_GITHUB_TOKEN already are +// (worker.ts's Container envVars) -- it is never a Docker build-time value, so it never risks being baked +// into a cached image layer. +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import { captureSourcemapUploadFailure, flushSentry, initSentry, resolveDiscoveryIndexSentryRelease, resolveSentryEnvironment } from "./sentry.js"; + +const require = createRequire(import.meta.url); + +type RunOptions = { + allowExistingRelease?: boolean; + allowFailure?: boolean; +}; + +const distDir = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(distDir, ".."); + +function nonBlank(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +function log(event: string, fields: Record = {}): void { + console.log(JSON.stringify({ event, ...fields })); +} + +function warn(event: string, fields: Record = {}): void { + console.error(JSON.stringify({ level: "warn", event, ...fields })); +} + +function listFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +function validateSourceMaps(): void { + const serverBundle = resolve(distDir, "server.js"); + const serverMap = resolve(distDir, "server.js.map"); + if (!existsSync(serverBundle)) throw new Error("dist/server.js is missing"); + if (!existsSync(serverMap)) throw new Error("dist/server.js.map is missing"); + if (!readFileSync(serverBundle, "utf8").includes("//# sourceMappingURL=server.js.map")) { + throw new Error("dist/server.js is missing the server.js.map sourceMappingURL"); + } + + const maps = listFiles(distDir).filter((path) => path.endsWith(".js.map")); + if (maps.length === 0) throw new Error("dist has no JavaScript source maps"); + + let sawServerSource = false; + for (const path of maps) { + const map = JSON.parse(readFileSync(path, "utf8")) as { sources?: unknown; sourcesContent?: unknown }; + const label = relative(appDir, path); + if (!Array.isArray(map.sources) || map.sources.length === 0) { + throw new Error(`${label} has no original sources`); + } + if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + throw new Error(`${label} does not embed sourcesContent for every source`); + } + if (!map.sourcesContent.some((source) => typeof source === "string" && source.trim().length > 0)) { + throw new Error(`${label} has empty sourcesContent`); + } + if (map.sources.some((source) => String(source).replaceAll("\\", "/").endsWith("src/server.ts"))) { + sawServerSource = true; + } + } + if (!sawServerSource) throw new Error("source maps do not include src/server.ts"); +} + +// Resolved via require.resolve, not a hardcoded packages/discovery-index/node_modules/.bin/ path: unlike +// review-enrichment (a standalone, non-workspace package this file otherwise mirrors), discovery-index is +// a real npm workspace member, so npm hoists @sentry/cli's binary to the ROOT node_modules/.bin/ by default +// -- a package-relative path assumption would silently look in the wrong place. Same resolution pattern as +// the root repo's own scripts/gen-cf-typegen.mjs resolveLocalWranglerBin(). +function sentryCliPath(): string { + const override = nonBlank(process.env.SENTRY_CLI_PATH); + if (override) return override; + const pkgJsonPath = require.resolve("@sentry/cli/package.json"); + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { bin?: string | Record }; + const binRelativePath = typeof pkg.bin === "string" ? pkg.bin : (pkg.bin?.["sentry-cli"] ?? pkg.bin?.["@sentry/cli"]); + if (!binRelativePath) throw new Error("@sentry/cli package.json has no resolvable bin entry"); + return join(dirname(pkgJsonPath), binRelativePath); +} + +function runSentry(args: string[], options: RunOptions = {}): void { + const result = spawnSync(sentryCliPath(), args, { cwd: appDir, env: process.env, encoding: "utf8" }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (result.status === 0) { + if (output) log("discovery_index_sentry_cli", { command: args.slice(0, 2).join(" "), output: output.slice(0, 300) }); + return; + } + if (options.allowExistingRelease && /already exists|version already exists/i.test(output)) return; + if (options.allowFailure) { + warn("discovery_index_sentry_cli_failed", { command: args.slice(0, 3).join(" "), status: result.status, message: output.slice(0, 300) }); + return; + } + throw new Error(`sentry-cli ${args.join(" ")} failed (${result.status}): ${output.slice(0, 500)}`); +} + +function shouldValidateRelease(): boolean { + return !/^(0|false|no|off)$/i.test(process.env.DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE ?? ""); +} + +function numericEnv(name: string, fallback: number, max: number): number { + const raw = Number(nonBlank(process.env[name])); + return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), max) : fallback; +} + +async function runReleaseValidation(release: string, fields: { sha?: string | undefined; deployName: string; environment: string; strict: boolean }): Promise { + if (!shouldValidateRelease()) return; + const attempts = Math.max(1, numericEnv("DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS", 5, 20)); + const retryDelayMs = numericEnv("DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS", 1_000, 30_000); + let output = ""; + let status: number | null = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const result = spawnSync(process.execPath, ["scripts/validate-sentry-release.mjs"], { + cwd: appDir, + env: { + ...process.env, + SENTRY_RELEASE: release, + SENTRY_COMMIT_SHA: fields.sha ?? "", + SENTRY_DEPLOY_NAME: fields.deployName, + SENTRY_ENVIRONMENT: fields.environment, + SENTRY_REQUIRE_COMMITS: fields.strict ? "true" : "false", + SENTRY_REQUIRE_DEPLOY: "true", + SENTRY_REQUIRE_FINALIZED: "true", + }, + encoding: "utf8", + }); + status = result.status; + output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (result.status === 0) { + if (output) log("discovery_index_sentry_release_validation", { output: output.slice(0, 500), attempt }); + return; + } + if (attempt < attempts) { + warn("discovery_index_sentry_release_validation_retry", { attempt, attempts, retryDelayMs, message: output.slice(0, 500) }); + if (retryDelayMs > 0) await sleep(retryDelayMs); + } + } + throw new Error(`Sentry release validation failed (${status}): ${output.slice(0, 500)}`); +} + +async function main(): Promise { + // initSentry's own body already wraps everything error-prone in its own try/catch and always resolves + // (never rejects) when called with a real process.env -- this .catch is unreachable through the real + // call site above, same "defensive net, no live branch" reasoning as sentry.ts's own sentryTagValue guard. + /* v8 ignore next -- @preserve unreachable: initSentry(process.env) never rejects */ + await initSentry(process.env).catch(() => false); + const release = resolveDiscoveryIndexSentryRelease(process.env); + const required = { + SENTRY_AUTH_TOKEN: nonBlank(process.env.SENTRY_AUTH_TOKEN), + SENTRY_ORG: nonBlank(process.env.SENTRY_ORG), + SENTRY_PROJECT: nonBlank(process.env.SENTRY_PROJECT), + SENTRY_RELEASE: release, + }; + const missing = Object.entries(required) + .filter(([, value]) => !value) + .map(([key]) => key); + if (missing.length > 0) { + log("discovery_index_sentry_sourcemap_upload_skipped", { reason: "missing_config", missing }); + return 0; + } + + const strict = /^(1|true|yes|on)$/i.test(process.env.DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT ?? ""); + try { + validateSourceMaps(); + const projectArgs = ["--org", required.SENTRY_ORG!, "--project", required.SENTRY_PROJECT!]; + runSentry(["releases", ...projectArgs, "new", release!], { allowExistingRelease: true }); + + const sha = nonBlank(process.env.SENTRY_COMMIT_SHA); + if (sha) { + const repo = nonBlank(process.env.SENTRY_REPOSITORY) ?? "JSONbored/loopover"; + const previous = nonBlank(process.env.SENTRY_PREVIOUS_COMMIT_SHA); + const spec = previous ? `${repo}@${previous}..${sha}` : `${repo}@${sha}`; + runSentry(["releases", ...projectArgs, "set-commits", release!, "--commit", spec, "--ignore-missing"], { allowFailure: !strict }); + } + + runSentry(["sourcemaps", ...projectArgs, "inject", "dist"]); + validateSourceMaps(); + runSentry(["sourcemaps", ...projectArgs, "upload", "--release", release!, "--validate", "--wait", ...(strict ? ["--strict"] : []), "dist"]); + const deployName = nonBlank(process.env.SENTRY_DEPLOY_NAME) ?? "cloudflare-container"; + runSentry(["releases", ...projectArgs, "deploys", "new", "--release", release!, "--env", resolveSentryEnvironment(process.env), "--name", deployName]); + runSentry(["releases", ...projectArgs, "finalize", release!]); + await runReleaseValidation(release!, { sha, deployName, environment: resolveSentryEnvironment(process.env), strict }); + log("discovery_index_sentry_sourcemap_upload_complete", { release }); + return 0; + } catch (error) { + captureSourcemapUploadFailure(error, { + release, + deploymentId: nonBlank(process.env.SENTRY_DEPLOY_NAME), + strict, + sha: nonBlank(process.env.SENTRY_COMMIT_SHA), + }); + await flushSentry(); + warn("discovery_index_sentry_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); + return strict ? 1 : 0; + } +} + +process.exitCode = await main(); diff --git a/packages/discovery-index/src/worker.ts b/packages/discovery-index/src/worker.ts index eb36dc56db..c7a93097d6 100644 --- a/packages/discovery-index/src/worker.ts +++ b/packages/discovery-index/src/worker.ts @@ -34,9 +34,20 @@ export class DiscoveryIndexContainer extends Container { // long enough that normal miner query cadence (cache TTL is 5 minutes, per README.md) doesn't constantly // pay a cold-start penalty between requests. override sleepAfter = "10m"; + // Sentry vars (#4934) are forwarded whether or not they're actually configured -- initSentry/ + // upload-sourcemaps.ts (both running inside the container, not this Worker) already no-op cleanly on an + // empty/unset SENTRY_DSN/SENTRY_AUTH_TOKEN; nothing here needs to conditionally omit them. Container.envVars + // requires Record (no undefined) -- SENTRY_RELEASE/SENTRY_AUTH_TOKEN are optional (env.d.ts), + // so they're coerced to "" here, which initSentry's own nonBlank() already treats identically to unset. override envVars = { DISCOVERY_INDEX_SHARED_SECRET: env.DISCOVERY_INDEX_SHARED_SECRET, DISCOVERY_INDEX_GITHUB_TOKEN: env.DISCOVERY_INDEX_GITHUB_TOKEN, + SENTRY_DSN: env.SENTRY_DSN, + SENTRY_ENVIRONMENT: env.SENTRY_ENVIRONMENT, + SENTRY_RELEASE: env.SENTRY_RELEASE ?? "", + SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN ?? "", + SENTRY_ORG: env.SENTRY_ORG, + SENTRY_PROJECT: env.SENTRY_PROJECT, }; } @@ -45,6 +56,12 @@ interface WorkerEnv { DISCOVERY_INDEX_RATE_LIMITER: DurableObjectNamespace; DISCOVERY_INDEX_SHARED_SECRET: string; DISCOVERY_INDEX_GITHUB_TOKEN: string; + SENTRY_DSN: string; + SENTRY_ENVIRONMENT: string; + SENTRY_RELEASE: string; + SENTRY_AUTH_TOKEN: string; + SENTRY_ORG: string; + SENTRY_PROJECT: string; } // /health, /ready, /metrics are cheap liveness/monitoring routes a legitimate uptime checker may poll diff --git a/packages/discovery-index/tsconfig.json b/packages/discovery-index/tsconfig.json index dba156cc97..6a143c0617 100644 --- a/packages/discovery-index/tsconfig.json +++ b/packages/discovery-index/tsconfig.json @@ -8,6 +8,12 @@ "rootDir": "src", "outDir": "dist", "noEmit": false, + // Sentry source-map upload (upload-sourcemaps.ts, #4934) needs real, embedded original source -- + // inlineSources embeds src/*.ts content directly into the .map files, matching review-enrichment's + // identical setup, so Sentry can render the actual TypeScript in a stack trace without a separate + // source upload step. + "sourceMap": true, + "inlineSources": true, // Without this, the inherited root value (tsconfig.json:24, "./.tsbuildinfo") resolves relative to the // ROOT config's location, not this one -- this package's build and the root Typecheck step would then // read/write the exact same cache file at the repo root and corrupt each other's incremental state, diff --git a/packages/discovery-index/worker-configuration.d.ts b/packages/discovery-index/worker-configuration.d.ts index af8640063f..860d1ab468 100644 --- a/packages/discovery-index/worker-configuration.d.ts +++ b/packages/discovery-index/worker-configuration.d.ts @@ -1,7 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: d2eeff9c442143d238b1256f3c987930) +// Generated by Wrangler by running `wrangler types` (hash: eb3cdf7ee4529b8efb96d70f4144a615) // Runtime types generated with workerd@1.20260714.1 2026-07-21 interface __BaseEnv_Env { + SENTRY_ORG: "jsonbored"; + SENTRY_PROJECT: "metagraphed"; + SENTRY_DSN: "https://998bd096e2c9f1d81781ed3a88fed0b9@o4511631313666048.ingest.us.sentry.io/4511749777588224"; + SENTRY_ENVIRONMENT: "production"; DISCOVERY_INDEX_CONTAINER: DurableObjectNamespace; DISCOVERY_INDEX_RATE_LIMITER: DurableObjectNamespace; } diff --git a/packages/discovery-index/wrangler.jsonc b/packages/discovery-index/wrangler.jsonc index ced623d368..043b2cdc07 100644 --- a/packages/discovery-index/wrangler.jsonc +++ b/packages/discovery-index/wrangler.jsonc @@ -4,12 +4,29 @@ // running the SAME, unmodified Docker image the existing Dockerfile already builds and the Node // entrypoint (src/server.ts) already runs on port 8080. // - // Before first deploy, set both real secrets (never commit real values, and never put them in this file): + // Before first deploy, set the real secrets (never commit real values, and never put them in this file): // npx wrangler secret put DISCOVERY_INDEX_SHARED_SECRET // npx wrangler secret put DISCOVERY_INDEX_GITHUB_TOKEN + // npx wrangler secret put SENTRY_AUTH_TOKEN (only needed for source-map upload; omit to skip it -- + // upload-sourcemaps.ts no-ops cleanly when unset) + // + // SENTRY_RELEASE is deliberately NOT a static var below -- it should be the actual deploy's git commit + // (loopover-discovery-index@), which changes every release. Set per-deploy: + // npx wrangler deploy --var SENTRY_RELEASE:loopover-discovery-index@$(git rev-parse HEAD) "name": "loopover-discovery-index", "main": "src/worker.ts", "compatibility_date": "2026-07-21", + "vars": { + // Error tracking (#4934) reports into the shared "metagraphed" Sentry project other infra/operational + // pieces already flow into (jsonbored org) -- not a new, separate project. SENTRY_DSN is write-only + // (submits events, cannot read account data) -- safe to commit, the same way it's commonly embedded in + // public client-side bundles; SENTRY_AUTH_TOKEN (full API access, used only for source-map upload) is + // the one genuinely sensitive value and stays a wrangler secret, never here. + "SENTRY_ORG": "jsonbored", + "SENTRY_PROJECT": "metagraphed", + "SENTRY_DSN": "https://998bd096e2c9f1d81781ed3a88fed0b9@o4511631313666048.ingest.us.sentry.io/4511749777588224", + "SENTRY_ENVIRONMENT": "production" + }, "observability": { "enabled": true, "logs": { diff --git a/test/unit/discovery-index/sentry.test.ts b/test/unit/discovery-index/sentry.test.ts new file mode 100644 index 0000000000..b3957fbbcd --- /dev/null +++ b/test/unit/discovery-index/sentry.test.ts @@ -0,0 +1,322 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureRouteError, + captureSourcemapUploadFailure, + captureUnhandledError, + flushSentry, + initSentry, + resetSentryForTest, + resolveDiscoveryIndexSentryRelease, + resolveSentryEnvironment, + resolveTracesSampleRate, + setSentryForTest, +} from "../../../packages/discovery-index/src/sentry"; + +function sentryHarness() { + const tags: Record = {}; + const contexts: Record = {}; + const fingerprints: unknown[][] = []; + const levels: string[] = []; + const captured: Error[] = []; + const flushed: number[] = []; + const scope = { + setLevel: (level: string) => levels.push(level), + setContext: (name: string, context: unknown) => { + contexts[name] = context; + }, + setFingerprint: (fingerprint: unknown[]) => fingerprints.push(fingerprint), + setTag: (name: string, value: string) => { + tags[name] = value; + }, + }; + setSentryForTest( + // The real @sentry/node types for withScope/flush are far broader than what captureScopedError + // actually calls (it only ever touches setLevel/setContext/setFingerprint/setTag on the scope, and + // awaits flush with no args) -- cast through unknown, the same way this repo's other Sentry test + // harnesses (test/unit/selfhost-sentry.test.ts) work around fakes narrower than the real SDK surface. + { + withScope: (run: (value: typeof scope) => void) => run(scope), + captureException: (error: unknown) => { + captured.push(error instanceof Error ? error : new Error(String(error))); + return "event-id"; + }, + flush: async (timeoutMs: number) => { + flushed.push(timeoutMs); + return true; + }, + } as unknown as Parameters[0], + { release: "loopover-discovery-index@test", environment: "test" }, + ); + return { tags, contexts, fingerprints, levels, captured, flushed }; +} + +afterEach(() => { + resetSentryForTest(); +}); + +describe("resolveDiscoveryIndexSentryRelease", () => { + it("prefers an explicit SENTRY_RELEASE", () => { + expect(resolveDiscoveryIndexSentryRelease({ SENTRY_RELEASE: "v1", SENTRY_COMMIT_SHA: "abc" } as unknown as NodeJS.ProcessEnv)).toBe("v1"); + }); + it("derives a release from SENTRY_COMMIT_SHA when SENTRY_RELEASE is unset", () => { + expect(resolveDiscoveryIndexSentryRelease({ SENTRY_COMMIT_SHA: "abc123" } as unknown as NodeJS.ProcessEnv)).toBe("loopover-discovery-index@abc123"); + }); + it("returns undefined when neither is set", () => { + expect(resolveDiscoveryIndexSentryRelease({} as unknown as NodeJS.ProcessEnv)).toBeUndefined(); + }); +}); + +describe("resolveSentryEnvironment", () => { + it("uses SENTRY_ENVIRONMENT when set", () => { + expect(resolveSentryEnvironment({ SENTRY_ENVIRONMENT: "staging" } as unknown as NodeJS.ProcessEnv)).toBe("staging"); + }); + it("defaults to production", () => { + expect(resolveSentryEnvironment({} as unknown as NodeJS.ProcessEnv)).toBe("production"); + }); + it("treats a blank/whitespace-only value as unset", () => { + expect(resolveSentryEnvironment({ SENTRY_ENVIRONMENT: " " } as unknown as NodeJS.ProcessEnv)).toBe("production"); + }); +}); + +describe("resolveTracesSampleRate", () => { + it("parses a valid in-range rate", () => { + expect(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "0.5" } as unknown as NodeJS.ProcessEnv)).toBe(0.5); + }); + it("clamps a rate above 1 down to 1", () => { + expect(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "5" } as unknown as NodeJS.ProcessEnv)).toBe(1); + }); + it("clamps a negative rate up to 0", () => { + expect(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "-2" } as unknown as NodeJS.ProcessEnv)).toBe(0); + }); + it("falls back to 0 for a non-numeric value", () => { + expect(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "not-a-number" } as unknown as NodeJS.ProcessEnv)).toBe(0); + }); + it("falls back to 0 when unset", () => { + expect(resolveTracesSampleRate({} as unknown as NodeJS.ProcessEnv)).toBe(0); + }); +}); + +describe("initSentry", () => { + it("stays inert (returns false) when SENTRY_DSN is unset", async () => { + await expect(initSentry({} as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + }); + + it("stays inert when SENTRY_DSN is blank/whitespace-only", async () => { + await expect(initSentry({ SENTRY_DSN: " " } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + }); + + it("resets state and returns false when the dynamic @sentry/node import throws an Error", async () => { + vi.doMock("@sentry/node", () => { + throw new Error("module load failed"); + }); + vi.resetModules(); + const { initSentry: initSentryFresh } = await import("../../../packages/discovery-index/src/sentry"); + await expect(initSentryFresh({ SENTRY_DSN: "https://example.test/1" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + vi.doUnmock("@sentry/node"); + vi.resetModules(); + }); + + it("resets state and returns false when Sentry.init throws a non-Error value", async () => { + // A synchronous throw from inside vi.doMock's own factory gets re-wrapped by vitest itself into a real + // Error ("There was an error when mocking a module...") before initSentry's catch ever sees it -- that + // path can't actually exercise the non-Error side of `error instanceof Error ? ... : String(error)`. + // Throwing from the mocked Sentry.init() call instead reaches initSentry's catch block directly, with + // the raw value intact. + vi.doMock("@sentry/node", () => ({ + init: () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately + throw "init failed (string throw)"; + }, + withScope: () => undefined, + captureException: () => undefined, + })); + vi.resetModules(); + const { initSentry: initSentryFresh } = await import("../../../packages/discovery-index/src/sentry"); + await expect(initSentryFresh({ SENTRY_DSN: "https://example.test/1" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + vi.doUnmock("@sentry/node"); + vi.resetModules(); + }); + + it("succeeds, wires beforeSend to scrubEvent, and leaves Sentry active", async () => { + let capturedInitOptions: { beforeSend?: (event: unknown) => unknown } | undefined; + const captured: Error[] = []; + vi.doMock("@sentry/node", () => ({ + init: (options: { beforeSend?: (event: unknown) => unknown }) => { + capturedInitOptions = options; + return {}; + }, + withScope: (run: (scope: unknown) => void) => run({ setLevel() {}, setContext() {}, setFingerprint() {}, setTag() {} }), + captureException: (error: unknown) => { + captured.push(error instanceof Error ? error : new Error(String(error))); + }, + })); + vi.resetModules(); + const { initSentry: initSentryFresh, captureRouteError: captureRouteErrorFresh, resetSentryForTest: resetFresh } = await import( + "../../../packages/discovery-index/src/sentry" + ); + await expect(initSentryFresh({ SENTRY_DSN: "https://example.test/1", SENTRY_COMMIT_SHA: "abc" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(true); + // beforeSend is wired to scrubEvent -- a secret-shaped field on a real event gets redacted. + const scrubbed = capturedInitOptions?.beforeSend?.({ tags: { authorization: "should-be-filtered" } }) as { tags: Record }; + expect(scrubbed.tags.authorization).toBe("[Filtered]"); + // Proves init actually left `active` true end-to-end: a real capture reaches the mocked client. + captureRouteErrorFresh(new Error("real capture"), { route: "/x", method: "GET" }); + expect(captured[0]?.message).toBe("real capture"); + resetFresh(); + vi.doUnmock("@sentry/node"); + vi.resetModules(); + }); +}); + +describe("captureRouteError", () => { + it("is inert when Sentry is disabled", () => { + expect(() => captureRouteError(new Error("boom"), { route: "/v1/discovery-index/query", method: "POST" })).not.toThrow(); + }); + + it("tags, fingerprints, and scopes a route-level error", () => { + const sentry = sentryHarness(); + captureRouteError(new Error("boom"), { route: "/v1/discovery-index/query", method: "POST" }); + + expect(sentry.levels).toEqual(["error"]); + expect(sentry.fingerprints).toEqual([["discovery-index-route-error", "/v1/discovery-index/query", "POST"]]); + expect(sentry.tags.event).toBe("discovery_index_route_error"); + expect(sentry.tags.route).toBe("/v1/discovery-index/query"); + expect(sentry.tags.method).toBe("POST"); + expect(sentry.tags.release).toBe("loopover-discovery-index@test"); + expect(sentry.tags.environment).toBe("test"); + expect(sentry.captured[0]?.message).toBe("boom"); + }); + + it("wraps a non-Error throw into a real Error before capture", () => { + const sentry = sentryHarness(); + captureRouteError("a plain string throw", { route: "/health", method: "GET" }); + expect(sentry.captured[0]?.message).toBe("a plain string throw"); + }); +}); + +describe("captureUnhandledError", () => { + it("fingerprints process-level failures by event class", () => { + const sentry = sentryHarness(); + captureUnhandledError(new Error("kaboom"), { event: "discovery_index_uncaught_exception" }); + + expect(sentry.fingerprints).toEqual([["discovery-index-process-error", "discovery_index_uncaught_exception"]]); + expect(sentry.tags.event).toBe("discovery_index_uncaught_exception"); + expect(sentry.contexts.discovery_index_process).toEqual({ + event: "discovery_index_uncaught_exception", + release: "loopover-discovery-index@test", + environment: "test", + }); + }); + + it("covers the unhandled_rejection event branch too", () => { + const sentry = sentryHarness(); + captureUnhandledError(new Error("rejected"), { event: "discovery_index_unhandled_rejection" }); + expect(sentry.tags.event).toBe("discovery_index_unhandled_rejection"); + }); +}); + +describe("captureSourcemapUploadFailure", () => { + it("applies stable upload grouping and forwards optional context fields", () => { + const sentry = sentryHarness(); + captureSourcemapUploadFailure(new Error("upload failed"), { + release: "loopover-discovery-index@abc", + deploymentId: "cloudflare-container", + strict: true, + sha: "abcdef1234567890", + }); + + expect(sentry.fingerprints).toEqual([["discovery-index-sourcemap-upload-failed"]]); + expect(sentry.tags.event).toBe("discovery_index_sourcemap_upload_failed"); + expect(sentry.tags.release).toBe("loopover-discovery-index@abc"); + expect(sentry.contexts.discovery_index_sourcemap_upload).toEqual({ + event: "discovery_index_sourcemap_upload_failed", + release: "loopover-discovery-index@abc", + deploymentId: "cloudflare-container", + strict: true, + sha: "abcdef1234567890", + environment: "test", + }); + }); + + it("falls back to the active release when no explicit release is given", () => { + const sentry = sentryHarness(); + captureSourcemapUploadFailure(new Error("upload failed"), {}); + expect(sentry.tags.release).toBe("loopover-discovery-index@test"); + // deploymentId/sha/strict all undefined -> compactContext drops them from the stored context. + expect(sentry.contexts.discovery_index_sourcemap_upload).toEqual({ + event: "discovery_index_sourcemap_upload_failed", + release: "loopover-discovery-index@test", + environment: "test", + }); + }); +}); + +describe("secret scrubbing", () => { + it("redacts a context field by KEY name regardless of its value (object branch)", () => { + const sentry = sentryHarness(); + // captureSourcemapUploadFailure builds its own context object from named fields (event/release/ + // deploymentId/strict/sha/environment) -- none of those names are secret-shaped, so an *extra* field + // would just be dropped before scrubValue ever sees it. To reach scrubValue's object/key-name branch, + // inject a nested object through `sha` itself (typed string, but scrubValue recurses on whatever it + // actually receives at runtime) -- the same `as never` bypass review-enrichment's own tests use to feed + // shapes the public type wouldn't otherwise allow. + captureSourcemapUploadFailure(new Error("boom"), { sha: { authorization: "innocuous-looking-value" } } as never); + const context = sentry.contexts.discovery_index_sourcemap_upload as Record; + expect(context.sha).toEqual({ authorization: "[Filtered]" }); + }); + + it("redacts secret-named keys inside a nested array value (array branch)", () => { + const sentry = sentryHarness(); + captureSourcemapUploadFailure(new Error("boom"), { sha: [{ token: "should-be-filtered" }, "plain-string"] } as never); + const context = sentry.contexts.discovery_index_sourcemap_upload as Record; + expect(context.sha).toEqual([{ token: "[Filtered]" }, "plain-string"]); + }); + + it("redacts a GitHub-token-shaped VALUE (not just key name) inside upload-failure context", () => { + const sentry = sentryHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadFailure(new Error(`upload failed for ${fakeToken}`), { sha: fakeToken }); + const context = sentry.contexts.discovery_index_sourcemap_upload as Record; + expect(JSON.stringify(context)).not.toContain(fakeToken); + expect(context.sha).toBe("[Filtered]"); + }); + + it("filters a secret-shaped TAG value down to [Filtered]", () => { + const sentry = sentryHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadFailure(new Error("boom"), { release: fakeToken }); + expect(sentry.tags.release).toBe("[Filtered]"); + }); + + it("drops an empty tag value instead of setting a blank tag, and falls fingerprint parts back to 'unknown'", () => { + const sentry = sentryHarness(); + captureRouteError(new Error("boom"), { route: "", method: "GET" }); + expect(sentry.tags.route).toBeUndefined(); + expect(sentry.fingerprints).toEqual([["discovery-index-route-error", "unknown", "GET"]]); + }); +}); + +describe("flushSentry", () => { + it("is inert when Sentry is disabled", async () => { + await expect(flushSentry()).resolves.toBeUndefined(); + }); + + it("flushes with the given timeout when enabled", async () => { + const sentry = sentryHarness(); + await flushSentry(1234); + expect(sentry.flushed).toEqual([1234]); + }); + + it("swallows a flush rejection rather than throwing", async () => { + setSentryForTest( + { + withScope: () => undefined, + captureException: () => "id", + flush: async () => { + throw new Error("flush failed"); + }, + }, + {}, + ); + await expect(flushSentry()).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/discovery-index/upload-sourcemaps.test.ts b/test/unit/discovery-index/upload-sourcemaps.test.ts new file mode 100644 index 0000000000..7fba6f4942 --- /dev/null +++ b/test/unit/discovery-index/upload-sourcemaps.test.ts @@ -0,0 +1,448 @@ +// Coverage for the discovery-index build's Sentry source-map upload entrypoint (#4934). The module has no +// exports -- it runs `process.exitCode = await main()` as a side effect of being imported (mirroring +// review-enrichment/src/upload-sourcemaps.ts, which is instead tested via subprocess spawn since it lives +// outside Codecov's vitest-measured src/** scope). discovery-index/src/** IS measured here, so this file +// gets real v8 line/branch coverage by re-importing the module in-process per scenario (vi.resetModules() + +// a fresh dynamic import), with node:fs and node:child_process mocked so no real files or processes are +// touched. +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const MODULE_PATH = "../../../packages/discovery-index/src/upload-sourcemaps"; + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +// upload-sourcemaps.ts derives its own directory from import.meta.url -- since vitest transforms the real +// .ts file in place (no build step), that's genuinely packages/discovery-index/src at test time too. +const DIST_DIR = resolve(TEST_DIR, "../../../packages/discovery-index/src"); +const SERVER_JS = resolve(DIST_DIR, "server.js"); +const SERVER_MAP = resolve(DIST_DIR, "server.js.map"); + +const testRequire = createRequire(import.meta.url); +const CLI_PKG_JSON = testRequire.resolve("@sentry/cli/package.json"); + +const { existsSyncMock, readFileSyncMock, readdirSyncMock, statSyncMock, spawnSyncMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), + readdirSyncMock: vi.fn(), + statSyncMock: vi.fn(), + spawnSyncMock: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: existsSyncMock, readFileSync: readFileSyncMock, readdirSync: readdirSyncMock, statSync: statSyncMock }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: spawnSyncMock }; +}); + +const VALID_MAP = JSON.stringify({ sources: ["../src/server.ts"], sourcesContent: ["export const x = 1;"] }); +const VALID_BUNDLE = "console.log(1);\n//# sourceMappingURL=server.js.map\n"; + +type FsFixture = { files: Record; dirs: Record }; + +function validDistFixture(): FsFixture { + return { + files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: VALID_MAP }, + dirs: { [DIST_DIR]: ["server.js", "server.js.map"] }, + }; +} + +function applyFsFixture({ files, dirs }: FsFixture): void { + existsSyncMock.mockImplementation((path: string) => path in files); + readFileSyncMock.mockImplementation((path: string) => { + if (!(path in files)) throw new Error(`ENOENT (fixture): ${path}`); + return files[path]; + }); + readdirSyncMock.mockImplementation((dir: string) => { + const children = dirs[dir] ?? []; + return children.map((name) => ({ name, isDirectory: () => Array.isArray(dirs[resolve(dir, name)]) })); + }); + statSyncMock.mockImplementation(() => ({ isFile: () => true })); +} + +const REQUIRED_ENV: Record = { + SENTRY_CLI_PATH: "FAKE_SENTRY_CLI", + SENTRY_AUTH_TOKEN: "test-token", + SENTRY_ORG: "jsonbored", + SENTRY_PROJECT: "discovery-index", + SENTRY_RELEASE: "loopover-discovery-index@abc123", + DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "0", +}; + +let originalEnv: NodeJS.ProcessEnv; + +function setEnv(overrides: Record): void { + for (const [key, value] of Object.entries({ ...REQUIRED_ENV, ...overrides })) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function spawnSuccess(): { status: number; stdout: string; stderr: string } { + return { status: 0, stdout: "", stderr: "" }; +} + +function isValidateReleaseCall(args: string[]): boolean { + return args[0] === "scripts/validate-sentry-release.mjs"; +} + +async function run(): Promise { + await import(MODULE_PATH); +} + +beforeEach(() => { + originalEnv = { ...process.env }; + vi.resetModules(); + existsSyncMock.mockReset(); + readFileSyncMock.mockReset(); + readdirSyncMock.mockReset(); + statSyncMock.mockReset(); + spawnSyncMock.mockReset(); + spawnSyncMock.mockImplementation(spawnSuccess); + applyFsFixture(validDistFixture()); + process.exitCode = undefined; + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + vi.restoreAllMocks(); +}); + +describe("discovery-index upload-sourcemaps (#4934)", () => { + it("skips the upload and exits 0 when required Sentry config is missing", async () => { + setEnv({ SENTRY_AUTH_TOKEN: undefined }); + await run(); + expect(process.exitCode).toBe(0); + expect(spawnSyncMock).not.toHaveBeenCalled(); + }); + + it("runs the full success flow with no sha and validation turned off", async () => { + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + const calls = spawnSyncMock.mock.calls.map(([command, args]) => ({ command, args })); + expect(calls).toEqual([ + { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "new", "loopover-discovery-index@abc123"] }, + { command: "FAKE_SENTRY_CLI", args: ["sourcemaps", "--org", "jsonbored", "--project", "discovery-index", "inject", "dist"] }, + { + command: "FAKE_SENTRY_CLI", + args: ["sourcemaps", "--org", "jsonbored", "--project", "discovery-index", "upload", "--release", "loopover-discovery-index@abc123", "--validate", "--wait", "dist"], + }, + { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "deploys", "new", "--release", "loopover-discovery-index@abc123", "--env", "production", "--name", "cloudflare-container"] }, + { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "finalize", "loopover-discovery-index@abc123"] }, + ]); + // No sha at all -> set-commits is skipped entirely; validation is off -> its script is never spawned. + expect(calls.some((call) => call.args.includes("set-commits"))).toBe(false); + expect(calls.some((call) => isValidateReleaseCall(call.args))).toBe(false); + }); + + it("associates commits via set-commits using the default repo when only a commit sha is given", async () => { + setEnv({ SENTRY_COMMIT_SHA: "abc123" }); + await run(); + expect(process.exitCode).toBe(0); + const setCommits = spawnSyncMock.mock.calls.find(([, args]) => args.includes("set-commits")); + expect(setCommits?.[1]).toEqual([ + "releases", + "--org", + "jsonbored", + "--project", + "discovery-index", + "set-commits", + "loopover-discovery-index@abc123", + "--commit", + "JSONbored/loopover@abc123", + "--ignore-missing", + ]); + }); + + it("uses a commit range and a custom repo when a previous sha and SENTRY_REPOSITORY are both given", async () => { + setEnv({ SENTRY_COMMIT_SHA: "def456", SENTRY_PREVIOUS_COMMIT_SHA: "abc123", SENTRY_REPOSITORY: "acme/other-repo" }); + await run(); + expect(process.exitCode).toBe(0); + const setCommits = spawnSyncMock.mock.calls.find(([, args]) => args.includes("set-commits")); + expect(setCommits?.[1]).toContain("acme/other-repo@abc123..def456"); + }); + + it("treats a 'release already exists' failure on the create step as success (allowExistingRelease)", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (args[0] === "releases" && args.includes("new")) return { status: 1, stdout: "", stderr: "410: version already exists" }; + return spawnSuccess(); + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + }); + + it("swallows a failed non-strict set-commits with a warning and continues the upload", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; + return spawnSuccess(); + }); + setEnv({ SENTRY_COMMIT_SHA: "abc123" }); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_cli_failed")); + // Non-strict allowFailure means the upload still proceeds past set-commits to finalize. + expect(spawnSyncMock.mock.calls.some(([, args]) => args.includes("finalize"))).toBe(true); + }); + + it("propagates a failed strict set-commits as a hard failure and exits 1", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; + return spawnSuccess(); + }); + setEnv({ SENTRY_COMMIT_SHA: "abc123", DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true" }); + await run(); + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); + }); + + it("fails non-strict validateSourceMaps errors as a soft failure (exit 0) with the reason logged", async () => { + applyFsFixture({ files: { [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js.map"] } }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist/server.js is missing")); + }); + + it("throws when dist/server.js.map is missing", async () => { + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE }, dirs: { [DIST_DIR]: ["server.js"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist/server.js.map is missing")); + }); + + it("throws when the server bundle is missing its sourceMappingURL comment", async () => { + applyFsFixture({ files: { [SERVER_JS]: "console.log(1);\n", [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing the server.js.map sourceMappingURL")); + }); + + it("throws when no .js.map files are found even though the required files exist", async () => { + // existsSync reports both files present, but the directory listing (a separate mock) omits the map -- + // exercises the maps.length === 0 branch independently of the existsSync checks above it. + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist has no JavaScript source maps")); + }); + + it("throws when a source map has no original sources", async () => { + const badMap = JSON.stringify({ sources: [], sourcesContent: [] }); + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has no original sources")); + }); + + it("throws when a source map's sourcesContent doesn't match its sources length", async () => { + const badMap = JSON.stringify({ sources: ["../src/server.ts"], sourcesContent: [] }); + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does not embed sourcesContent for every source")); + }); + + it("throws when a source map's sourcesContent is present but entirely blank", async () => { + const badMap = JSON.stringify({ sources: ["../src/server.ts"], sourcesContent: [" "] }); + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has empty sourcesContent")); + }); + + it("throws when no source map references src/server.ts", async () => { + const badMap = JSON.stringify({ sources: ["../src/other.ts"], sourcesContent: ["export const y = 1;"] }); + applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); + setEnv({}); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("source maps do not include src/server.ts")); + }); + + it("recurses into nested directories when scanning dist for source maps", async () => { + const chunkDir = resolve(DIST_DIR, "chunks"); + const chunkMap = JSON.stringify({ sources: ["../src/other.ts"], sourcesContent: ["export const z = 1;"] }); + applyFsFixture({ + files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: VALID_MAP, [resolve(chunkDir, "chunk1.js.map")]: chunkMap }, + dirs: { [DIST_DIR]: ["server.js", "server.js.map", "chunks"], [chunkDir]: ["chunk1.js.map"] }, + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + }); + + it("resolves the real sentry-cli binary from a string-form package.json bin field", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: "bin/sentry-cli" }) }, + }); + setEnv({ SENTRY_CLI_PATH: undefined }); + await run(); + expect(process.exitCode).toBe(0); + const [command] = spawnSyncMock.mock.calls[0] ?? []; + expect(command).toBe(resolve(dirname(CLI_PKG_JSON), "bin/sentry-cli")); + }); + + it("falls back to an '@sentry/cli'-keyed bin field when 'sentry-cli' isn't present", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: { "@sentry/cli": "bin/alt-cli" } }) }, + }); + setEnv({ SENTRY_CLI_PATH: undefined }); + await run(); + expect(process.exitCode).toBe(0); + const [command] = spawnSyncMock.mock.calls[0] ?? []; + expect(command).toBe(resolve(dirname(CLI_PKG_JSON), "bin/alt-cli")); + }); + + it("fails when @sentry/cli's package.json has no resolvable bin entry", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({}) }, + }); + setEnv({ SENTRY_CLI_PATH: undefined }); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no resolvable bin entry")); + }); + + it("skips release validation entirely when DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE is off", async () => { + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "0" }); + await run(); + expect(spawnSyncMock.mock.calls.some(([, args]) => isValidateReleaseCall(args))).toBe(false); + }); + + it("runs release validation once and succeeds on the first attempt", async () => { + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1" }); + await run(); + expect(process.exitCode).toBe(0); + const validateCalls = spawnSyncMock.mock.calls.filter(([, args]) => isValidateReleaseCall(args)); + expect(validateCalls).toHaveLength(1); + }); + + it("retries release validation until it succeeds, logging a retry warning each time", async () => { + let validateAttempts = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isValidateReleaseCall(args)) { + validateAttempts += 1; + return validateAttempts < 3 ? { status: 1, stdout: "", stderr: "release not fully propagated yet" } : spawnSuccess(); + } + return spawnSuccess(); + }); + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "5", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); + await run(); + expect(process.exitCode).toBe(0); + expect(validateAttempts).toBe(3); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_release_validation_retry")); + }); + + it("falls back to the default attempt count when DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS is invalid", async () => { + let validateAttempts = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isValidateReleaseCall(args)) { + validateAttempts += 1; + return { status: 1, stdout: "", stderr: "still not visible" }; + } + return spawnSuccess(); + }); + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "not-a-number", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); + await run(); + expect(process.exitCode).toBe(0); + // Non-strict: exhausting all attempts is caught by main() and treated as a soft failure. + expect(validateAttempts).toBe(5); + }); + + it("clamps an oversized DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS to its max of 20", async () => { + let validateAttempts = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isValidateReleaseCall(args)) { + validateAttempts += 1; + return { status: 1, stdout: "", stderr: "still not visible" }; + } + return spawnSuccess(); + }); + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "999", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); + await run(); + expect(validateAttempts).toBe(20); + }); + + it("tolerates a spawnSync result missing stdout/stderr, and logs verbose output on success", async () => { + let call = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + call += 1; + // No stdout/stderr keys at all -> exercises the `result.stdout ?? ""` / `result.stderr ?? ""` + // fallback used to build runSentry's `output` string. + if (call === 1) return { status: 0 }; + if (call === 2) return { status: 0, stdout: "uploaded 3 files" }; + return spawnSuccess(); + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_cli")); + }); + + it("defaults release validation to ON when DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE is unset", async () => { + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: undefined }); + await run(); + expect(process.exitCode).toBe(0); + expect(spawnSyncMock.mock.calls.some(([, args]) => isValidateReleaseCall(args))).toBe(true); + }); + + it("logs verbose release-validation output, waits between retries, and tolerates a missing stdout/stderr result", async () => { + let attempt = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (!isValidateReleaseCall(args)) return spawnSuccess(); + attempt += 1; + // First attempt fails with no stdout/stderr keys (the ?? "" fallback); second succeeds with real + // output (the `if (output) log(...)` truthy branch). + if (attempt === 1) return { status: 1 }; + return { status: 0, stdout: "release visible" }; + }); + setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "3", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "5" }); + await run(); + expect(process.exitCode).toBe(0); + expect(attempt).toBe(2); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_release_validation")); + }); + + it("handles a non-Error value thrown out of spawnSync as a soft failure", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (args[0] === "releases" && args.includes("new")) { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately + throw "spawnSync exploded (string throw)"; + } + return spawnSuccess(); + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); + }); + + it("exhausts validation attempts and fails strictly (exit 1) when set to strict", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; + return spawnSuccess(); + }); + setEnv({ + DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", + DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "2", + DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0", + DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true", + }); + await run(); + expect(process.exitCode).toBe(1); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); + }); +});