From ad2a367e42a50f474ff7d73f3d327fa96dd20c60 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:08:56 -0700 Subject: [PATCH 1/2] feat(discovery-index): add PostHog error tracking + sourcemap pipeline as a parallel Sentry sink Adds src/posthog.ts, mirroring src/sentry.ts's shape exactly (same redaction rules, tag allowlist, capture entry points) using PostHog's documented captureException(error, distinctId, properties) API, wired into app.ts's onError handler, server.ts's unhandled-rejection/ uncaught-exception hooks and shutdown path, and worker.ts's Container env forwarding. Extends upload-sourcemaps.ts with a second, fully independent leg: posthog-cli sourcemap inject/upload with an explicit --release-version (the Docker build never copies .git, so the CLI's own git-metadata auto-detection has nothing to inspect), running after the existing, unmodified Sentry leg so an unverified interaction between the two CLIs' inject steps can't put the proven flow at risk. Adds scripts/validate-posthog-release.mjs, a narrower release-verification counterpart to validate-sentry-release.mjs -- PostHog's release model has no commits/deploys/finalize lifecycle to check, only symbol-set presence via the error_tracking/symbol_sets API. All PostHog config (POSTHOG_API_KEY/HOST/ENVIRONMENT/RELEASE for capture, POSTHOG_CLI_API_KEY/PROJECT_ID/HOST for sourcemap upload) is wired end-to-end but not yet a real wrangler.jsonc var/secret -- #7875 (a real loopover-owned PostHog project) isn't provisioned yet, so every path stays a complete no-op until then, same as an unset SENTRY_DSN. Runs fully parallel with the existing Sentry sink per the epic's gated decommission plan (#8298); nothing about the Sentry leg changes here. Closes #8289 --- package-lock.json | 47 ++- packages/discovery-index/Dockerfile | 10 +- packages/discovery-index/README.md | 6 +- packages/discovery-index/package.json | 4 +- .../scripts/validate-posthog-release.d.mts | 25 ++ .../scripts/validate-posthog-release.mjs | 123 +++++++ packages/discovery-index/src/app.ts | 2 + packages/discovery-index/src/env.d.ts | 29 ++ packages/discovery-index/src/posthog.ts | 182 ++++++++++ packages/discovery-index/src/server.ts | 11 +- .../discovery-index/src/upload-sourcemaps.ts | 127 ++++++- packages/discovery-index/src/worker.ts | 20 ++ packages/discovery-index/wrangler.jsonc | 15 +- test/unit/discovery-index/posthog.test.ts | 318 ++++++++++++++++++ .../discovery-index/upload-sourcemaps.test.ts | 268 +++++++++++++++ .../validate-posthog-release.test.ts | 134 ++++++++ 16 files changed, 1306 insertions(+), 15 deletions(-) create mode 100644 packages/discovery-index/scripts/validate-posthog-release.d.mts create mode 100644 packages/discovery-index/scripts/validate-posthog-release.mjs create mode 100644 packages/discovery-index/src/posthog.ts create mode 100644 test/unit/discovery-index/posthog.test.ts create mode 100644 test/unit/discovery-index/validate-posthog-release.test.ts diff --git a/package-lock.json b/package-lock.json index 731f314ce0..7cb3df28f3 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", @@ -21042,9 +21085,11 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", + "@posthog/cli": "^0.9.1", "@sentry/cli": "^3.6.0", "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "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..288ebb4546 100644 --- a/packages/discovery-index/Dockerfile +++ b/packages/discovery-index/Dockerfile @@ -38,8 +38,14 @@ EXPOSE 8080 # 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 (#8289, parallel-run alongside the Sentry vars above until the epic's gated decommission, #8298): +# POSTHOG_API_KEY/POSTHOG_HOST/POSTHOG_ENVIRONMENT/POSTHOG_RELEASE for error tracking, and +# POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID/POSTHOG_CLI_HOST for source-map upload -- same optional, +# no-op-when-unset posture as their Sentry counterparts. Pending #7875 (a real, loopover-owned PostHog +# project), so this leg 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 -# starts, so they're never served or left on disk in the running container. Mirrors +# SENTRY_AUTH_TOKEN/POSTHOG_CLI_API_KEY are runtime-only secrets) -- 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/README.md b/packages/discovery-index/README.md index afc985f23e..cefd994bd3 100644 --- a/packages/discovery-index/README.md +++ b/packages/discovery-index/README.md @@ -32,13 +32,17 @@ Soft-claim design note: the shipped client payload never carries caller identity | `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), parallel-run alongside the Sentry vars above. Same shape/optionality as their Sentry counterparts (`POSTHOG_API_KEY` is a write-only project token, safe as a plain `wrangler.jsonc` var like `SENTRY_DSN`; `POSTHOG_RELEASE` is set per-deploy, not static). **Pending #7875** (a real, loopover-owned PostHog project) — currently unset everywhere, so this leg 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`'s PostHog leg for source-map upload. Mirrors `SENTRY_AUTH_TOKEN`/`SENTRY_ORG`/`SENTRY_PROJECT`'s role. `POSTHOG_CLI_API_KEY` is genuinely sensitive (always a `wrangler secret`); `POSTHOG_CLI_PROJECT_ID` is a plain identifier. **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`. 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. +**PostHog (#8289, epic #8286)** runs as a second, fully parallel leg alongside the Sentry flow above — both active simultaneously once configured; Sentry is only removed once the epic's gated decommission (#8298) says so, across all six Phase-1 surfaces at once, not early just because this one works. See `src/posthog.ts` for the capture/redaction logic (deliberately mirrors `src/sentry.ts`'s shape line-for-line) and `scripts/validate-posthog-release.mjs` for the PostHog release-verification step (queries PostHog's `error_tracking/symbol_sets` API — a narrower check than the Sentry validator's, since PostHog's release model has no equivalent of Sentry's commits/deploys/finalize lifecycle: it just confirms at least one symbol set exists for the release with no recorded `failure_reason`). `upload-sourcemaps.ts` runs `posthog-cli sourcemap inject`/`upload` with an explicit `--release-version` (not the CLI's own git-metadata auto-detection — the Docker build stage never copies `.git` into the image). Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT=true` is set. + ## Deployment The production deployment (#7167) is a **Cloudflare Container**, not a bare VPS/Docker host: `wrangler.jsonc` runs the exact same `Dockerfile` as a Container behind a Durable Object, giving it a public URL (`discovery.loopover.ai`) and TLS with no manual DNS/reverse-proxy setup. This was chosen over a raw VPS/PaaS deploy because it reuses the same platform already chosen for the ORB+AMS hosted control-plane (#7173), and over native (non-Container) Workers because this service's cache (`cache.ts`) and soft-claim dedup store (`soft-claim.ts`) are both in-process memory — a Container is one real, persistent process (so that state behaves correctly, exactly as already tested), where Workers' distributed isolates would not reliably share it. See `src/worker.ts`'s header comment for why the config pins `max_instances: 1` (a correctness requirement for soft-claim dedup, not just a cost choice). diff --git a/packages/discovery-index/package.json b/packages/discovery-index/package.json index 8ae45ee785..24838cd2b8 100644 --- a/packages/discovery-index/package.json +++ b/packages/discovery-index/package.json @@ -21,9 +21,11 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", + "@posthog/cli": "^0.9.1", "@sentry/cli": "^3.6.0", "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "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/src/app.ts b/packages/discovery-index/src/app.ts index ccef3501fa..fd9ec63834 100644 --- a/packages/discovery-index/src/app.ts +++ b/packages/discovery-index/src/app.ts @@ -15,6 +15,7 @@ import { normalizeSharedSecret, verifyBearer } from "./auth.js"; import type { TtlCache } from "./cache.js"; import { runDiscoveryQuery, type GitHubClientLike } from "./discovery-query.js"; import { incr, observe, renderMetrics } from "./metrics.js"; +import { captureRoutePostHogError } from "./posthog.js"; import { captureRouteError } from "./sentry.js"; import { parseSoftClaimRequest, softClaimKey, type SoftClaimStoreLike } from "./soft-claim.js"; @@ -50,6 +51,7 @@ export function createApp(deps: AppDeps): Hono { // 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..2da42f583e 100644 --- a/packages/discovery-index/src/env.d.ts +++ b/packages/discovery-index/src/env.d.ts @@ -20,6 +20,28 @@ declare global { * 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, parallel-run with Sentry above) -- error capture. PENDING #7875 (a real + * loopover-owned PostHog project): once provisioned, this graduates to a plain wrangler.jsonc `vars` entry + * (write-only like SENTRY_DSN, safe to commit) and this declaration is removed. Declared here only so it + * type-checks in the meantime; optional and no-op when unset, matching every other PostHog var precedent + * in this repo. */ + 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, matching SENTRY_ENVIRONMENT's role. PENDING #7875. */ + POSTHOG_ENVIRONMENT?: string; + /** The deploy's PostHog release identifier, matching SENTRY_RELEASE's deliberately-not-a-static-var, + * set-per-deploy pattern once configured. 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. Mirrors SENTRY_AUTH_TOKEN's identical role/optionality + * (upload-sourcemaps.ts's PostHog leg no-ops with a log line when unset). PENDING #7875. */ + POSTHOG_CLI_API_KEY?: string; + /** The PostHog project's numeric id (not a secret -- a plain identifier, like SENTRY_PROJECT), 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, @@ -31,6 +53,13 @@ declare global { 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/server.ts b/packages/discovery-index/src/server.ts index 538536eed5..57f37a1274 100644 --- a/packages/discovery-index/src/server.ts +++ b/packages/discovery-index/src/server.ts @@ -10,6 +10,7 @@ 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 { captureUnhandledPostHogError, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolvePostHogEnvironment, shutdownDiscoveryIndexPostHog } from "./posthog.js"; import { captureUnhandledError, flushSentry, initSentry, resolveSentryEnvironment } from "./sentry.js"; import { DEFAULT_SOFT_CLAIM_TTL_MS, SoftClaimStore } from "./soft-claim.js"; @@ -17,6 +18,10 @@ 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 ?? ""; const configuredCacheTtlMs = Number(process.env.DISCOVERY_INDEX_CACHE_TTL_MS); @@ -41,15 +46,17 @@ 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 Promise.all([flushSentry(), flushDiscoveryIndexPostHog()]).finally(() => process.exit(1)); }); process.on("SIGTERM", () => { - void flushSentry().finally(() => process.exit(0)); + void Promise.all([flushSentry(), 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..07950d16f3 100644 --- a/packages/discovery-index/src/upload-sourcemaps.ts +++ b/packages/discovery-index/src/upload-sourcemaps.ts @@ -1,13 +1,22 @@ -// 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 +// Uploads this build's source maps to Sentry AND PostHog at container startup, then deletes them before the +// real server starts (see the Dockerfile's runtime CMD) -- mirrors review-enrichment/src/upload-sourcemaps.ts // (a comparably-sized standalone service with the identical Sentry setup), adapted only by dropping that // copy's Railway-specific env vars (RAILWAY_GIT_COMMIT_SHA, RAILWAY_DEPLOYMENT_ID, RAILWAY_ENVIRONMENT_NAME) // since discovery-index deploys via a Cloudflare Container (#7167), not Railway. // -// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: SENTRY_AUTH_TOKEN is a -// real secret, injected the same way DISCOVERY_INDEX_SHARED_SECRET/DISCOVERY_INDEX_GITHUB_TOKEN already are -// (worker.ts's Container envVars) -- it is never a Docker build-time value, so it never risks being baked -// into a cached image layer. +// PostHog leg (#8289, epic #8286): parallel-run alongside the existing Sentry leg below, not a replacement -- +// Sentry stays wired until the gated decommission (#8298). Both legs run sequentially, Sentry first (the +// existing, already-proven flow, left completely unmodified) then PostHog second -- if the two CLIs' inject +// steps ever interact unexpectedly on the same on-disk .js/.map files (Sentry's `debug_id` comment vs +// PostHog's `chunkId` comment), the untouched, load-bearing Sentry leg is never put at risk by the newer one +// running first. Currently a no-op in practice: POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID are pending #7875 +// (see wrangler.jsonc's own header comment), so runPostHogSourcemapUpload's missing-config skip fires until +// that's provisioned. +// +// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: SENTRY_AUTH_TOKEN/ +// POSTHOG_CLI_API_KEY are real secrets, injected the same way DISCOVERY_INDEX_SHARED_SECRET/ +// DISCOVERY_INDEX_GITHUB_TOKEN already are (worker.ts's Container envVars) -- neither is ever a Docker +// build-time value, so neither risks being baked into a cached image layer. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { createRequire } from "node:module"; @@ -15,6 +24,7 @@ import { dirname, join, relative, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; +import { captureSourcemapUploadPostHogFailure, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolveDiscoveryIndexPostHogRelease } from "./posthog.js"; import { captureSourcemapUploadFailure, flushSentry, initSentry, resolveDiscoveryIndexSentryRelease, resolveSentryEnvironment } from "./sentry.js"; const require = createRequire(import.meta.url); @@ -156,7 +166,7 @@ async function runReleaseValidation(release: string, fields: { sha?: string | un throw new Error(`Sentry release validation failed (${status}): ${output.slice(0, 500)}`); } -async function main(): Promise { +async function runSentrySourcemapUpload(): 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. @@ -213,4 +223,107 @@ async function main(): Promise { } } +// Resolved via require.resolve, mirroring sentryCliPath()'s identical reasoning (this is a real npm +// workspace member, so npm hoists @posthog/cli's binary to the ROOT node_modules/.bin/ by default). +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 shouldValidatePostHogRelease(): boolean { + return !/^(0|false|no|off)$/i.test(process.env.DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE ?? ""); +} + +async function runPostHogReleaseValidation(release: string): Promise { + if (!shouldValidatePostHogRelease()) return; + 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-posthog-release.mjs"], { + cwd: appDir, + 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_posthog_release_validation", { output: output.slice(0, 500), attempt }); + return; + } + if (attempt < attempts) { + warn("discovery_index_posthog_release_validation_retry", { attempt, attempts, retryDelayMs, message: output.slice(0, 500) }); + if (retryDelayMs > 0) await sleep(retryDelayMs); + } + } + throw new Error(`PostHog release validation failed (${status}): ${output.slice(0, 500)}`); +} + +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) -- no equivalent of Sentry CLI's --org/--project + // flags needed here. + 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 runPostHogSourcemapUpload(): Promise { + /* v8 ignore next -- @preserve unreachable: initDiscoveryIndexPostHog(process.env) never rejects, mirrors initSentry's identical guarantee */ + await initDiscoveryIndexPostHog(process.env).catch(() => false); + const release = resolveDiscoveryIndexPostHogRelease(process.env); + const required = { + 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_posthog_sourcemap_upload_skipped", { reason: "missing_config", missing }); + return 0; + } + + const strict = /^(1|true|yes|on)$/i.test(process.env.DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT ?? ""); + try { + validateSourceMaps(); + // No separate "create release" step -- PostHog release metadata is a byproduct of the inject/upload + // calls below (unlike Sentry's explicit releases new/set-commits/deploys/finalize lifecycle). 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(); + runPostHog(["sourcemap", "upload", "--directory", "dist", "--release-version", release!]); + await runPostHogReleaseValidation(release!); + log("discovery_index_posthog_sourcemap_upload_complete", { release }); + return 0; + } catch (error) { + captureSourcemapUploadPostHogFailure(error, { release }); + await flushDiscoveryIndexPostHog(); + warn("discovery_index_posthog_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); + return strict ? 1 : 0; + } +} + +async function main(): Promise { + // Sentry first (existing, proven flow, unmodified), PostHog second -- see this file's header comment for + // why that order matters. Both legs are independent and non-blocking of each other; a failure in one + // doesn't skip the other. + const sentryExitCode = await runSentrySourcemapUpload(); + const postHogExitCode = await runPostHogSourcemapUpload(); + return Math.max(sentryExitCode, postHogExitCode); +} + process.exitCode = await main(); diff --git a/packages/discovery-index/src/worker.ts b/packages/discovery-index/src/worker.ts index c7a93097d6..b7f322b451 100644 --- a/packages/discovery-index/src/worker.ts +++ b/packages/discovery-index/src/worker.ts @@ -39,6 +39,12 @@ export class DiscoveryIndexContainer extends Container { // 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, parallel-run alongside the Sentry vars above) follow the identical forwarding + // discipline: initDiscoveryIndexPostHog/upload-sourcemaps.ts no-op cleanly on an empty/unset POSTHOG_API_KEY/ + // POSTHOG_CLI_API_KEY, so they're forwarded unconditionally too. 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. override envVars = { DISCOVERY_INDEX_SHARED_SECRET: env.DISCOVERY_INDEX_SHARED_SECRET, DISCOVERY_INDEX_GITHUB_TOKEN: env.DISCOVERY_INDEX_GITHUB_TOKEN, @@ -48,6 +54,13 @@ export class DiscoveryIndexContainer extends Container { 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 ?? "", }; } @@ -62,6 +75,13 @@ interface WorkerEnv { 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/wrangler.jsonc b/packages/discovery-index/wrangler.jsonc index 043b2cdc07..33f2460514 100644 --- a/packages/discovery-index/wrangler.jsonc +++ b/packages/discovery-index/wrangler.jsonc @@ -9,10 +9,21 @@ // 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, mirrors SENTRY_AUTH_TOKEN) // // SENTRY_RELEASE is deliberately NOT a static var below -- it should be the actual deploy's git commit // (loopover-discovery-index@), which changes every release. Set per-deploy: // npx wrangler deploy --var SENTRY_RELEASE:loopover-discovery-index@$(git rev-parse HEAD) + // POSTHOG_RELEASE follows the identical per-deploy pattern once configured. + // + // 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 like SENTRY_DSN below -- 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 no-ops, exactly like an unset SENTRY_DSN. "name": "loopover-discovery-index", "main": "src/worker.ts", "compatibility_date": "2026-07-21", @@ -21,7 +32,9 @@ // 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. + // the one genuinely sensitive value and stays a wrangler secret, never here. #8289 migrates this surface + // to a loopover-owned PostHog project (parallel-run with this Sentry sink until the gated decommission, + // #8298) -- see the POSTHOG_* comment above for why those vars aren't here yet. "SENTRY_ORG": "jsonbored", "SENTRY_PROJECT": "metagraphed", "SENTRY_DSN": "https://998bd096e2c9f1d81781ed3a88fed0b9@o4511631313666048.ingest.us.sentry.io/4511749777588224", 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/upload-sourcemaps.test.ts b/test/unit/discovery-index/upload-sourcemaps.test.ts index 7fba6f4942..ec970444c9 100644 --- a/test/unit/discovery-index/upload-sourcemaps.test.ts +++ b/test/unit/discovery-index/upload-sourcemaps.test.ts @@ -21,6 +21,7 @@ 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(), @@ -74,6 +75,16 @@ const REQUIRED_ENV: Record = { DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "0", }; +// PostHog leg's own required config (#8289) -- NOT included in REQUIRED_ENV above, so every existing Sentry- +// only test in this file exercises the PostHog leg's missing-config skip path (contributing exit code 0 to +// main()'s Math.max combine) without needing any changes. +const POSTHOG_REQUIRED_ENV: Record = { + 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", +}; + let originalEnv: NodeJS.ProcessEnv; function setEnv(overrides: Record): void { @@ -83,6 +94,23 @@ function setEnv(overrides: Record): void { } } +/** Sets both legs' required env (Sentry's REQUIRED_ENV + PostHog's POSTHOG_REQUIRED_ENV), so a test can + * exercise the PostHog leg's real behavior alongside the (unchanged, default-successful) Sentry leg. */ +function setEnvWithPostHog(overrides: Record): void { + for (const [key, value] of Object.entries({ ...REQUIRED_ENV, ...POSTHOG_REQUIRED_ENV, ...overrides })) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function isPostHogCliCall(command: string): boolean { + return command === "FAKE_POSTHOG_CLI"; +} + +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: "" }; } @@ -446,3 +474,243 @@ describe("discovery-index upload-sourcemaps (#4934)", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); }); }); + +describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { + it("skips the PostHog upload and exits 0 when required PostHog config is missing (Sentry leg still runs)", async () => { + setEnv({}); + await run(); + expect(process.exitCode).toBe(0); + expect(spawnSyncMock.mock.calls.some(([command]) => isPostHogCliCall(command))).toBe(false); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_skipped")); + }); + + it("runs the full PostHog success flow with the exact inject/upload args, after the Sentry leg", async () => { + setEnvWithPostHog({}); + await run(); + expect(process.exitCode).toBe(0); + 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"], + ]); + // Sentry's own calls are unaffected -- both legs' spawnSync calls coexist in the same mock's call list. + expect(spawnSyncMock.mock.calls.some(([command]) => command === "FAKE_SENTRY_CLI")).toBe(true); + }); + + it("treats a non-strict PostHog upload failure as a soft failure (exit 0) with the reason logged", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; + return spawnSuccess(); + }); + setEnvWithPostHog({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_failed")); + }); + + it("propagates a strict PostHog upload failure as exit 1, even when the Sentry leg succeeds", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; + return spawnSuccess(); + }); + setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT: "true" }); + await run(); + expect(process.exitCode).toBe(1); + }); + + it("still runs (and can fail) the PostHog leg even when the Sentry leg fails strictly -- both legs are independent", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; + return spawnSuccess(); + }); + setEnvWithPostHog({ SENTRY_COMMIT_SHA: "abc123", DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true" }); + await run(); + expect(process.exitCode).toBe(1); + expect(spawnSyncMock.mock.calls.some(([command]) => isPostHogCliCall(command))).toBe(true); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_complete")); + }); + + it("handles a non-Error value thrown out of the PostHog spawnSync as a soft failure", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + 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(); + }); + setEnvWithPostHog({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); + }); + + it("resolves the real posthog-cli binary from its package.json bin field", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "posthog-cli": "run-posthog-cli.js" } }) }, + }); + setEnvWithPostHog({ 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("falls back to an '@posthog/cli'-keyed bin field when 'posthog-cli' isn't present", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "@posthog/cli": "bin/alt-cli" } }) }, + }); + setEnvWithPostHog({ 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), "bin/alt-cli")); + }); + + it("resolves a string-form package.json bin field", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: "run-posthog-cli.js" }) }, + }); + setEnvWithPostHog({ 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("fails when @posthog/cli's package.json has no resolvable bin entry", async () => { + applyFsFixture({ + ...validDistFixture(), + files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({}) }, + }); + setEnvWithPostHog({ POSTHOG_CLI_PATH: undefined }); + await run(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no resolvable bin entry")); + }); + + it("tolerates a posthog-cli spawnSync result missing stdout/stderr", async () => { + spawnSyncMock.mockImplementation((command: string) => { + if (isPostHogCliCall(command)) return { status: 0 }; + return spawnSuccess(); + }); + setEnvWithPostHog({}); + 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(); + }); + setEnvWithPostHog({}); + await run(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_cli")); + }); + + it("skips PostHog release validation entirely when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is off", async () => { + setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "0" }); + await run(); + expect(spawnSyncMock.mock.calls.some(([, args]) => isPostHogValidateReleaseCall(args))).toBe(false); + }); + + it("defaults PostHog release validation to ON when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is unset", async () => { + setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: undefined }); + await run(); + expect(process.exitCode).toBe(0); + expect(spawnSyncMock.mock.calls.some(([, args]) => isPostHogValidateReleaseCall(args))).toBe(true); + }); + + it("retries PostHog release validation until it succeeds, logging a retry warning each time", async () => { + let validateAttempts = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogValidateReleaseCall(args)) { + validateAttempts += 1; + return validateAttempts < 3 ? { status: 1, stdout: "", stderr: "release not fully propagated yet" } : { status: 0, stdout: "release visible" }; + } + return spawnSuccess(); + }); + setEnvWithPostHog({ 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_posthog_release_validation_retry")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_release_validation")); + }); + + it("tolerates a PostHog validate-release result missing stdout/stderr, and waits between retries", async () => { + let attempt = 0; + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (!isPostHogValidateReleaseCall(args)) return spawnSuccess(); + attempt += 1; + if (attempt === 1) return { status: 1 }; + return { status: 0, stdout: "release visible" }; + }); + setEnvWithPostHog({ 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); + }); + + it("exhausts PostHog validation attempts and fails softly (exit 0) when not strict", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; + return spawnSuccess(); + }); + setEnvWithPostHog({ 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("discovery_index_posthog_sourcemap_upload_failed")); + }); + + it("exhausts PostHog validation attempts and fails strictly (exit 1) when set to strict", async () => { + spawnSyncMock.mockImplementation((command: string, args: string[]) => { + if (isPostHogValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; + return spawnSuccess(); + }); + setEnvWithPostHog({ + 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); + }); + + 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 (isPostHogValidateReleaseCall(args)) { + validateAttempts += 1; + return { status: 1, stdout: "", stderr: "still not visible" }; + } + return spawnSuccess(); + }); + setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "999", DISCOVERY_INDEX_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0" }); + await run(); + expect(validateAttempts).toBe(20); + }); + + it("re-validates source maps between inject and upload, failing the PostHog 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]; + }); + setEnvWithPostHog({}); + await run(); + expect(process.exitCode).toBe(0); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has no original sources")); + }); +}); 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"], + }); + }); +}); From 2bfc6afc55e330a8f4cb297b5d5cf19272fdd42a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:43:09 -0700 Subject: [PATCH 2/2] feat(discovery-index): remove Sentry, PostHog is now the sole error tracking sink Per the epic's revised strategy (#8286 correction, 2026-07-25): PostHog replaces Sentry directly, not a parallel-run sink. This branch's earlier commit added PostHog alongside the existing Sentry wiring; this commit finishes the swap by removing Sentry entirely. Deletes src/sentry.ts, scripts/validate-sentry-release.mjs, the @sentry/node/@sentry/cli dependencies, and the live metagraphed Sentry DSN previously baked into wrangler.jsonc's vars (a genuine cross-project entanglement -- discovery-index reporting into a different product's shared Sentry project, exactly the problem #8289 set out to fix). Removes the corresponding SENTRY_* fields from env.d.ts/WorkerEnv/ Cloudflare.Env, regenerates worker-configuration.d.ts, and drops the Sentry leg from upload-sourcemaps.ts (PostHog's inject/upload/validate flow is now the only path, no more Math.max(sentryExitCode, postHogExitCode) combining). @posthog/cli is pinned to an exact version (0.9.1, no caret) rather than a range -- its postinstall script downloads a platform binary over HTTPS with no checksum verification. Also rebases onto current main to pick up #8612's postcss/tar dependency overrides, which an earlier `npm install --workspace` in this branch had inadvertently regressed to their pre-fix versions. Closes #8289 --- package-lock.json | 190 +----- packages/discovery-index/Dockerfile | 18 +- packages/discovery-index/OPERATIONS.md | 2 +- packages/discovery-index/README.md | 15 +- packages/discovery-index/package.json | 4 +- .../scripts/validate-sentry-release.mjs | 244 -------- packages/discovery-index/src/app.ts | 2 - packages/discovery-index/src/env.d.ts | 34 +- packages/discovery-index/src/sentry.ts | 197 ------ packages/discovery-index/src/server.ts | 11 +- .../discovery-index/src/upload-sourcemaps.ts | 209 +------ packages/discovery-index/src/worker.ts | 32 +- packages/discovery-index/tsconfig.json | 4 +- .../discovery-index/worker-configuration.d.ts | 6 +- packages/discovery-index/wrangler.jsonc | 36 +- test/unit/discovery-index/sentry.test.ts | 322 ---------- .../discovery-index/upload-sourcemaps.test.ts | 569 +++++------------- 17 files changed, 230 insertions(+), 1665 deletions(-) delete mode 100644 packages/discovery-index/scripts/validate-sentry-release.mjs delete mode 100644 packages/discovery-index/src/sentry.ts delete mode 100644 test/unit/discovery-index/sentry.test.ts diff --git a/package-lock.json b/package-lock.json index 7cb3df28f3..b378b3f12f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6456,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", @@ -20002,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" @@ -21085,9 +20901,7 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", - "@posthog/cli": "^0.9.1", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", + "@posthog/cli": "0.9.1", "hono": "^4.12.27", "posthog-node": "^5.44.0" }, diff --git a/packages/discovery-index/Dockerfile b/packages/discovery-index/Dockerfile index 288ebb4546..165b6b6e22 100644 --- a/packages/discovery-index/Dockerfile +++ b/packages/discovery-index/Dockerfile @@ -34,18 +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 (#8289, parallel-run alongside the Sentry vars above until the epic's gated decommission, #8298): -# POSTHOG_API_KEY/POSTHOG_HOST/POSTHOG_ENVIRONMENT/POSTHOG_RELEASE for error tracking, and -# POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID/POSTHOG_CLI_HOST for source-map upload -- same optional, -# no-op-when-unset posture as their Sentry counterparts. Pending #7875 (a real, loopover-owned PostHog -# project), so this leg is currently inert in every real deployment. +# 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/POSTHOG_CLI_API_KEY are runtime-only secrets) -- 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 +# 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 cefd994bd3..afd6fc4d12 100644 --- a/packages/discovery-index/README.md +++ b/packages/discovery-index/README.md @@ -27,21 +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), parallel-run alongside the Sentry vars above. Same shape/optionality as their Sentry counterparts (`POSTHOG_API_KEY` is a write-only project token, safe as a plain `wrangler.jsonc` var like `SENTRY_DSN`; `POSTHOG_RELEASE` is set per-deploy, not static). **Pending #7875** (a real, loopover-owned PostHog project) — currently unset everywhere, so this leg 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`'s PostHog leg for source-map upload. Mirrors `SENTRY_AUTH_TOKEN`/`SENTRY_ORG`/`SENTRY_PROJECT`'s role. `POSTHOG_CLI_API_KEY` is genuinely sensitive (always a `wrangler secret`); `POSTHOG_CLI_PROJECT_ID` is a plain identifier. **Pending #7875**, same as above. | +| `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, #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. - -**PostHog (#8289, epic #8286)** runs as a second, fully parallel leg alongside the Sentry flow above — both active simultaneously once configured; Sentry is only removed once the epic's gated decommission (#8298) says so, across all six Phase-1 surfaces at once, not early just because this one works. See `src/posthog.ts` for the capture/redaction logic (deliberately mirrors `src/sentry.ts`'s shape line-for-line) and `scripts/validate-posthog-release.mjs` for the PostHog release-verification step (queries PostHog's `error_tracking/symbol_sets` API — a narrower check than the Sentry validator's, since PostHog's release model has no equivalent of Sentry's commits/deploys/finalize lifecycle: it just confirms at least one symbol set exists for the release with no recorded `failure_reason`). `upload-sourcemaps.ts` runs `posthog-cli sourcemap inject`/`upload` with an explicit `--release-version` (not the CLI's own git-metadata auto-detection — the Docker build stage never copies `.git` into the image). Any step failing is non-fatal to the boot unless `DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT=true` is set. +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 24838cd2b8..3a26aa5bcc 100644 --- a/packages/discovery-index/package.json +++ b/packages/discovery-index/package.json @@ -21,9 +21,7 @@ "@cloudflare/containers": "^0.3.7", "@hono/node-server": "^2.0.11", "@loopover/engine": "^3.4.0", - "@posthog/cli": "^0.9.1", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", + "@posthog/cli": "0.9.1", "hono": "^4.12.27", "posthog-node": "^5.44.0" }, 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 fd9ec63834..c832631b87 100644 --- a/packages/discovery-index/src/app.ts +++ b/packages/discovery-index/src/app.ts @@ -16,7 +16,6 @@ import type { TtlCache } from "./cache.js"; import { runDiscoveryQuery, type GitHubClientLike } from "./discovery-query.js"; import { incr, observe, renderMetrics } from "./metrics.js"; import { captureRoutePostHogError } from "./posthog.js"; -import { captureRouteError } from "./sentry.js"; import { parseSoftClaimRequest, softClaimKey, type SoftClaimStoreLike } from "./soft-claim.js"; export interface AppDeps { @@ -50,7 +49,6 @@ 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 2da42f583e..7867483e59 100644 --- a/packages/discovery-index/src/env.d.ts +++ b/packages/discovery-index/src/env.d.ts @@ -11,34 +11,24 @@ 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, parallel-run with Sentry above) -- error capture. PENDING #7875 (a real - * loopover-owned PostHog project): once provisioned, this graduates to a plain wrangler.jsonc `vars` entry - * (write-only like SENTRY_DSN, safe to commit) and this declaration is removed. Declared here only so it - * type-checks in the meantime; optional and no-op when unset, matching every other PostHog var precedent - * in this repo. */ + /** 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, matching SENTRY_ENVIRONMENT's role. PENDING #7875. */ + /** PostHog capture `environment` tag override. PENDING #7875. */ POSTHOG_ENVIRONMENT?: string; - /** The deploy's PostHog release identifier, matching SENTRY_RELEASE's deliberately-not-a-static-var, - * set-per-deploy pattern once configured. PENDING #7875. */ + /** 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. Mirrors SENTRY_AUTH_TOKEN's identical role/optionality - * (upload-sourcemaps.ts's PostHog leg no-ops with a log line when unset). PENDING #7875. */ + * 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, like SENTRY_PROJECT), required - * alongside POSTHOG_CLI_API_KEY for source-map upload. PENDING #7875. */ + /** 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; @@ -51,8 +41,6 @@ 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; 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 57f37a1274..cea8ebb671 100644 --- a/packages/discovery-index/src/server.ts +++ b/packages/discovery-index/src/server.ts @@ -11,13 +11,8 @@ import { TtlCache } from "./cache.js"; import { DEFAULT_CACHE_TTL_MS } from "./discovery-query.js"; import { GitHubClient } from "./github-client.js"; import { captureUnhandledPostHogError, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolvePostHogEnvironment, shutdownDiscoveryIndexPostHog } from "./posthog.js"; -import { captureUnhandledError, flushSentry, initSentry, resolveSentryEnvironment } from "./sentry.js"; import { DEFAULT_SOFT_CLAIM_TTL_MS, SoftClaimStore } from "./soft-claim.js"; -const sentryEnabled = await initSentry(process.env); -if (sentryEnabled) { - console.log(JSON.stringify({ event: "discovery_index_sentry", environment: resolveSentryEnvironment(process.env) })); -} const posthogEnabled = await initDiscoveryIndexPostHog(process.env); if (posthogEnabled) { console.log(JSON.stringify({ event: "discovery_index_posthog", environment: resolvePostHogEnvironment(process.env) })); @@ -45,18 +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" }); captureUnhandledPostHogError(error, { event: "discovery_index_uncaught_exception" }); - void Promise.all([flushSentry(), flushDiscoveryIndexPostHog()]).finally(() => process.exit(1)); + void flushDiscoveryIndexPostHog().finally(() => process.exit(1)); }); process.on("SIGTERM", () => { - void Promise.all([flushSentry(), shutdownDiscoveryIndexPostHog()]).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 07950d16f3..b3020a7cf1 100644 --- a/packages/discovery-index/src/upload-sourcemaps.ts +++ b/packages/discovery-index/src/upload-sourcemaps.ts @@ -1,22 +1,11 @@ -// Uploads this build's source maps to Sentry AND PostHog 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. // -// PostHog leg (#8289, epic #8286): parallel-run alongside the existing Sentry leg below, not a replacement -- -// Sentry stays wired until the gated decommission (#8298). Both legs run sequentially, Sentry first (the -// existing, already-proven flow, left completely unmodified) then PostHog second -- if the two CLIs' inject -// steps ever interact unexpectedly on the same on-disk .js/.map files (Sentry's `debug_id` comment vs -// PostHog's `chunkId` comment), the untouched, load-bearing Sentry leg is never put at risk by the newer one -// running first. Currently a no-op in practice: POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID are pending #7875 -// (see wrangler.jsonc's own header comment), so runPostHogSourcemapUpload's missing-config skip fires until -// that's provisioned. -// -// Running this at CONTAINER STARTUP rather than at Docker BUILD time is deliberate: SENTRY_AUTH_TOKEN/ -// POSTHOG_CLI_API_KEY are real secrets, injected the same way DISCOVERY_INDEX_SHARED_SECRET/ -// DISCOVERY_INDEX_GITHUB_TOKEN already are (worker.ts's Container envVars) -- neither is ever a Docker -// build-time value, so neither risks being baked into a cached image layer. +// 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. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { createRequire } from "node:module"; @@ -25,15 +14,9 @@ import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { captureSourcemapUploadPostHogFailure, flushDiscoveryIndexPostHog, initDiscoveryIndexPostHog, resolveDiscoveryIndexPostHogRelease } from "./posthog.js"; -import { captureSourcemapUploadFailure, flushSentry, initSentry, resolveDiscoveryIndexSentryRelease, resolveSentryEnvironment } from "./sentry.js"; const require = createRequire(import.meta.url); -type RunOptions = { - allowExistingRelease?: boolean; - allowFailure?: boolean; -}; - const distDir = dirname(fileURLToPath(import.meta.url)); const appDir = resolve(distDir, ".."); @@ -92,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 { @@ -131,100 +84,37 @@ 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)}`); -} - -async function runSentrySourcemapUpload(): Promise { - // initSentry's own body already wraps everything error-prone in its own try/catch and always resolves - // (never rejects) when called with a real process.env -- this .catch is unreachable through the real - // call site above, same "defensive net, no live branch" reasoning as sentry.ts's own sentryTagValue guard. - /* v8 ignore next -- @preserve unreachable: initSentry(process.env) never rejects */ - await initSentry(process.env).catch(() => false); - const release = resolveDiscoveryIndexSentryRelease(process.env); - const required = { - SENTRY_AUTH_TOKEN: nonBlank(process.env.SENTRY_AUTH_TOKEN), - SENTRY_ORG: nonBlank(process.env.SENTRY_ORG), - SENTRY_PROJECT: nonBlank(process.env.SENTRY_PROJECT), - SENTRY_RELEASE: release, - }; - const missing = Object.entries(required) - .filter(([, value]) => !value) - .map(([key]) => key); - if (missing.length > 0) { - log("discovery_index_sentry_sourcemap_upload_skipped", { reason: "missing_config", missing }); - return 0; - } - - const strict = /^(1|true|yes|on)$/i.test(process.env.DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT ?? ""); - try { - validateSourceMaps(); - const projectArgs = ["--org", required.SENTRY_ORG!, "--project", required.SENTRY_PROJECT!]; - runSentry(["releases", ...projectArgs, "new", release!], { allowExistingRelease: true }); - - const sha = nonBlank(process.env.SENTRY_COMMIT_SHA); - if (sha) { - const repo = nonBlank(process.env.SENTRY_REPOSITORY) ?? "JSONbored/loopover"; - const previous = nonBlank(process.env.SENTRY_PREVIOUS_COMMIT_SHA); - const spec = previous ? `${repo}@${previous}..${sha}` : `${repo}@${sha}`; - runSentry(["releases", ...projectArgs, "set-commits", release!, "--commit", spec, "--ignore-missing"], { allowFailure: !strict }); - } - - runSentry(["sourcemaps", ...projectArgs, "inject", "dist"]); - validateSourceMaps(); - runSentry(["sourcemaps", ...projectArgs, "upload", "--release", release!, "--validate", "--wait", ...(strict ? ["--strict"] : []), "dist"]); - const deployName = nonBlank(process.env.SENTRY_DEPLOY_NAME) ?? "cloudflare-container"; - runSentry(["releases", ...projectArgs, "deploys", "new", "--release", release!, "--env", resolveSentryEnvironment(process.env), "--name", deployName]); - runSentry(["releases", ...projectArgs, "finalize", release!]); - await runReleaseValidation(release!, { sha, deployName, environment: resolveSentryEnvironment(process.env), strict }); - log("discovery_index_sentry_sourcemap_upload_complete", { release }); - return 0; - } catch (error) { - captureSourcemapUploadFailure(error, { - release, - deploymentId: nonBlank(process.env.SENTRY_DEPLOY_NAME), - strict, - sha: nonBlank(process.env.SENTRY_COMMIT_SHA), - }); - await flushSentry(); - warn("discovery_index_sentry_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); - return strict ? 1 : 0; - } + throw new Error(`PostHog release validation failed (${status}): ${output.slice(0, 500)}`); } -// Resolved via require.resolve, mirroring sentryCliPath()'s identical reasoning (this is a real npm -// workspace member, so npm hoists @posthog/cli's binary to the ROOT node_modules/.bin/ by default). +// 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; @@ -235,40 +125,9 @@ function postHogCliPath(): string { return join(dirname(pkgJsonPath), binRelativePath); } -function shouldValidatePostHogRelease(): boolean { - return !/^(0|false|no|off)$/i.test(process.env.DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE ?? ""); -} - -async function runPostHogReleaseValidation(release: string): Promise { - if (!shouldValidatePostHogRelease()) return; - 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-posthog-release.mjs"], { - cwd: appDir, - 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_posthog_release_validation", { output: output.slice(0, 500), attempt }); - return; - } - if (attempt < attempts) { - warn("discovery_index_posthog_release_validation_retry", { attempt, attempts, retryDelayMs, message: output.slice(0, 500) }); - if (retryDelayMs > 0) await sleep(retryDelayMs); - } - } - throw new Error(`PostHog release validation failed (${status}): ${output.slice(0, 500)}`); -} - 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) -- no equivalent of Sentry CLI's --org/--project - // flags needed here. + // 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) { @@ -278,8 +137,8 @@ function runPostHog(args: string[]): void { throw new Error(`posthog-cli ${args.join(" ")} failed (${result.status}): ${output.slice(0, 500)}`); } -async function runPostHogSourcemapUpload(): Promise { - /* v8 ignore next -- @preserve unreachable: initDiscoveryIndexPostHog(process.env) never rejects, mirrors initSentry's identical guarantee */ +async function main(): Promise { + /* v8 ignore next -- @preserve unreachable: initDiscoveryIndexPostHog(process.env) never rejects */ await initDiscoveryIndexPostHog(process.env).catch(() => false); const release = resolveDiscoveryIndexPostHogRelease(process.env); const required = { @@ -299,31 +158,21 @@ async function runPostHogSourcemapUpload(): Promise { try { validateSourceMaps(); // No separate "create release" step -- PostHog release metadata is a byproduct of the inject/upload - // calls below (unlike Sentry's explicit releases new/set-commits/deploys/finalize lifecycle). 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. + // 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(); runPostHog(["sourcemap", "upload", "--directory", "dist", "--release-version", release!]); - await runPostHogReleaseValidation(release!); + await runReleaseValidation(release!); log("discovery_index_posthog_sourcemap_upload_complete", { release }); return 0; } catch (error) { - captureSourcemapUploadPostHogFailure(error, { release }); + 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; } } -async function main(): Promise { - // Sentry first (existing, proven flow, unmodified), PostHog second -- see this file's header comment for - // why that order matters. Both legs are independent and non-blocking of each other; a failure in one - // doesn't skip the other. - const sentryExitCode = await runSentrySourcemapUpload(); - const postHogExitCode = await runPostHogSourcemapUpload(); - return Math.max(sentryExitCode, postHogExitCode); -} - process.exitCode = await main(); diff --git a/packages/discovery-index/src/worker.ts b/packages/discovery-index/src/worker.ts index b7f322b451..b471f13f73 100644 --- a/packages/discovery-index/src/worker.ts +++ b/packages/discovery-index/src/worker.ts @@ -34,26 +34,18 @@ 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, parallel-run alongside the Sentry vars above) follow the identical forwarding - // discipline: initDiscoveryIndexPostHog/upload-sourcemaps.ts no-op cleanly on an empty/unset POSTHOG_API_KEY/ - // POSTHOG_CLI_API_KEY, so they're forwarded unconditionally too. 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. + // 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 ?? "", @@ -69,12 +61,6 @@ 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; 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 33f2460514..220dae1df1 100644 --- a/packages/discovery-index/wrangler.jsonc +++ b/packages/discovery-index/wrangler.jsonc @@ -7,39 +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, mirrors SENTRY_AUTH_TOKEN) + // 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) - // POSTHOG_RELEASE follows the identical per-deploy pattern once configured. + // 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 like SENTRY_DSN below -- 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 no-ops, exactly like an unset SENTRY_DSN. + // (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. #8289 migrates this surface - // to a loopover-owned PostHog project (parallel-run with this Sentry sink until the gated decommission, - // #8298) -- see the POSTHOG_* comment above for why those vars aren't here yet. - "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/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 ec970444c9..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,6 @@ 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(() => ({ @@ -67,22 +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 leg's own required config (#8289) -- NOT included in REQUIRED_ENV above, so every existing Sentry- -// only test in this file exercises the PostHog leg's missing-config skip path (contributing exit code 0 to -// main()'s Math.max combine) without needing any changes. -const POSTHOG_REQUIRED_ENV: Record = { 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; @@ -94,15 +83,6 @@ function setEnv(overrides: Record): void { } } -/** Sets both legs' required env (Sentry's REQUIRED_ENV + PostHog's POSTHOG_REQUIRED_ENV), so a test can - * exercise the PostHog leg's real behavior alongside the (unchanged, default-successful) Sentry leg. */ -function setEnvWithPostHog(overrides: Record): void { - for (const [key, value] of Object.entries({ ...REQUIRED_ENV, ...POSTHOG_REQUIRED_ENV, ...overrides })) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } -} - function isPostHogCliCall(command: string): boolean { return command === "FAKE_POSTHOG_CLI"; } @@ -115,10 +95,6 @@ function spawnSuccess(): { status: number; stdout: string; stderr: string } { return { status: 0, stdout: "", stderr: "" }; } -function isValidateReleaseCall(args: string[]): boolean { - return args[0] === "scripts/validate-sentry-release.mjs"; -} - async function run(): Promise { await import(MODULE_PATH); } @@ -146,346 +122,17 @@ 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(); - }); - - it("runs the full success flow with no sha and validation turned off", async () => { - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - const calls = spawnSyncMock.mock.calls.map(([command, args]) => ({ command, args })); - expect(calls).toEqual([ - { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "new", "loopover-discovery-index@abc123"] }, - { command: "FAKE_SENTRY_CLI", args: ["sourcemaps", "--org", "jsonbored", "--project", "discovery-index", "inject", "dist"] }, - { - command: "FAKE_SENTRY_CLI", - args: ["sourcemaps", "--org", "jsonbored", "--project", "discovery-index", "upload", "--release", "loopover-discovery-index@abc123", "--validate", "--wait", "dist"], - }, - { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "deploys", "new", "--release", "loopover-discovery-index@abc123", "--env", "production", "--name", "cloudflare-container"] }, - { command: "FAKE_SENTRY_CLI", args: ["releases", "--org", "jsonbored", "--project", "discovery-index", "finalize", "loopover-discovery-index@abc123"] }, - ]); - // No sha at all -> set-commits is skipped entirely; validation is off -> its script is never spawned. - expect(calls.some((call) => call.args.includes("set-commits"))).toBe(false); - expect(calls.some((call) => isValidateReleaseCall(call.args))).toBe(false); - }); - - it("associates commits via set-commits using the default repo when only a commit sha is given", async () => { - setEnv({ SENTRY_COMMIT_SHA: "abc123" }); - await run(); - expect(process.exitCode).toBe(0); - const setCommits = spawnSyncMock.mock.calls.find(([, args]) => args.includes("set-commits")); - expect(setCommits?.[1]).toEqual([ - "releases", - "--org", - "jsonbored", - "--project", - "discovery-index", - "set-commits", - "loopover-discovery-index@abc123", - "--commit", - "JSONbored/loopover@abc123", - "--ignore-missing", - ]); - }); - - it("uses a commit range and a custom repo when a previous sha and SENTRY_REPOSITORY are both given", async () => { - setEnv({ SENTRY_COMMIT_SHA: "def456", SENTRY_PREVIOUS_COMMIT_SHA: "abc123", SENTRY_REPOSITORY: "acme/other-repo" }); - await run(); - expect(process.exitCode).toBe(0); - const setCommits = spawnSyncMock.mock.calls.find(([, args]) => args.includes("set-commits")); - expect(setCommits?.[1]).toContain("acme/other-repo@abc123..def456"); - }); - - it("treats a 'release already exists' failure on the create step as success (allowExistingRelease)", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args[0] === "releases" && args.includes("new")) return { status: 1, stdout: "", stderr: "410: version already exists" }; - return spawnSuccess(); - }); - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - }); - - it("swallows a failed non-strict set-commits with a warning and continues the upload", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; - return spawnSuccess(); - }); - setEnv({ SENTRY_COMMIT_SHA: "abc123" }); - await run(); - expect(process.exitCode).toBe(0); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_cli_failed")); - // Non-strict allowFailure means the upload still proceeds past set-commits to finalize. - expect(spawnSyncMock.mock.calls.some(([, args]) => args.includes("finalize"))).toBe(true); - }); - - it("propagates a failed strict set-commits as a hard failure and exits 1", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; - return spawnSuccess(); - }); - setEnv({ SENTRY_COMMIT_SHA: "abc123", DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true" }); - await run(); - expect(process.exitCode).toBe(1); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); - }); - - it("fails non-strict validateSourceMaps errors as a soft failure (exit 0) with the reason logged", async () => { - applyFsFixture({ files: { [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js.map"] } }); - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist/server.js is missing")); - }); - - it("throws when dist/server.js.map is missing", async () => { - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE }, dirs: { [DIST_DIR]: ["server.js"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist/server.js.map is missing")); - }); - - it("throws when the server bundle is missing its sourceMappingURL comment", async () => { - applyFsFixture({ files: { [SERVER_JS]: "console.log(1);\n", [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing the server.js.map sourceMappingURL")); - }); - - it("throws when no .js.map files are found even though the required files exist", async () => { - // existsSync reports both files present, but the directory listing (a separate mock) omits the map -- - // exercises the maps.length === 0 branch independently of the existsSync checks above it. - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: VALID_MAP }, dirs: { [DIST_DIR]: ["server.js"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("dist has no JavaScript source maps")); - }); - - it("throws when a source map has no original sources", async () => { - const badMap = JSON.stringify({ sources: [], sourcesContent: [] }); - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has no original sources")); - }); - - it("throws when a source map's sourcesContent doesn't match its sources length", async () => { - const badMap = JSON.stringify({ sources: ["../src/server.ts"], sourcesContent: [] }); - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does not embed sourcesContent for every source")); - }); - - it("throws when a source map's sourcesContent is present but entirely blank", async () => { - const badMap = JSON.stringify({ sources: ["../src/server.ts"], sourcesContent: [" "] }); - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("has empty sourcesContent")); - }); - - it("throws when no source map references src/server.ts", async () => { - const badMap = JSON.stringify({ sources: ["../src/other.ts"], sourcesContent: ["export const y = 1;"] }); - applyFsFixture({ files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: badMap }, dirs: { [DIST_DIR]: ["server.js", "server.js.map"] } }); - setEnv({}); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("source maps do not include src/server.ts")); - }); - - it("recurses into nested directories when scanning dist for source maps", async () => { - const chunkDir = resolve(DIST_DIR, "chunks"); - const chunkMap = JSON.stringify({ sources: ["../src/other.ts"], sourcesContent: ["export const z = 1;"] }); - applyFsFixture({ - files: { [SERVER_JS]: VALID_BUNDLE, [SERVER_MAP]: VALID_MAP, [resolve(chunkDir, "chunk1.js.map")]: chunkMap }, - dirs: { [DIST_DIR]: ["server.js", "server.js.map", "chunks"], [chunkDir]: ["chunk1.js.map"] }, - }); - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - }); - - it("resolves the real sentry-cli binary from a string-form package.json bin field", async () => { - applyFsFixture({ - ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: "bin/sentry-cli" }) }, - }); - setEnv({ SENTRY_CLI_PATH: undefined }); - await run(); - expect(process.exitCode).toBe(0); - const [command] = spawnSyncMock.mock.calls[0] ?? []; - expect(command).toBe(resolve(dirname(CLI_PKG_JSON), "bin/sentry-cli")); - }); - - it("falls back to an '@sentry/cli'-keyed bin field when 'sentry-cli' isn't present", async () => { - applyFsFixture({ - ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({ bin: { "@sentry/cli": "bin/alt-cli" } }) }, - }); - setEnv({ SENTRY_CLI_PATH: undefined }); - await run(); - expect(process.exitCode).toBe(0); - const [command] = spawnSyncMock.mock.calls[0] ?? []; - expect(command).toBe(resolve(dirname(CLI_PKG_JSON), "bin/alt-cli")); - }); - - it("fails when @sentry/cli's package.json has no resolvable bin entry", async () => { - applyFsFixture({ - ...validDistFixture(), - files: { ...validDistFixture().files, [CLI_PKG_JSON]: JSON.stringify({}) }, - }); - setEnv({ SENTRY_CLI_PATH: undefined }); - await run(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no resolvable bin entry")); - }); - - it("skips release validation entirely when DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE is off", async () => { - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "0" }); - await run(); - expect(spawnSyncMock.mock.calls.some(([, args]) => isValidateReleaseCall(args))).toBe(false); - }); - - it("runs release validation once and succeeds on the first attempt", async () => { - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1" }); - await run(); - expect(process.exitCode).toBe(0); - const validateCalls = spawnSyncMock.mock.calls.filter(([, args]) => isValidateReleaseCall(args)); - expect(validateCalls).toHaveLength(1); - }); - - it("retries release validation until it succeeds, logging a retry warning each time", async () => { - let validateAttempts = 0; - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) { - validateAttempts += 1; - return validateAttempts < 3 ? { status: 1, stdout: "", stderr: "release not fully propagated yet" } : spawnSuccess(); - } - return spawnSuccess(); - }); - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "5", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); - await run(); - expect(process.exitCode).toBe(0); - expect(validateAttempts).toBe(3); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_release_validation_retry")); - }); - - it("falls back to the default attempt count when DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS is invalid", async () => { - let validateAttempts = 0; - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) { - validateAttempts += 1; - return { status: 1, stdout: "", stderr: "still not visible" }; - } - return spawnSuccess(); - }); - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "not-a-number", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); - await run(); - expect(process.exitCode).toBe(0); - // Non-strict: exhausting all attempts is caught by main() and treated as a soft failure. - expect(validateAttempts).toBe(5); - }); - - it("clamps an oversized DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS to its max of 20", async () => { - let validateAttempts = 0; - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) { - validateAttempts += 1; - return { status: 1, stdout: "", stderr: "still not visible" }; - } - return spawnSuccess(); - }); - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "999", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0" }); - await run(); - expect(validateAttempts).toBe(20); - }); - - it("tolerates a spawnSync result missing stdout/stderr, and logs verbose output on success", async () => { - let call = 0; - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - call += 1; - // No stdout/stderr keys at all -> exercises the `result.stdout ?? ""` / `result.stderr ?? ""` - // fallback used to build runSentry's `output` string. - if (call === 1) return { status: 0 }; - if (call === 2) return { status: 0, stdout: "uploaded 3 files" }; - return spawnSuccess(); - }); - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_cli")); - }); - - it("defaults release validation to ON when DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE is unset", async () => { - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: undefined }); - await run(); - expect(process.exitCode).toBe(0); - expect(spawnSyncMock.mock.calls.some(([, args]) => isValidateReleaseCall(args))).toBe(true); - }); - - it("logs verbose release-validation output, waits between retries, and tolerates a missing stdout/stderr result", async () => { - let attempt = 0; - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (!isValidateReleaseCall(args)) return spawnSuccess(); - attempt += 1; - // First attempt fails with no stdout/stderr keys (the ?? "" fallback); second succeeds with real - // output (the `if (output) log(...)` truthy branch). - if (attempt === 1) return { status: 1 }; - return { status: 0, stdout: "release visible" }; - }); - setEnv({ DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "3", DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "5" }); - await run(); - expect(process.exitCode).toBe(0); - expect(attempt).toBe(2); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_release_validation")); - }); - - it("handles a non-Error value thrown out of spawnSync as a soft failure", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args[0] === "releases" && args.includes("new")) { - // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately - throw "spawnSync exploded (string throw)"; - } - return spawnSuccess(); - }); - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); - }); - - it("exhausts validation attempts and fails strictly (exit 1) when set to strict", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (isValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; - return spawnSuccess(); - }); - setEnv({ - DISCOVERY_INDEX_SENTRY_VALIDATE_RELEASE: "1", - DISCOVERY_INDEX_SENTRY_VALIDATE_ATTEMPTS: "2", - DISCOVERY_INDEX_SENTRY_VALIDATE_RETRY_DELAY_MS: "0", - DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true", - }); - await run(); - expect(process.exitCode).toBe(1); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_sentry_sourcemap_upload_failed")); - }); -}); - -describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { - it("skips the PostHog upload and exits 0 when required PostHog config is missing (Sentry leg still runs)", async () => { - setEnv({}); - await run(); - expect(process.exitCode).toBe(0); - expect(spawnSyncMock.mock.calls.some(([command]) => isPostHogCliCall(command))).toBe(false); expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_skipped")); }); - it("runs the full PostHog success flow with the exact inject/upload args, after the Sentry leg", async () => { - setEnvWithPostHog({}); + it("runs the full success flow with the exact inject/upload args", async () => { + setEnv({}); await run(); expect(process.exitCode).toBe(0); const posthogCalls = spawnSyncMock.mock.calls.filter(([command]) => isPostHogCliCall(command)).map(([, args]) => args); @@ -493,44 +140,31 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { ["sourcemap", "inject", "--directory", "dist", "--release-version", "loopover-discovery-index@abc123"], ["sourcemap", "upload", "--directory", "dist", "--release-version", "loopover-discovery-index@abc123"], ]); - // Sentry's own calls are unaffected -- both legs' spawnSync calls coexist in the same mock's call list. - expect(spawnSyncMock.mock.calls.some(([command]) => command === "FAKE_SENTRY_CLI")).toBe(true); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_complete")); }); - it("treats a non-strict PostHog upload failure as a soft failure (exit 0) with the reason logged", 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 (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; return spawnSuccess(); }); - setEnvWithPostHog({}); + setEnv({}); await run(); expect(process.exitCode).toBe(0); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_failed")); }); - it("propagates a strict PostHog upload failure as exit 1, even when the Sentry leg succeeds", async () => { + it("propagates a strict upload failure as exit 1", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (isPostHogCliCall(command) && args[1] === "upload") return { status: 1, stdout: "", stderr: "upload rejected" }; return spawnSuccess(); }); - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT: "true" }); + setEnv({ DISCOVERY_INDEX_POSTHOG_UPLOAD_STRICT: "true" }); await run(); expect(process.exitCode).toBe(1); }); - it("still runs (and can fail) the PostHog leg even when the Sentry leg fails strictly -- both legs are independent", async () => { - spawnSyncMock.mockImplementation((command: string, args: string[]) => { - if (args.includes("set-commits")) return { status: 1, stdout: "", stderr: "unrelated commit history" }; - return spawnSuccess(); - }); - setEnvWithPostHog({ SENTRY_COMMIT_SHA: "abc123", DISCOVERY_INDEX_SENTRY_UPLOAD_STRICT: "true" }); - await run(); - expect(process.exitCode).toBe(1); - expect(spawnSyncMock.mock.calls.some(([command]) => isPostHogCliCall(command))).toBe(true); - expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_sourcemap_upload_complete")); - }); - - it("handles a non-Error value thrown out of the PostHog spawnSync as a soft failure", async () => { + it("handles a non-Error value thrown out of spawnSync as a soft failure", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (isPostHogCliCall(command) && args[0] === "sourcemap" && args[1] === "inject") { // eslint-disable-next-line @typescript-eslint/only-throw-error -- exercising the non-Error branch deliberately @@ -538,7 +172,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { } return spawnSuccess(); }); - setEnvWithPostHog({}); + setEnv({}); await run(); expect(process.exitCode).toBe(0); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("spawnSync exploded (string throw)")); @@ -549,7 +183,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { ...validDistFixture(), files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "posthog-cli": "run-posthog-cli.js" } }) }, }); - setEnvWithPostHog({ POSTHOG_CLI_PATH: undefined }); + setEnv({ POSTHOG_CLI_PATH: undefined }); await run(); expect(process.exitCode).toBe(0); const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); @@ -561,7 +195,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { ...validDistFixture(), files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: { "@posthog/cli": "bin/alt-cli" } }) }, }); - setEnvWithPostHog({ POSTHOG_CLI_PATH: undefined }); + setEnv({ POSTHOG_CLI_PATH: undefined }); await run(); expect(process.exitCode).toBe(0); const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); @@ -573,7 +207,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { ...validDistFixture(), files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({ bin: "run-posthog-cli.js" }) }, }); - setEnvWithPostHog({ POSTHOG_CLI_PATH: undefined }); + setEnv({ POSTHOG_CLI_PATH: undefined }); await run(); expect(process.exitCode).toBe(0); const posthogCall = spawnSyncMock.mock.calls.find(([, args]) => args[0] === "sourcemap"); @@ -585,7 +219,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { ...validDistFixture(), files: { ...validDistFixture().files, [POSTHOG_CLI_PKG_JSON]: JSON.stringify({}) }, }); - setEnvWithPostHog({ POSTHOG_CLI_PATH: undefined }); + setEnv({ POSTHOG_CLI_PATH: undefined }); await run(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no resolvable bin entry")); }); @@ -595,7 +229,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { if (isPostHogCliCall(command)) return { status: 0 }; return spawnSuccess(); }); - setEnvWithPostHog({}); + setEnv({}); await run(); expect(process.exitCode).toBe(0); }); @@ -605,25 +239,25 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { if (isPostHogCliCall(command)) return { status: 0, stdout: "uploaded 3 sourcemaps" }; return spawnSuccess(); }); - setEnvWithPostHog({}); + setEnv({}); await run(); expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_cli")); }); - it("skips PostHog release validation entirely when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is off", async () => { - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: "0" }); + 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]) => isPostHogValidateReleaseCall(args))).toBe(false); }); - it("defaults PostHog release validation to ON when DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE is unset", async () => { - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_RELEASE: undefined }); + 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); expect(spawnSyncMock.mock.calls.some(([, args]) => isPostHogValidateReleaseCall(args))).toBe(true); }); - it("retries PostHog release validation until it succeeds, logging a retry warning each time", async () => { + it("retries release validation until it succeeds, logging a retry warning each time", async () => { let validateAttempts = 0; spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (isPostHogValidateReleaseCall(args)) { @@ -632,7 +266,7 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { } return spawnSuccess(); }); - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "5", DISCOVERY_INDEX_POSTHOG_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); @@ -640,7 +274,37 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining("discovery_index_posthog_release_validation")); }); - it("tolerates a PostHog validate-release result missing stdout/stderr, and waits between retries", 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 (isPostHogValidateReleaseCall(args)) { + validateAttempts += 1; + return { status: 1, stdout: "", stderr: "still not visible" }; + } + return spawnSuccess(); + }); + 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 and treated as a soft failure. + expect(validateAttempts).toBe(5); + }); + + 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 (isPostHogValidateReleaseCall(args)) { + validateAttempts += 1; + return { status: 1, stdout: "", stderr: "still not visible" }; + } + return spawnSuccess(); + }); + 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 validate-release result missing stdout/stderr, and waits between retries", async () => { let attempt = 0; spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (!isPostHogValidateReleaseCall(args)) return spawnSuccess(); @@ -648,52 +312,40 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { if (attempt === 1) return { status: 1 }; return { status: 0, stdout: "release visible" }; }); - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "3", DISCOVERY_INDEX_POSTHOG_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); }); - it("exhausts PostHog validation attempts and fails softly (exit 0) when not strict", async () => { + it("exhausts validation attempts and fails softly (exit 0) when not strict", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (isPostHogValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; return spawnSuccess(); }); - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "2", DISCOVERY_INDEX_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0" }); + 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("discovery_index_posthog_sourcemap_upload_failed")); }); - it("exhausts PostHog validation attempts and fails strictly (exit 1) when set to strict", async () => { + it("exhausts validation attempts and fails strictly (exit 1) when set to strict", async () => { spawnSyncMock.mockImplementation((command: string, args: string[]) => { if (isPostHogValidateReleaseCall(args)) return { status: 1, stdout: "", stderr: "still not visible" }; return spawnSuccess(); }); - setEnvWithPostHog({ + setEnv({ + 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_posthog_sourcemap_upload_failed")); }); - 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 (isPostHogValidateReleaseCall(args)) { - validateAttempts += 1; - return { status: 1, stdout: "", stderr: "still not visible" }; - } - return spawnSuccess(); - }); - setEnvWithPostHog({ DISCOVERY_INDEX_POSTHOG_VALIDATE_ATTEMPTS: "999", DISCOVERY_INDEX_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0" }); - await run(); - expect(validateAttempts).toBe(20); - }); - - it("re-validates source maps between inject and upload, failing the PostHog leg if injection corrupted them", async () => { + 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") { @@ -708,9 +360,84 @@ describe("discovery-index upload-sourcemaps -- PostHog leg (#8289)", () => { if (!(path in fixture)) throw new Error(`ENOENT (fixture): ${path}`); return fixture[path]; }); - setEnvWithPostHog({}); + 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); + }); });