diff --git a/package-lock.json b/package-lock.json index 731f314ce0..b378b3f12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4061,6 +4061,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@posthog/cli": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@posthog/cli/-/cli-0.9.1.tgz", + "integrity": "sha512-O02l2e5wuuIUzqdF3Q1gtqgi139fK1V9uXRJn8WToxmazT48ub/ODIrB3A+G/cK+ccZPc05NuPLqcsT1VOFkgw==", + "hasInstallScript": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "posthog-cli": "run-posthog-cli.js" + }, + "engines": { + "node": ">=14.14", + "npm": ">=6" + } + }, + "node_modules/@posthog/cli/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@posthog/cli/node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "extraneous": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@posthog/core": { "version": "1.42.1", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.42.1.tgz", @@ -6413,191 +6456,6 @@ "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", @@ -19959,6 +19817,7 @@ "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" @@ -21042,9 +20901,9 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "@posthog/cli": "0.9.1", + "hono": "^4.12.27", + "posthog-node": "^5.44.0" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260721.1", diff --git a/packages/discovery-index/Dockerfile b/packages/discovery-index/Dockerfile index 52991a27de..165b6b6e22 100644 --- a/packages/discovery-index/Dockerfile +++ b/packages/discovery-index/Dockerfile @@ -34,12 +34,14 @@ 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), -# 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). +# POSTHOG_API_KEY/POSTHOG_HOST/POSTHOG_ENVIRONMENT/POSTHOG_RELEASE (error tracking, #8289 -- optional, no-op +# when POSTHOG_API_KEY is unset), and for source-map upload specifically: POSTHOG_CLI_API_KEY, +# POSTHOG_CLI_PROJECT_ID, POSTHOG_CLI_HOST (also optional -- upload-sourcemaps.ts skips itself with a log +# line, never fails the boot, when POSTHOG_CLI_API_KEY/PROJECT_ID are unset). All PostHog vars are pending +# #7875 (a real, loopover-owned PostHog project), so this is currently inert in every real deployment. # # 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 +# POSTHOG_CLI_API_KEY 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 39cdabcb44..6d36955a1c 100644 --- a/packages/discovery-index/OPERATIONS.md +++ b/packages/discovery-index/OPERATIONS.md @@ -52,4 +52,4 @@ All opted-in miners authenticate with **one shared `DISCOVERY_INDEX_SHARED_SECRE `/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. +**Error tracking (#8289):** route/process-level errors report into PostHog — check it first when triaging a spike in `/metrics`' error counters, before digging into Cloudflare's own Worker logs. See `src/posthog.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 stack traces point at real TypeScript instead of a minified bundle. Previously reported into the shared `metagraphed` Sentry project (`jsonbored` org) other infra/operational pieces flow into, until #8289 replaced it outright. diff --git a/packages/discovery-index/README.md b/packages/discovery-index/README.md index afc985f23e..afd6fc4d12 100644 --- a/packages/discovery-index/README.md +++ b/packages/discovery-index/README.md @@ -27,17 +27,14 @@ 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`. | +| `POSTHOG_API_KEY` / `POSTHOG_HOST` / `POSTHOG_ENVIRONMENT` / `POSTHOG_RELEASE` | PostHog error tracking (#8289). Unset ⇒ PostHog stays inert (no-op) everywhere, matching this repo's other services. `POSTHOG_API_KEY` is a write-only project token, safe as a plain `wrangler.jsonc` var once set; `POSTHOG_RELEASE` is set per-deploy, not static — see `wrangler.jsonc`'s header comment. **Pending #7875** (a real, loopover-owned PostHog project) — currently unset everywhere, so this stays a complete no-op. | +| `POSTHOG_CLI_API_KEY` / `POSTHOG_CLI_PROJECT_ID` / `POSTHOG_CLI_HOST` | PostHog **personal** API key (error-tracking write + organization read scopes, distinct from `POSTHOG_API_KEY`'s project token) + numeric project id, used only by `upload-sourcemaps.ts` at container startup to upload this build's source maps. `POSTHOG_CLI_API_KEY` is genuinely sensitive — always a `wrangler secret`. Unset ⇒ source-map upload skips itself with a log line; never fails the boot. **Pending #7875**, same as above. | -## Error tracking & releases (#4934) +## Error tracking & releases (#4934, #8289) -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`. +Runtime errors (route handler failures, unhandled rejections/exceptions) report into PostHog. See `src/posthog.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`. This surface previously reported into the shared `metagraphed` Sentry project (`jsonbored` org) that this repo's other infra/operational pieces flow into -- a genuine cross-project entanglement -- until #8289 replaced it outright. -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. +Source maps and release 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 — `POSTHOG_CLI_API_KEY` is a runtime-only secret, never baked into an image layer): it injects PostHog chunk ids, uploads the exact post-injection source maps, then verifies at least one symbol set exists for the release via `scripts/validate-posthog-release.mjs` (queries PostHog's `error_tracking/symbol_sets` API — PostHog's release model has no equivalent of Sentry's commits/deploys/finalize lifecycle, so this is a narrower check by design), before deleting the `.map` files it just uploaded and starting `server.ts`. Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT=true` is set. `posthog-cli sourcemap inject`/`upload` run with an explicit `--release-version` (not the CLI's own git-metadata auto-detection — the Docker build stage never copies `.git` into the image). ## Deployment diff --git a/packages/discovery-index/package.json b/packages/discovery-index/package.json index 8ae45ee785..3a26aa5bcc 100644 --- a/packages/discovery-index/package.json +++ b/packages/discovery-index/package.json @@ -21,9 +21,9 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "@posthog/cli": "0.9.1", + "hono": "^4.12.27", + "posthog-node": "^5.44.0" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260721.1", diff --git a/packages/discovery-index/scripts/validate-posthog-release.d.mts b/packages/discovery-index/scripts/validate-posthog-release.d.mts new file mode 100644 index 0000000000..da75dd90a8 --- /dev/null +++ b/packages/discovery-index/scripts/validate-posthog-release.d.mts @@ -0,0 +1,25 @@ +export type PostHogReleaseValidationConfig = { + apiKey: string | undefined; + projectId: string | undefined; + release: string | undefined; + baseUrl: string; +}; + +export type PostHogReleaseValidationResult = { + release: string | undefined; + symbolSetCount: number; +}; + +export declare class PostHogReleaseValidationError extends Error { + failures: string[]; + constructor(message: string, failures?: string[]); +} + +export declare function loadPostHogReleaseValidationConfig( + env?: Record, +): PostHogReleaseValidationConfig; + +export declare function validatePostHogRelease( + env?: Record, + fetchImpl?: typeof fetch, +): Promise; diff --git a/packages/discovery-index/scripts/validate-posthog-release.mjs b/packages/discovery-index/scripts/validate-posthog-release.mjs new file mode 100644 index 0000000000..89df6c1046 --- /dev/null +++ b/packages/discovery-index/scripts/validate-posthog-release.mjs @@ -0,0 +1,123 @@ +// PostHog release/sourcemap-upload verification (#8289), the PostHog counterpart to validate-sentry-release.mjs. +// PostHog's release model is intentionally much simpler than Sentry's -- there is no separate release +// resource with its own commits/deploys/finalize lifecycle; a release is purely a version string baked into +// each uploaded symbol set's metadata as a byproduct of `posthog-cli sourcemap upload`. This script therefore +// verifies a narrower, honestly-scoped claim than its Sentry sibling: that at least one symbol set exists for +// our release, and none of them recorded a failure_reason. There is no PostHog equivalent to check for +// "commits associated" or "deploy recorded" or "finalized" -- those concepts don't exist in this API. +import { pathToFileURL } from "node:url"; + +const DEFAULT_POSTHOG_APP_HOST = "https://us.posthog.com"; + +export class PostHogReleaseValidationError extends Error { + constructor(message, failures = []) { + super(message); + this.name = "PostHogReleaseValidationError"; + this.failures = failures; + } +} + +function nonBlank(value) { + const text = typeof value === "string" ? value.trim() : undefined; + return text ? text : undefined; +} + +function apiBaseUrl(value) { + return (nonBlank(value) ?? DEFAULT_POSTHOG_APP_HOST).replace(/\/+$/, ""); +} + +export function loadPostHogReleaseValidationConfig(env = process.env) { + return { + // The same personal API key posthog-cli's upload step uses (error-tracking write + organization read + // scopes) -- listing symbol sets needs error_tracking:read, which that scope grant already covers. + apiKey: nonBlank(env.POSTHOG_CLI_API_KEY), + projectId: nonBlank(env.POSTHOG_CLI_PROJECT_ID), + release: nonBlank(env.POSTHOG_RELEASE), + baseUrl: apiBaseUrl(env.POSTHOG_CLI_HOST), + }; +} + +function requireConfig(config) { + const missing = [ + ["POSTHOG_CLI_API_KEY", config.apiKey], + ["POSTHOG_CLI_PROJECT_ID", config.projectId], + ["POSTHOG_RELEASE", config.release], + ] + .filter(([, value]) => !value) + .map(([name]) => name); + if (missing.length > 0) { + throw new PostHogReleaseValidationError("missing PostHog release validation config", [`missing ${missing.join(", ")}`]); + } +} + +function symbolSetsUrl(config) { + return `${config.baseUrl}/api/projects/${encodeURIComponent(config.projectId)}/error_tracking/symbol_sets?limit=100`; +} + +async function fetchSymbolSets(config, fetchImpl) { + const response = await fetchImpl(symbolSetsUrl(config), { + headers: { accept: "application/json", authorization: `Bearer ${config.apiKey}` }, + }); + 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 PostHogReleaseValidationError("PostHog API request failed", [`error_tracking/symbol_sets returned HTTP ${response.status}${message ? ` (${message})` : ""}`]); + } + const body = await response.json(); + return Array.isArray(body) ? body : Array.isArray(body?.results) ? body.results : []; +} + +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 validatePostHogRelease(env = process.env, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== "function") { + throw new PostHogReleaseValidationError("fetch is unavailable", ["Node 20+ fetch support is required"]); + } + + const config = loadPostHogReleaseValidationConfig(env); + requireConfig(config); + + const symbolSets = await fetchSymbolSets(config, fetchImpl); + const forRelease = symbolSets.filter((set) => nonBlank(set?.release) === config.release); + + const failures = []; + if (forRelease.length === 0) { + failures.push(`no symbol sets found for release ${config.release}`); + } + const failed = forRelease.filter((set) => nonBlank(set?.failure_reason)); + if (failed.length > 0) { + failures.push(`${failed.length} symbol set(s) for release ${config.release} recorded a failure_reason`); + } + + if (failures.length > 0) { + throw new PostHogReleaseValidationError("PostHog release validation failed", failures); + } + + return { release: config.release, symbolSetCount: forRelease.length }; +} + +async function main() { + try { + const result = await validatePostHogRelease(); + log("discovery_index_posthog_release_validation_complete", result); + } catch (error) { + const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; + logError("discovery_index_posthog_release_validation_failed", { release: nonBlank(process.env.POSTHOG_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-sentry-release.mjs b/packages/discovery-index/scripts/validate-sentry-release.mjs deleted file mode 100644 index 3e13bde70f..0000000000 --- a/packages/discovery-index/scripts/validate-sentry-release.mjs +++ /dev/null @@ -1,244 +0,0 @@ -// 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/src/app.ts b/packages/discovery-index/src/app.ts index ccef3501fa..c832631b87 100644 --- a/packages/discovery-index/src/app.ts +++ b/packages/discovery-index/src/app.ts @@ -15,7 +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 { captureRoutePostHogError } from "./posthog.js"; import { parseSoftClaimRequest, softClaimKey, type SoftClaimStoreLike } from "./soft-claim.js"; export interface AppDeps { @@ -49,7 +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 }); + captureRoutePostHogError(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 71ea4eba6d..7867483e59 100644 --- a/packages/discovery-index/src/env.d.ts +++ b/packages/discovery-index/src/env.d.ts @@ -11,15 +11,27 @@ 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; + /** PostHog PROJECT token (#8289) -- error capture. PENDING #7875 (a real loopover-owned PostHog project): + * once provisioned, this graduates to a plain wrangler.jsonc `vars` entry (write-only, safe to commit) + * and this declaration is removed. Declared here only so it type-checks in the meantime; optional and + * no-op when unset. */ + POSTHOG_API_KEY?: string; + /** PostHog ingestion host override (EU region). PENDING #7875, see POSTHOG_API_KEY above. */ + POSTHOG_HOST?: string; + /** PostHog capture `environment` tag override. PENDING #7875. */ + POSTHOG_ENVIRONMENT?: string; + /** The deploy's PostHog release identifier -- deliberately NOT a static wrangler.jsonc var since it must + * change every deploy; set via `wrangler deploy --var POSTHOG_RELEASE:...`. PENDING #7875. */ + POSTHOG_RELEASE?: string; + /** A PostHog PERSONAL API key (error-tracking write + organization read scopes) -- genuinely sensitive, + * used only for source-map upload via posthog-cli. Optional: upload-sourcemaps.ts's PostHog step no-ops + * with a log line when unset, never fails the boot. PENDING #7875. */ + POSTHOG_CLI_API_KEY?: string; + /** The PostHog project's numeric id (not a secret -- a plain identifier), required alongside + * POSTHOG_CLI_API_KEY for source-map upload. PENDING #7875. */ + POSTHOG_CLI_PROJECT_ID?: string; + /** posthog-cli host override (EU region), mirroring POSTHOG_HOST for the CLI's own separate auth config. PENDING #7875. */ + POSTHOG_CLI_HOST?: string; } // `import { env } from "cloudflare:workers"` (used in worker.ts's Container class field initializers, @@ -29,8 +41,13 @@ declare global { interface Env { DISCOVERY_INDEX_SHARED_SECRET: string; DISCOVERY_INDEX_GITHUB_TOKEN: string; - SENTRY_AUTH_TOKEN?: string; - SENTRY_RELEASE?: string; + POSTHOG_API_KEY?: string; + POSTHOG_HOST?: string; + POSTHOG_ENVIRONMENT?: string; + POSTHOG_RELEASE?: string; + POSTHOG_CLI_API_KEY?: string; + POSTHOG_CLI_PROJECT_ID?: string; + POSTHOG_CLI_HOST?: string; } } } diff --git a/packages/discovery-index/src/posthog.ts b/packages/discovery-index/src/posthog.ts new file mode 100644 index 0000000000..5465be8850 --- /dev/null +++ b/packages/discovery-index/src/posthog.ts @@ -0,0 +1,182 @@ +// PostHog error tracking for discovery-index (#8289, epic #8286). Parallel-run alongside sentry.ts (#4934) -- +// both active simultaneously when configured; Sentry is only removed once the gated decommission issue +// (#8298) says so, per-issue across all six Phase-1 surfaces at once. This service runs as a long-lived Node +// process in a Cloudflare Container (not a Workers isolate), so posthog-node's default batching client is +// fine here -- unlike src/api/worker-posthog.ts's per-request ephemeral-client design for the actual Workers +// isolate path, this mirrors src/selfhost/posthog.ts's long-running-server posture. +// +// Deliberately mirrors sentry.ts's shape (redaction, tag allowlist, capture entry points) rather than PostHog's +// own withScope-free API design, so the two modules stay easy to compare line-for-line during the parallel-run +// window. PostHog has no Sentry-style context/tags split -- captureException's flat properties bag carries the +// SAME allowlisted fields sentry.ts's setAllowedTags exposes as tags (DISCOVERY_INDEX_POSTHOG_TAG_KEYS), not +// the raw context object, so the redaction surface stays identical between the two sinks. +import type { PostHog } from "posthog-node"; + +type PostHogClient = Pick; + +let client: PostHogClient | undefined; +let active = false; +let activeRelease: string | undefined; +let activeEnvironment = "production"; + +/** No per-user identity is tracked (operational error events, not user analytics) -- every event shares one + * anonymous, constant distinct id, matching src/selfhost/posthog.ts's identical POSTHOG_DISTINCT_ID choice. */ +const DISTINCT_ID = "loopover-discovery-index"; + +/** PostHog US-cloud ingestion host, matching every other PostHog sink in this repo's default. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +// Identical redaction rules to sentry.ts's SECRET_FIELD/SECRET_VALUE (#8289 deliverable: capture/redaction +// parity with the existing Sentry sink) -- kept as a literal copy rather than an import so this module has no +// compile-time dependency on sentry.ts at all, matching the two sinks' fully independent parallel-run posture. +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_POSTHOG_TAG_KEYS = ["event", "route", "method", "release", "environment"] as const; + +type DiscoveryIndexPostHogTagKey = (typeof DISCOVERY_INDEX_POSTHOG_TAG_KEYS)[number]; +type DiscoveryIndexPostHogTags = Partial>; +type CaptureOptions = { + fingerprint: string[]; + tags: DiscoveryIndexPostHogTags; + /** Additional diagnostic properties outside the fixed tag allowlist (e.g. captureSourcemapUploadPostHogFailure's + * deploymentId/strict/sha) -- still scrubbed like everything else, just not one of the five stable tag keys. */ + extra?: Record; +}; + +function nonBlank(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +export function resolveDiscoveryIndexPostHogRelease(env: NodeJS.ProcessEnv): string | undefined { + return nonBlank(env.POSTHOG_RELEASE) ?? (nonBlank(env.POSTHOG_COMMIT_SHA) ? `loopover-discovery-index@${nonBlank(env.POSTHOG_COMMIT_SHA)}` : undefined); +} + +export function resolvePostHogEnvironment(env: NodeJS.ProcessEnv): string { + return nonBlank(env.POSTHOG_ENVIRONMENT) ?? "production"; +} + +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 tagValue(value: string | number | undefined): string | undefined { + if (value === undefined) return undefined; + const scrubbed = scrubValue(String(value)); + /* v8 ignore next -- @preserve unreachable: scrubValue(string) always returns a string, mirrors sentry.ts's identical sentryTagValue guard */ + 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 allowedTagProperties(tags: DiscoveryIndexPostHogTags): Record { + const properties: Record = {}; + for (const key of DISCOVERY_INDEX_POSTHOG_TAG_KEYS) { + const value = tagValue(tags[key]); + if (value) properties[key] = value; + } + return properties; +} + +function fingerprint(parts: string[]): string { + return parts.map((part) => tagValue(part) ?? "unknown").join("|"); +} + +function captureScopedError(error: unknown, options: CaptureOptions): void { + if (!active || !client) return; + const tags = { ...options.tags, release: options.tags.release ?? activeRelease, environment: options.tags.environment ?? activeEnvironment }; + const properties = { + ...allowedTagProperties(tags), + ...compactContext(options.extra ?? {}), + }; + const safeProperties = scrubValue(properties) as Record; + safeProperties.$exception_fingerprint = fingerprint(options.fingerprint); + client.captureException(error instanceof Error ? error : new Error(String(error)), DISTINCT_ID, safeProperties); +} + +export async function initDiscoveryIndexPostHog(env: NodeJS.ProcessEnv): Promise { + const apiKey = nonBlank(env.POSTHOG_API_KEY); + if (!apiKey) return false; + try { + const { PostHog } = await import("posthog-node"); + activeRelease = resolveDiscoveryIndexPostHogRelease(env); + activeEnvironment = resolvePostHogEnvironment(env); + client = new PostHog(apiKey, { + host: nonBlank(env.POSTHOG_HOST) ?? DEFAULT_POSTHOG_HOST, + before_send: (event) => (event ? (scrubValue(event) as typeof event) : event), + }); + active = true; + return true; + } catch (error) { + active = false; + client = undefined; + activeRelease = undefined; + activeEnvironment = "production"; + warn("discovery_index_posthog_init_failed", { message: error instanceof Error ? error.message : String(error) }); + return false; + } +} + +export function captureRoutePostHogError(error: unknown, context: { route: string; method: string }): void { + captureScopedError(error, { + fingerprint: ["discovery-index-route-error", context.route, context.method], + tags: { event: "discovery_index_route_error", route: context.route, method: context.method }, + }); +} + +export function captureUnhandledPostHogError(error: unknown, context: { event: "discovery_index_unhandled_rejection" | "discovery_index_uncaught_exception" }): void { + captureScopedError(error, { + fingerprint: ["discovery-index-process-error", context.event], + tags: { event: context.event }, + }); +} + +export function captureSourcemapUploadPostHogFailure( + error: unknown, + context: { release?: string | undefined; deploymentId?: string | undefined; strict?: boolean; sha?: string | undefined }, +): void { + captureScopedError(error, { + fingerprint: ["discovery-index-sourcemap-upload-failed"], + tags: { event: "discovery_index_sourcemap_upload_failed", release: context.release ?? activeRelease }, + extra: { deploymentId: context.deploymentId, strict: context.strict, sha: context.sha }, + }); +} + +export async function flushDiscoveryIndexPostHog(): Promise { + if (!active || !client) return; + await client.flush().catch(() => undefined); +} + +export async function shutdownDiscoveryIndexPostHog(): Promise { + if (!active || !client) return; + await client.shutdown().catch(() => undefined); +} + +export function resetDiscoveryIndexPostHogForTest(): void { + client = undefined; + active = false; + activeRelease = undefined; + activeEnvironment = "production"; +} + +export function setDiscoveryIndexPostHogForTest(posthog: PostHogClient, options: { release?: string; environment?: string } = {}): void { + client = posthog; + active = true; + activeRelease = options.release; + activeEnvironment = options.environment ?? "production"; +} diff --git a/packages/discovery-index/src/sentry.ts b/packages/discovery-index/src/sentry.ts deleted file mode 100644 index 971a435952..0000000000 --- a/packages/discovery-index/src/sentry.ts +++ /dev/null @@ -1,197 +0,0 @@ -// 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 538536eed5..cea8ebb671 100644 --- a/packages/discovery-index/src/server.ts +++ b/packages/discovery-index/src/server.ts @@ -10,12 +10,12 @@ 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 { captureUnhandledPostHogError, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolvePostHogEnvironment, shutdownDiscoveryIndexPostHog } from "./posthog.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 posthogEnabled = await initDiscoveryIndexPostHog(process.env); +if (posthogEnabled) { + console.log(JSON.stringify({ event: "discovery_index_posthog", environment: resolvePostHogEnvironment(process.env) })); } const githubToken = process.env.DISCOVERY_INDEX_GITHUB_TOKEN ?? ""; @@ -40,16 +40,16 @@ serve({ fetch: app.fetch, port }, (info) => { }); process.on("unhandledRejection", (reason) => { - captureUnhandledError(reason, { event: "discovery_index_unhandled_rejection" }); + captureUnhandledPostHogError(reason, { event: "discovery_index_unhandled_rejection" }); }); process.on("uncaughtException", (error) => { - captureUnhandledError(error, { event: "discovery_index_uncaught_exception" }); - void flushSentry().finally(() => process.exit(1)); + captureUnhandledPostHogError(error, { event: "discovery_index_uncaught_exception" }); + void flushDiscoveryIndexPostHog().finally(() => process.exit(1)); }); process.on("SIGTERM", () => { - void flushSentry().finally(() => process.exit(0)); + void shutdownDiscoveryIndexPostHog().finally(() => process.exit(0)); }); export { app }; diff --git a/packages/discovery-index/src/upload-sourcemaps.ts b/packages/discovery-index/src/upload-sourcemaps.ts index 34d3841042..b3020a7cf1 100644 --- a/packages/discovery-index/src/upload-sourcemaps.ts +++ b/packages/discovery-index/src/upload-sourcemaps.ts @@ -1,10 +1,8 @@ -// 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. +// Uploads this build's source maps to PostHog at container startup, then deletes them before the real server +// starts (see the Dockerfile's runtime CMD). REPLACES the old Sentry-based pipeline entirely (#8289, epic +// #8286's revised strategy, 2026-07-25 correction on #8286): no more @sentry/cli. // -// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: SENTRY_AUTH_TOKEN is a +// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: POSTHOG_CLI_API_KEY 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. @@ -15,15 +13,10 @@ 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"; +import { captureSourcemapUploadPostHogFailure, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolveDiscoveryIndexPostHogRelease } from "./posthog.js"; const require = createRequire(import.meta.url); -type RunOptions = { - allowExistingRelease?: boolean; - allowFailure?: boolean; -}; - const distDir = dirname(fileURLToPath(import.meta.url)); const appDir = resolve(distDir, ".."); @@ -82,38 +75,8 @@ function validateSourceMaps(): void { 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 ?? ""); + return !/^(0|false|no|off)$/i.test(process.env.DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE ?? ""); } function numericEnv(name: string, fallback: number, max: number): number { @@ -121,94 +84,93 @@ function numericEnv(name: string, fallback: number, max: number): number { 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 { +async function runReleaseValidation(release: string): 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); + const attempts = Math.max(1, numericEnv("DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS", 5, 20)); + const retryDelayMs = numericEnv("DISCOVERY_INDEX_POSTHOG_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"], { + const result = spawnSync(process.execPath, ["scripts/validate-posthog-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", - }, + env: { ...process.env, POSTHOG_RELEASE: release }, 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 }); + if (output) log("discovery_index_posthog_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) }); + warn("discovery_index_posthog_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)}`); + throw new Error(`PostHog release validation failed (${status}): ${output.slice(0, 500)}`); +} + +// Resolved via require.resolve, not a hardcoded packages/discovery-index/node_modules/.bin/ path: unlike +// review-enrichment (a standalone, non-workspace package), discovery-index is a real npm workspace member, +// so npm hoists @posthog/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 postHogCliPath(): string { + const override = nonBlank(process.env.POSTHOG_CLI_PATH); + if (override) return override; + const pkgJsonPath = require.resolve("@posthog/cli/package.json"); + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { bin?: string | Record }; + const binRelativePath = typeof pkg.bin === "string" ? pkg.bin : (pkg.bin?.["posthog-cli"] ?? pkg.bin?.["@posthog/cli"]); + if (!binRelativePath) throw new Error("@posthog/cli package.json has no resolvable bin entry"); + return join(dirname(pkgJsonPath), binRelativePath); +} + +function runPostHog(args: string[]): void { + // POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID/POSTHOG_CLI_HOST are read directly from the environment by + // posthog-cli itself (its own documented auth convention). + const result = spawnSync(postHogCliPath(), args, { cwd: appDir, env: process.env, encoding: "utf8" }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + if (result.status === 0) { + if (output) log("discovery_index_posthog_cli", { command: args.slice(0, 2).join(" "), output: output.slice(0, 300) }); + return; + } + throw new Error(`posthog-cli ${args.join(" ")} failed (${result.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); + /* v8 ignore next -- @preserve unreachable: initDiscoveryIndexPostHog(process.env) never rejects */ + await initDiscoveryIndexPostHog(process.env).catch(() => false); + const release = resolveDiscoveryIndexPostHogRelease(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, + POSTHOG_CLI_API_KEY: nonBlank(process.env.POSTHOG_CLI_API_KEY), + POSTHOG_CLI_PROJECT_ID: nonBlank(process.env.POSTHOG_CLI_PROJECT_ID), + POSTHOG_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 }); + log("discovery_index_posthog_sourcemap_upload_skipped", { reason: "missing_config", missing }); return 0; } - const strict = /^(1|true|yes|on)$/i.test(process.env.DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT ?? ""); + const strict = /^(1|true|yes|on)$/i.test(process.env.DISCOVERY_INDEX_POSTHOG_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"]); + // No separate "create release" step -- PostHog release metadata is a byproduct of the inject/upload + // calls below. Explicit --release-version rather than posthog-cli's own git-metadata auto-detection: the + // Dockerfile's build stage only copies packages/loopover-engine and packages/discovery-index source, + // never `.git`, so auto-detection has nothing to inspect at container-startup time. + runPostHog(["sourcemap", "inject", "--directory", "dist", "--release-version", release!]); 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 }); + runPostHog(["sourcemap", "upload", "--directory", "dist", "--release-version", release!]); + await runReleaseValidation(release!); + log("discovery_index_posthog_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 }); + captureSourcemapUploadPostHogFailure(error, { release, strict, sha: nonBlank(process.env.POSTHOG_COMMIT_SHA) }); + await flushDiscoveryIndexPostHog(); + warn("discovery_index_posthog_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); return strict ? 1 : 0; } } diff --git a/packages/discovery-index/src/worker.ts b/packages/discovery-index/src/worker.ts index c7a93097d6..b471f13f73 100644 --- a/packages/discovery-index/src/worker.ts +++ b/packages/discovery-index/src/worker.ts @@ -34,20 +34,25 @@ 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. + // PostHog vars (#8289) are forwarded whether or not they're actually configured -- + // initDiscoveryIndexPostHog/upload-sourcemaps.ts (both running inside the container, not this Worker) + // already no-op cleanly on an empty/unset POSTHOG_API_KEY/POSTHOG_CLI_API_KEY, so nothing here needs to + // conditionally omit them. Container.envVars requires Record (no undefined), so the + // optional ones are coerced to "" here, which every no-op guard already treats identically to unset. + // POSTHOG_CLI_API_KEY is a PostHog *personal* API key (error-tracking write + organization read scopes) -- + // deliberately separate from POSTHOG_API_KEY (the project token event capture uses), matching PostHog's own + // documented sourcemap-upload auth model. Replaces the Sentry vars this container used to forward (#4934) -- + // PostHog is a straight replacement here, not a parallel sink (epic #8286's 2026-07-25 strategy correction). 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, + POSTHOG_API_KEY: env.POSTHOG_API_KEY ?? "", + POSTHOG_HOST: env.POSTHOG_HOST ?? "", + POSTHOG_ENVIRONMENT: env.POSTHOG_ENVIRONMENT ?? "", + POSTHOG_RELEASE: env.POSTHOG_RELEASE ?? "", + POSTHOG_CLI_API_KEY: env.POSTHOG_CLI_API_KEY ?? "", + POSTHOG_CLI_PROJECT_ID: env.POSTHOG_CLI_PROJECT_ID ?? "", + POSTHOG_CLI_HOST: env.POSTHOG_CLI_HOST ?? "", }; } @@ -56,12 +61,13 @@ 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; + POSTHOG_API_KEY?: string; + POSTHOG_HOST?: string; + POSTHOG_ENVIRONMENT?: string; + POSTHOG_RELEASE?: string; + POSTHOG_CLI_API_KEY?: string; + POSTHOG_CLI_PROJECT_ID?: string; + POSTHOG_CLI_HOST?: 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 6a143c0617..93858a2263 100644 --- a/packages/discovery-index/tsconfig.json +++ b/packages/discovery-index/tsconfig.json @@ -8,9 +8,9 @@ "rootDir": "src", "outDir": "dist", "noEmit": false, - // Sentry source-map upload (upload-sourcemaps.ts, #4934) needs real, embedded original source -- + // PostHog source-map upload (upload-sourcemaps.ts, #8289) 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 + // identical setup, so PostHog can render the actual TypeScript in a stack trace without a separate // source upload step. "sourceMap": true, "inlineSources": true, diff --git a/packages/discovery-index/worker-configuration.d.ts b/packages/discovery-index/worker-configuration.d.ts index 860d1ab468..af8640063f 100644 --- a/packages/discovery-index/worker-configuration.d.ts +++ b/packages/discovery-index/worker-configuration.d.ts @@ -1,11 +1,7 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: eb3cdf7ee4529b8efb96d70f4144a615) +// Generated by Wrangler by running `wrangler types` (hash: d2eeff9c442143d238b1256f3c987930) // 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 043b2cdc07..220dae1df1 100644 --- a/packages/discovery-index/wrangler.jsonc +++ b/packages/discovery-index/wrangler.jsonc @@ -7,26 +7,29 @@ // 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) + // npx wrangler secret put POSTHOG_CLI_API_KEY (a PostHog PERSONAL API key, error-tracking write + + // organization read scopes -- only needed for source-map + // upload; omit to skip it) // - // SENTRY_RELEASE is deliberately NOT a static var below -- it should be the actual deploy's git commit + // POSTHOG_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) + // npx wrangler deploy --var POSTHOG_RELEASE:loopover-discovery-index@$(git rev-parse HEAD) + // + // POSTHOG_API_KEY/POSTHOG_HOST/POSTHOG_CLI_PROJECT_ID/POSTHOG_CLI_HOST are PENDING #7875 (provisioning a + // real, loopover-owned PostHog project) -- deliberately NOT added below yet. Once #7875 lands, POSTHOG_API_KEY + // (a PostHog PROJECT token, write-only -- safe to commit) and POSTHOG_CLI_PROJECT_ID (a plain numeric id, + // not a secret) become static vars here; POSTHOG_HOST/POSTHOG_CLI_HOST only need setting for an EU-region + // project (both default to PostHog's US cloud when unset). Until then, initDiscoveryIndexPostHog + // (src/posthog.ts) and the PostHog leg of upload-sourcemaps.ts stay complete no-ops. + // + // #8289 (epic #8286) replaced this surface's error tracking outright: it used to report into the shared + // "metagraphed" Sentry project (jsonbored org) other infra/operational pieces flow into -- a genuine + // cross-project entanglement, not loopover's own. That Sentry wiring (the SENTRY_DSN/ORG/PROJECT vars this + // block used to carry) is now removed entirely, not left running in parallel. "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" - }, + "vars": {}, "observability": { "enabled": true, "logs": { diff --git a/test/unit/discovery-index/posthog.test.ts b/test/unit/discovery-index/posthog.test.ts new file mode 100644 index 0000000000..3edaacee4b --- /dev/null +++ b/test/unit/discovery-index/posthog.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + captureRoutePostHogError, + captureSourcemapUploadPostHogFailure, + captureUnhandledPostHogError, + flushDiscoveryIndexPostHog, + initDiscoveryIndexPostHog, + resetDiscoveryIndexPostHogForTest, + resolveDiscoveryIndexPostHogRelease, + resolvePostHogEnvironment, + setDiscoveryIndexPostHogForTest, + shutdownDiscoveryIndexPostHog, +} from "../../../packages/discovery-index/src/posthog"; + +function postHogHarness() { + const captured: Array<{ error: Error; distinctId: string; properties: Record }> = []; + const flushed: number[] = []; + const shutdowns: number[] = []; + setDiscoveryIndexPostHogForTest( + { + captureException: (error: unknown, distinctId: string, properties: Record) => { + captured.push({ error: error instanceof Error ? error : new Error(String(error)), distinctId, properties }); + }, + flush: async () => { + flushed.push(Date.now()); + }, + shutdown: async () => { + shutdowns.push(Date.now()); + }, + } as unknown as Parameters[0], + { release: "loopover-discovery-index@test", environment: "test" }, + ); + return { captured, flushed, shutdowns }; +} + +afterEach(() => { + resetDiscoveryIndexPostHogForTest(); +}); + +describe("resolveDiscoveryIndexPostHogRelease", () => { + it("prefers an explicit POSTHOG_RELEASE", () => { + expect(resolveDiscoveryIndexPostHogRelease({ POSTHOG_RELEASE: "v1", POSTHOG_COMMIT_SHA: "abc" } as unknown as NodeJS.ProcessEnv)).toBe("v1"); + }); + it("derives a release from POSTHOG_COMMIT_SHA when POSTHOG_RELEASE is unset", () => { + expect(resolveDiscoveryIndexPostHogRelease({ POSTHOG_COMMIT_SHA: "abc123" } as unknown as NodeJS.ProcessEnv)).toBe("loopover-discovery-index@abc123"); + }); + it("returns undefined when neither is set", () => { + expect(resolveDiscoveryIndexPostHogRelease({} as unknown as NodeJS.ProcessEnv)).toBeUndefined(); + }); +}); + +describe("resolvePostHogEnvironment", () => { + it("uses POSTHOG_ENVIRONMENT when set", () => { + expect(resolvePostHogEnvironment({ POSTHOG_ENVIRONMENT: "staging" } as unknown as NodeJS.ProcessEnv)).toBe("staging"); + }); + it("defaults to production", () => { + expect(resolvePostHogEnvironment({} as unknown as NodeJS.ProcessEnv)).toBe("production"); + }); + it("treats a blank/whitespace-only value as unset", () => { + expect(resolvePostHogEnvironment({ POSTHOG_ENVIRONMENT: " " } as unknown as NodeJS.ProcessEnv)).toBe("production"); + }); +}); + +describe("initDiscoveryIndexPostHog", () => { + it("stays inert (returns false) when POSTHOG_API_KEY is unset", async () => { + await expect(initDiscoveryIndexPostHog({} as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + }); + + it("stays inert when POSTHOG_API_KEY is blank/whitespace-only", async () => { + await expect(initDiscoveryIndexPostHog({ POSTHOG_API_KEY: " " } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + }); + + it("resets state and returns false when the dynamic posthog-node import throws", async () => { + vi.doMock("posthog-node", () => { + throw new Error("module load failed"); + }); + vi.resetModules(); + const { initDiscoveryIndexPostHog: initFresh } = await import("../../../packages/discovery-index/src/posthog"); + await expect(initFresh({ POSTHOG_API_KEY: "phc_test" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + vi.doUnmock("posthog-node"); + vi.resetModules(); + }); + + it("resets state and returns false when the PostHog constructor throws a non-Error value", async () => { + vi.doMock("posthog-node", () => ({ + PostHog: class { + constructor() { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately + throw "init failed (string throw)"; + } + }, + })); + vi.resetModules(); + const { initDiscoveryIndexPostHog: initFresh } = await import("../../../packages/discovery-index/src/posthog"); + await expect(initFresh({ POSTHOG_API_KEY: "phc_test" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(false); + vi.doUnmock("posthog-node"); + vi.resetModules(); + }); + + it("succeeds, wires before_send to scrub a real event, and leaves PostHog active", async () => { + let capturedOptions: { before_send?: (event: unknown) => unknown; host?: string } | undefined; + const captured: unknown[] = []; + vi.doMock("posthog-node", () => ({ + PostHog: class { + constructor(_apiKey: string, options: typeof capturedOptions) { + capturedOptions = options; + } + captureException(error: unknown) { + captured.push(error); + } + }, + })); + vi.resetModules(); + const { initDiscoveryIndexPostHog: initFresh, captureRoutePostHogError: captureFresh, resetDiscoveryIndexPostHogForTest: resetFresh } = await import( + "../../../packages/discovery-index/src/posthog" + ); + await expect(initFresh({ POSTHOG_API_KEY: "phc_test", POSTHOG_COMMIT_SHA: "abc" } as unknown as NodeJS.ProcessEnv)).resolves.toBe(true); + expect(capturedOptions?.host).toBe("https://us.i.posthog.com"); + const scrubbed = capturedOptions?.before_send?.({ properties: { authorization: "should-be-filtered" } }) as { properties: Record }; + expect(scrubbed.properties.authorization).toBe("[Filtered]"); + // Proves init actually left `active` true end-to-end: a real capture reaches the mocked client. + captureFresh(new Error("real capture"), { route: "/x", method: "GET" }); + expect(captured[0]).toBeInstanceOf(Error); + resetFresh(); + vi.doUnmock("posthog-node"); + vi.resetModules(); + }); + + it("uses POSTHOG_HOST when set", async () => { + let capturedHost: string | undefined; + vi.doMock("posthog-node", () => ({ + PostHog: class { + constructor(_apiKey: string, options: { host?: string }) { + capturedHost = options.host; + } + captureException() {} + }, + })); + vi.resetModules(); + const { initDiscoveryIndexPostHog: initFresh } = await import("../../../packages/discovery-index/src/posthog"); + await initFresh({ POSTHOG_API_KEY: "phc_test", POSTHOG_HOST: "https://eu.i.posthog.com" } as unknown as NodeJS.ProcessEnv); + expect(capturedHost).toBe("https://eu.i.posthog.com"); + vi.doUnmock("posthog-node"); + vi.resetModules(); + }); + + it("before_send passes through a null event unchanged", async () => { + let capturedOptions: { before_send?: (event: unknown) => unknown } | undefined; + vi.doMock("posthog-node", () => ({ + PostHog: class { + constructor(_apiKey: string, options: typeof capturedOptions) { + capturedOptions = options; + } + captureException() {} + }, + })); + vi.resetModules(); + const { initDiscoveryIndexPostHog: initFresh } = await import("../../../packages/discovery-index/src/posthog"); + await initFresh({ POSTHOG_API_KEY: "phc_test" } as unknown as NodeJS.ProcessEnv); + expect(capturedOptions?.before_send?.(null)).toBeNull(); + vi.doUnmock("posthog-node"); + vi.resetModules(); + }); +}); + +describe("captureRoutePostHogError", () => { + it("is inert when PostHog is disabled", () => { + expect(() => captureRoutePostHogError(new Error("boom"), { route: "/v1/discovery-index/query", method: "POST" })).not.toThrow(); + }); + + it("tags, fingerprints, and captures a route-level error", () => { + const posthog = postHogHarness(); + captureRoutePostHogError(new Error("boom"), { route: "/v1/discovery-index/query", method: "POST" }); + + expect(posthog.captured).toHaveLength(1); + const [capture] = posthog.captured; + expect(capture!.distinctId).toBe("loopover-discovery-index"); + expect(capture!.error.message).toBe("boom"); + expect(capture!.properties.$exception_fingerprint).toBe("discovery-index-route-error|/v1/discovery-index/query|POST"); + expect(capture!.properties.event).toBe("discovery_index_route_error"); + expect(capture!.properties.route).toBe("/v1/discovery-index/query"); + expect(capture!.properties.method).toBe("POST"); + expect(capture!.properties.release).toBe("loopover-discovery-index@test"); + expect(capture!.properties.environment).toBe("test"); + }); + + it("wraps a non-Error throw into a real Error before capture", () => { + const posthog = postHogHarness(); + captureRoutePostHogError("a plain string throw", { route: "/health", method: "GET" }); + expect(posthog.captured[0]?.error.message).toBe("a plain string throw"); + }); + + it("drops an empty tag value instead of setting a blank property, and falls fingerprint parts back to 'unknown'", () => { + const posthog = postHogHarness(); + captureRoutePostHogError(new Error("boom"), { route: "", method: "GET" }); + expect(posthog.captured[0]?.properties.route).toBeUndefined(); + expect(posthog.captured[0]?.properties.$exception_fingerprint).toBe("discovery-index-route-error|unknown|GET"); + }); +}); + +describe("captureUnhandledPostHogError", () => { + it("fingerprints process-level failures by event class", () => { + const posthog = postHogHarness(); + captureUnhandledPostHogError(new Error("kaboom"), { event: "discovery_index_uncaught_exception" }); + + expect(posthog.captured[0]?.properties.$exception_fingerprint).toBe("discovery-index-process-error|discovery_index_uncaught_exception"); + expect(posthog.captured[0]?.properties.event).toBe("discovery_index_uncaught_exception"); + }); + + it("covers the unhandled_rejection event branch too", () => { + const posthog = postHogHarness(); + captureUnhandledPostHogError(new Error("rejected"), { event: "discovery_index_unhandled_rejection" }); + expect(posthog.captured[0]?.properties.event).toBe("discovery_index_unhandled_rejection"); + }); +}); + +describe("captureSourcemapUploadPostHogFailure", () => { + it("applies stable upload grouping and forwards optional extra fields", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("upload failed"), { + release: "loopover-discovery-index@abc", + deploymentId: "cloudflare-container", + strict: true, + sha: "abcdef1234567890", + }); + + expect(posthog.captured[0]?.properties.$exception_fingerprint).toBe("discovery-index-sourcemap-upload-failed"); + expect(posthog.captured[0]?.properties.event).toBe("discovery_index_sourcemap_upload_failed"); + expect(posthog.captured[0]?.properties.release).toBe("loopover-discovery-index@abc"); + expect(posthog.captured[0]?.properties.deploymentId).toBe("cloudflare-container"); + expect(posthog.captured[0]?.properties.strict).toBe(true); + expect(posthog.captured[0]?.properties.sha).toBe("abcdef1234567890"); + }); + + it("falls back to the active release when no explicit release is given", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("upload failed"), {}); + expect(posthog.captured[0]?.properties.release).toBe("loopover-discovery-index@test"); + expect(posthog.captured[0]?.properties.deploymentId).toBeUndefined(); + expect(posthog.captured[0]?.properties.strict).toBeUndefined(); + expect(posthog.captured[0]?.properties.sha).toBeUndefined(); + }); +}); + +describe("secret scrubbing", () => { + it("redacts an extra field by KEY name regardless of its value (object branch)", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("boom"), { sha: { authorization: "innocuous-looking-value" } } as never); + expect(posthog.captured[0]?.properties.sha).toEqual({ authorization: "[Filtered]" }); + }); + + it("redacts secret-named keys inside a nested array value (array branch)", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("boom"), { sha: [{ token: "should-be-filtered" }, "plain-string"] } as never); + expect(posthog.captured[0]?.properties.sha).toEqual([{ token: "[Filtered]" }, "plain-string"]); + }); + + it("redacts a GitHub-token-shaped VALUE (not just key name) inside upload-failure properties", () => { + const posthog = postHogHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadPostHogFailure(new Error(`upload failed for ${fakeToken}`), { sha: fakeToken }); + expect(JSON.stringify(posthog.captured[0]?.properties)).not.toContain(fakeToken); + expect(posthog.captured[0]?.properties.sha).toBe("[Filtered]"); + }); + + it("filters a secret-shaped tag value down to [Filtered]", () => { + const posthog = postHogHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadPostHogFailure(new Error("boom"), { release: fakeToken }); + expect(posthog.captured[0]?.properties.release).toBe("[Filtered]"); + }); +}); + +describe("flushDiscoveryIndexPostHog", () => { + it("is inert when PostHog is disabled", async () => { + await expect(flushDiscoveryIndexPostHog()).resolves.toBeUndefined(); + }); + + it("flushes when enabled", async () => { + const posthog = postHogHarness(); + await flushDiscoveryIndexPostHog(); + expect(posthog.flushed).toHaveLength(1); + }); + + it("swallows a flush rejection rather than throwing", async () => { + setDiscoveryIndexPostHogForTest({ + captureException: () => undefined, + flush: async () => { + throw new Error("flush failed"); + }, + shutdown: async () => undefined, + } as never); + await expect(flushDiscoveryIndexPostHog()).resolves.toBeUndefined(); + }); +}); + +describe("shutdownDiscoveryIndexPostHog", () => { + it("is inert when PostHog is disabled", async () => { + await expect(shutdownDiscoveryIndexPostHog()).resolves.toBeUndefined(); + }); + + it("shuts down when enabled", async () => { + const posthog = postHogHarness(); + await shutdownDiscoveryIndexPostHog(); + expect(posthog.shutdowns).toHaveLength(1); + }); + + it("swallows a shutdown rejection rather than throwing", async () => { + setDiscoveryIndexPostHogForTest({ + captureException: () => undefined, + flush: async () => undefined, + shutdown: async () => { + throw new Error("shutdown failed"); + }, + } as never); + await expect(shutdownDiscoveryIndexPostHog()).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/discovery-index/sentry.test.ts b/test/unit/discovery-index/sentry.test.ts deleted file mode 100644 index b3957fbbcd..0000000000 --- a/test/unit/discovery-index/sentry.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -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 index 7fba6f4942..912fdea04e 100644 --- a/test/unit/discovery-index/upload-sourcemaps.test.ts +++ b/test/unit/discovery-index/upload-sourcemaps.test.ts @@ -1,10 +1,11 @@ -// 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. +// Coverage for the discovery-index build's PostHog source-map upload entrypoint (#8289). REPLACES the old +// Sentry-based entrypoint entirely (epic #8286's 2026-07-25 strategy correction) -- there is no more Sentry +// leg to test here. 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"; @@ -20,7 +21,7 @@ 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 POSTHOG_CLI_PKG_JSON = testRequire.resolve("@posthog/cli/package.json"); const { existsSyncMock, readFileSyncMock, readdirSyncMock, statSyncMock, spawnSyncMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -66,12 +67,11 @@ function applyFsFixture({ files, dirs }: FsFixture): void { } 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", + POSTHOG_CLI_PATH: "FAKE_POSTHOG_CLI", + POSTHOG_CLI_API_KEY: "phx_test_personal_key", + POSTHOG_CLI_PROJECT_ID: "12345", + POSTHOG_RELEASE: "loopover-discovery-index@abc123", + DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "0", }; let originalEnv: NodeJS.ProcessEnv; @@ -83,12 +83,16 @@ function setEnv(overrides: Record): void { } } -function spawnSuccess(): { status: number; stdout: string; stderr: string } { - return { status: 0, stdout: "", stderr: "" }; +function isPostHogCliCall(command: string): boolean { + return command === "FAKE_POSTHOG_CLI"; } -function isValidateReleaseCall(args: string[]): boolean { - return args[0] === "scripts/validate-sentry-release.mjs"; +function isPostHogValidateReleaseCall(args: string[]): boolean { + return args[0] === "scripts/validate-posthog-release.mjs"; +} + +function spawnSuccess(): { status: number; stdout: string; stderr: string } { + return { status: 0, stdout: "", stderr: "" }; } async function run(): Promise { @@ -118,331 +122,322 @@ afterEach(() => { 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 }); +describe("discovery-index upload-sourcemaps -- PostHog (#8289)", () => { + it("skips the upload and exits 0 when required PostHog config is missing", async () => { + setEnv({ POSTHOG_CLI_API_KEY: undefined }); await run(); expect(process.exitCode).toBe(0); expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_skipped")); }); - it("runs the full success flow with no sha and validation turned off", async () => { + it("runs the full success flow with the exact inject/upload args", 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"] }, + const posthogCalls = spawnSyncMock.mock.calls.filter(([command]) => isPostHogCliCall(command)).map(([, args]) => args); + expect(posthogCalls).toEqual([ + ["sourcemap", "inject", "--directory", "dist", "--release-version", "loopover-discovery-index@abc123"], + ["sourcemap", "upload", "--directory", "dist", "--release-version", "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"); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_complete")); }); - it("treats a 'release already exists' failure on the create step as success (allowExistingRelease)", async () => { + it("treats a non-strict upload failure as a soft failure (exit 0) with the reason logged", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args[0] === "releases" && args.includes("new")) return { status: 1, stdout: "", stderr: "410: version already exists" }; + if (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; return spawnSuccess(); }); setEnv({}); await run(); expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_failed")); }); - it("swallows a failed non-strict set-commits with a warning and continues the upload", async () => { + it("propagates a strict upload failure as exit 1", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; + if (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; return spawnSuccess(); }); - setEnv({ SENTRY_COMMIT_SHA: "abc123" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT: "true" }); 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); + expect(process.exitCode).toBe(1); }); - it("propagates a failed strict set-commits as a hard failure and exits 1", async () => { + it("handles a non-Error value thrown out of spawnSync as a soft failure", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; + if (isPostHogCliCall(command) && args[0] === "sourcemap" && args[1] === "inject") { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately + throw "spawnSync exploded (string throw)"; + } 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")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); }); - 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;"] }); + it("resolves the real posthog-cli binary from its package.json bin field", async () => { 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"] }, + ...validDistFixture(), + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "posthog-cli": "run-posthog-cli.js" } }) }, }); - setEnv({}); + setEnv({ POSTHOG_CLI_PATH: undefined }); await run(); expect(process.exitCode).toBe(0); + const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); + expect(posthogCall?.[0]).toBe(resolve(dirname(POSTHOG_CLI_PKG_JSON), "run-posthog-cli.js")); }); - it("resolves the real sentry-cli binary from a string-form package.json bin field", async () => { + it("falls back to an '@posthog/cli'-keyed bin field when 'posthog-cli' isn't present", async () => { applyFsFixture({ ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: "bin/sentry-cli" }) }, + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "@posthog/cli": "bin/alt-cli" } }) }, }); - setEnv({ SENTRY_CLI_PATH: undefined }); + setEnv({ POSTHOG_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")); + const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); + expect(posthogCall?.[0]).toBe(resolve(dirname(POSTHOG_CLI_PKG_JSON), "bin/alt-cli")); }); - it("falls back to an '@sentry/cli'-keyed bin field when 'sentry-cli' isn't present", async () => { + it("resolves a string-form package.json bin field", async () => { applyFsFixture({ ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: { "@sentry/cli": "bin/alt-cli" } }) }, + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: "run-posthog-cli.js" }) }, }); - setEnv({ SENTRY_CLI_PATH: undefined }); + setEnv({ POSTHOG_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")); + const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); + expect(posthogCall?.[0]).toBe(resolve(dirname(POSTHOG_CLI_PKG_JSON), "run-posthog-cli.js")); }); - it("fails when @sentry/cli's package.json has no resolvable bin entry", async () => { + it("fails when @posthog/cli's package.json has no resolvable bin entry", async () => { applyFsFixture({ ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({}) }, + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({}) }, }); - setEnv({ SENTRY_CLI_PATH: undefined }); + setEnv({ POSTHOG_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" }); + it("tolerates a posthog-cli spawnSync result missing stdout/stderr", async () => { + spawnSyncMock.mockImplementation((command: string) => { + if (isPostHogCliCall(command)) return { status: 0 }; + return spawnSuccess(); + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + }); + + it("logs verbose posthog-cli output on success", async () => { + spawnSyncMock.mockImplementation((command: string) => { + if (isPostHogCliCall(command)) return { status: 0, stdout: "uploaded 3 sourcemaps" }; + return spawnSuccess(); + }); + setEnv({}); + await run(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_cli")); + }); + + it("skips release validation entirely when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is off", async () => { + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "0" }); await run(); - expect(spawnSyncMock.mock.calls.some(([, args]) => isValidateReleaseCall(args))).toBe(false); + expect(spawnSyncMock.mock.calls.some(([, args]) => isPostHogValidateReleaseCall(args))).toBe(false); }); - it("runs release validation once and succeeds on the first attempt", async () => { - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1" }); + it("defaults release validation to ON when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is unset", async () => { + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: undefined }); await run(); expect(process.exitCode).toBe(0); - const validateCalls = spawnSyncMock.mock.calls.filter(([, args]) => isValidateReleaseCall(args)); - expect(validateCalls).toHaveLength(1); + expect(spawnSyncMock.mock.calls.some(([, args]) => isPostHogValidateReleaseCall(args))).toBe(true); }); 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)) { + if (isPostHogValidateReleaseCall(args)) { validateAttempts += 1; - return validateAttempts < 3 ? { status: 1, stdout: "", stderr: "release not fully propagated yet" } : spawnSuccess(); + return validateAttempts < 3 ? { status: 1, stdout: "", stderr: "release not fully propagated yet" } : { status: 0, stdout: "release visible" }; } return spawnSuccess(); }); - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "5", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "5", DISCOVERY_INDEX_POSTHOG_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")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_release_validation_retry")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_release_validation")); }); - it("falls back to the default attempt count when DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS is invalid", async () => { + it("falls back to the default attempt count when DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS is invalid", async () => { let validateAttempts = 0; spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) { + if (isPostHogValidateReleaseCall(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" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "not-a-number", DISCOVERY_INDEX_POSTHOG_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. + // Non-strict: exhausting all attempts is caught 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 () => { + it("clamps an oversized DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS to its max of 20", async () => { let validateAttempts = 0; spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) { + if (isPostHogValidateReleaseCall(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" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "999", DISCOVERY_INDEX_POSTHOG_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 () => { + it("tolerates a validate-release result missing stdout/stderr, and waits between retries", async () => { let attempt = 0; spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (!isValidateReleaseCall(args)) return spawnSuccess(); + if (!isPostHogValidateReleaseCall(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" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "3", DISCOVERY_INDEX_POSTHOG_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 () => { + it("exhausts validation attempts and fails softly (exit 0) when not strict", 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)"; - } + if (isPostHogValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; return spawnSuccess(); }); - setEnv({}); + setEnv({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "2", DISCOVERY_INDEX_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0" }); await run(); expect(process.exitCode).toBe(0); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_failed")); }); 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" }; + if (isPostHogValidateReleaseCall(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", + DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "1", + DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "2", + DISCOVERY_INDEX_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0", + DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT: "true", }); await run(); expect(process.exitCode).toBe(1); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_failed")); + }); + + it("re-validates source maps between inject and upload, failing the leg if injection corrupted them", async () => { + let injected = false; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogCliCall(command) && args[1] === "inject") { + injected = true; + return spawnSuccess(); + } + return spawnSuccess(); + }); + readFileSyncMock.mockImplementation((path: string) => { + if (injected && path === SERVER_MAP) return JSON.stringify({ sources: [], sourcesContent: [] }); + const fixture = validDistFixture().files; + if (!(path in fixture)) throw new Error(`ENOENT (fixture): ${path}`); + return fixture[path]; + }); + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has no original sources")); + }); + + 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); }); }); diff --git a/test/unit/discovery-index/validate-posthog-release.test.ts b/test/unit/discovery-index/validate-posthog-release.test.ts new file mode 100644 index 0000000000..57510bc546 --- /dev/null +++ b/test/unit/discovery-index/validate-posthog-release.test.ts @@ -0,0 +1,134 @@ +// Coverage for validate-posthog-release.mjs (#8289). Outside codecov.yml's measured discovery-index/src/** +// scope (scripts/** isn't gated), same as its untested validate-sentry-release.mjs sibling -- written anyway +// since it carries real logic (config loading, API querying, failure aggregation) worth verifying directly. +import { describe, expect, it, vi } from "vitest"; +import { + loadPostHogReleaseValidationConfig, + PostHogReleaseValidationError, + validatePostHogRelease, +} from "../../../packages/discovery-index/scripts/validate-posthog-release.mjs"; + +function jsonResponse(body: unknown, ok = true, status = 200, statusText = "OK") { + return { ok, status, statusText, json: async () => body }; +} + +describe("loadPostHogReleaseValidationConfig", () => { + it("reads all fields and defaults the host to PostHog's app host", () => { + const config = loadPostHogReleaseValidationConfig({ + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-discovery-index@abc", + }); + expect(config).toEqual({ apiKey: "phx_test", projectId: "42", release: "loopover-discovery-index@abc", baseUrl: "https://us.posthog.com" }); + }); + + it("uses POSTHOG_CLI_HOST when set and strips a trailing slash", () => { + const config = loadPostHogReleaseValidationConfig({ POSTHOG_CLI_HOST: "https://eu.posthog.com/" }); + expect(config.baseUrl).toBe("https://eu.posthog.com"); + }); + + it("treats blank/whitespace values as unset", () => { + const config = loadPostHogReleaseValidationConfig({ POSTHOG_CLI_API_KEY: " ", POSTHOG_CLI_PROJECT_ID: "", POSTHOG_RELEASE: undefined }); + expect(config.apiKey).toBeUndefined(); + expect(config.projectId).toBeUndefined(); + expect(config.release).toBeUndefined(); + }); +}); + +describe("validatePostHogRelease", () => { + const validEnv = { POSTHOG_CLI_API_KEY: "phx_test", POSTHOG_CLI_PROJECT_ID: "42", POSTHOG_RELEASE: "loopover-discovery-index@abc" }; + + it("throws when fetch is unavailable", async () => { + // null, not undefined -- a default parameter only activates on an omitted/undefined argument, and + // undefined here would silently fall through to the real globalThis.fetch (a live network call). + await expect(validatePostHogRelease(validEnv, null as never)).rejects.toThrow("fetch is unavailable"); + }); + + it("throws with all missing fields listed when config is incomplete", async () => { + const fetchImpl = vi.fn(); + await expect(validatePostHogRelease({}, fetchImpl)).rejects.toMatchObject({ + failures: ["missing POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID, POSTHOG_RELEASE"], + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("queries the correct URL with the bearer auth header", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ results: [] })); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toBeInstanceOf(PostHogReleaseValidationError); + expect(fetchImpl).toHaveBeenCalledWith( + "https://us.posthog.com/api/projects/42/error_tracking/symbol_sets?limit=100", + expect.objectContaining({ headers: { accept: "application/json", authorization: "Bearer phx_test" } }), + ); + }); + + it("throws with the response status/message when the API request fails", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ detail: "invalid token" }, false, 401, "Unauthorized")); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: [expect.stringContaining("returned HTTP 401 (invalid token)")], + }); + }); + + it("falls back to statusText when a failed response body isn't valid JSON", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: async () => { + throw new Error("not json"); + }, + }); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: [expect.stringContaining("returned HTTP 500 (Internal Server Error)")], + }); + }); + + it("accepts a bare array response body (not wrapped in {results})", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse([{ release: "loopover-discovery-index@abc", failure_reason: null }])); + await expect(validatePostHogRelease(validEnv, fetchImpl)).resolves.toEqual({ release: "loopover-discovery-index@abc", symbolSetCount: 1 }); + }); + + it("treats a response body that's neither an array nor {results} as an empty list", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ unexpected: "shape" })); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: [`no symbol sets found for release loopover-discovery-index@abc`], + }); + }); + + it("fails when no symbol sets match the target release", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ results: [{ release: "some-other-release" }] })); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: [`no symbol sets found for release loopover-discovery-index@abc`], + }); + }); + + it("fails when a matching symbol set recorded a failure_reason", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ results: [{ release: "loopover-discovery-index@abc", failure_reason: "could not parse sourcemap" }] }), + ); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: ["1 symbol set(s) for release loopover-discovery-index@abc recorded a failure_reason"], + }); + }); + + it("succeeds when at least one matching symbol set has no failure_reason", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ + results: [ + { release: "some-other-release", failure_reason: "unrelated" }, + { release: "loopover-discovery-index@abc", failure_reason: null }, + { release: "loopover-discovery-index@abc" }, + ], + }), + ); + await expect(validatePostHogRelease(validEnv, fetchImpl)).resolves.toEqual({ release: "loopover-discovery-index@abc", symbolSetCount: 2 }); + }); + + it("counts every failed symbol set, not just the first one found", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + jsonResponse({ results: [{ release: "loopover-discovery-index@abc", failure_reason: "a" }, { release: "loopover-discovery-index@abc", failure_reason: "b" }] }), + ); + await expect(validatePostHogRelease(validEnv, fetchImpl)).rejects.toMatchObject({ + failures: ["2 symbol set(s) for release loopover-discovery-index@abc recorded a failure_reason"], + }); + }); +});