From 33c2542a48383ee781f6e55b66fbcda981f34d68 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:30:49 -0700 Subject: [PATCH 1/2] feat(rees): replace Sentry with PostHog for error tracking + source maps Per the epic's revised strategy (#8286 correction, 2026-07-25): PostHog replaces Sentry directly, not a parallel-run sink -- deletes src/sentry.ts, scripts/validate-sentry-release.mjs, @sentry/node, @sentry/cli, and the sentry-*.test.ts suite entirely. Adds src/posthog.ts, mirroring the deleted sentry.ts's shape 1:1 (redaction, tag allowlist, capture entry points, including captureAnalyzerDegradationPostHog's #5010 group-by-WHY behavior) using posthog-node's captureException API. Adds scripts/validate-posthog-release.mjs, a narrower release-verification counterpart to the deleted Sentry validator -- PostHog's release model has no commits/deploys/finalize lifecycle, only symbol-set presence via the error_tracking/symbol_sets API. Drops all Railway-specific config (RAILWAY_GIT_COMMIT_SHA, RAILWAY_DEPLOYMENT_ID, RAILWAY_ENVIRONMENT_NAME) -- this repo no longer deploys anything on Railway. README's Railway deploy section becomes a generic "Deploy standalone" section; docker-compose.yml's rees profile comment and the self-hosting-rees docs page now reference POSTHOG_API_KEY instead of SENTRY_DSN for the same cross-wire-avoidance reasoning that applied to the Sentry var. @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, the same category of risk @sentry/cli already carried here via its own installer. Closes #8290 --- .../content/docs/self-hosting-rees.mdx | 13 +- docker-compose.yml | 10 +- review-enrichment/Dockerfile | 5 +- review-enrichment/README.md | 129 ++-- review-enrichment/package-lock.json | 639 ++---------------- review-enrichment/package.json | 8 +- .../scripts/validate-posthog-release.mjs | 122 ++++ .../scripts/validate-sentry-release.mjs | 294 -------- review-enrichment/src/brief.ts | 4 +- review-enrichment/src/posthog.ts | 256 +++++++ review-enrichment/src/sentry.ts | 336 --------- review-enrichment/src/server.ts | 36 +- review-enrichment/src/types.ts | 2 +- review-enrichment/src/upload-sourcemaps.ts | 184 ++--- .../test/posthog-degradation.test.ts | 259 +++++++ .../test/posthog-release-validation.test.ts | 179 +++++ review-enrichment/test/posthog-upload.test.ts | 132 ++++ .../test/sentry-degradation.test.ts | 488 ------------- .../test/sentry-release-validation.test.ts | 195 ------ review-enrichment/test/sentry-upload.test.ts | 211 ------ 20 files changed, 1163 insertions(+), 2339 deletions(-) create mode 100644 review-enrichment/scripts/validate-posthog-release.mjs delete mode 100644 review-enrichment/scripts/validate-sentry-release.mjs create mode 100644 review-enrichment/src/posthog.ts delete mode 100644 review-enrichment/src/sentry.ts create mode 100644 review-enrichment/test/posthog-degradation.test.ts create mode 100644 review-enrichment/test/posthog-release-validation.test.ts create mode 100644 review-enrichment/test/posthog-upload.test.ts delete mode 100644 review-enrichment/test/sentry-degradation.test.ts delete mode 100644 review-enrichment/test/sentry-release-validation.test.ts delete mode 100644 review-enrichment/test/sentry-upload.test.ts diff --git a/apps/loopover-ui/content/docs/self-hosting-rees.mdx b/apps/loopover-ui/content/docs/self-hosting-rees.mdx index 52a89b2277..b8ec4c8d54 100644 --- a/apps/loopover-ui/content/docs/self-hosting-rees.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-rees.mdx @@ -64,11 +64,11 @@ REES_URL=http://rees:8080 REES_SHARED_SECRET=`} /> -No `SENTRY_*` variables are required for a working local REES. Set them only if you want REES +No `POSTHOG_*` variables are required for a working local REES. Set them only if you want REES error reporting — see "Service configuration" below for the variables REES reads, and add them for the `rees` service through a `docker-compose.override.yml` rather than the root `.env`: REES -reads the same `SENTRY_DSN` name the main engine uses, so forwarding the whole `.env` file would -point REES's error reporting at the engine's Sentry project instead of a dedicated one. +reads the same `POSTHOG_API_KEY` name the main engine uses, so forwarding the whole `.env` file +would point REES's error reporting at the engine's PostHog project instead of a dedicated one. ### Pointing at an external or managed instance instead @@ -177,15 +177,14 @@ when the REES service is inside your trust boundary. ## Service configuration -The REES service must use the matching `REES_SHARED_SECRET`. Optional Sentry env captures +The REES service must use the matching `REES_SHARED_SECRET`. Optional PostHog env captures analyzer degradations without logging request bodies, tokens, diffs, or review content. -SENTRY_DSN= -SENTRY_ENVIRONMENT=production -SENTRY_TRACES_SAMPLE_RATE=0`} +POSTHOG_API_KEY= +POSTHOG_ENVIRONMENT=production`} /> ## Failure behavior diff --git a/docker-compose.yml b/docker-compose.yml index 218d119e89..245afa2959 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -652,7 +652,7 @@ services: # (src/review/enrichment-wire.ts) -- this whole service is optional end to end, same as browserless above. # After `docker compose --profile rees up -d`, set in .env: LOOPOVER_REVIEW_ENRICHMENT=true, # REES_URL=http://rees:8080, and a freshly generated REES_SHARED_SECRET -- do not reuse a secret from any - # other REES instance (e.g. a managed Railway deployment) you also point at (#1667). + # other REES instance you also point at (#1667). rees: build: context: review-enrichment @@ -660,10 +660,10 @@ services: <<: *default-logging profiles: ["rees"] # Deliberately NOT `env_file: .env` like the loopover service above: that would also hand this - # analyzer service every unrelated secret in .env, and REES reads the same SENTRY_DSN key name the main - # app uses (src/selfhost/sentry.ts) -- blanket-forwarding .env would silently point REES's error reporting - # at the main app's Sentry project instead of a dedicated one. Add REES-specific Sentry vars via - # docker-compose.override.yml if you want REES error reporting (see review-enrichment/README.md). + # analyzer service every unrelated secret in .env, and REES reads the same POSTHOG_API_KEY key name the + # main app uses (src/selfhost/posthog.ts) -- blanket-forwarding .env would silently point REES's error + # reporting at the main app's PostHog project instead of a dedicated one. Add REES-specific PostHog vars + # via docker-compose.override.yml if you want REES error reporting (see review-enrichment/README.md). environment: PORT: "8080" # Soft default, not ":?required" -- compose interpolates every service's env vars for the whole file diff --git a/review-enrichment/Dockerfile b/review-enrichment/Dockerfile index 7bef6a95b9..0facb5a3fb 100644 --- a/review-enrichment/Dockerfile +++ b/review-enrichment/Dockerfile @@ -1,7 +1,8 @@ # LoopOver review-enrichment service (REES). Lean two-stage Node build; analyzers are pure-JS diff-hunk # heuristics with no external CLI tools (#1477 scoped this to the cheap, no-checkout path -- see # review-enrichment/src/analyzers/complexity.ts's header for why a real linter/AST toolchain is out of scope). -# Build context = the review-enrichment/ directory (Railway "Root Directory" = review-enrichment). +# Build context = the review-enrichment/ directory ("Root Directory" = review-enrichment for any +# Dockerfile-builder platform). FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ @@ -27,5 +28,5 @@ USER rees ENV PORT=8080 EXPOSE 8080 # Provide at runtime (NOT baked into the image): REES_SHARED_SECRET (shared bearer with the engine), -# plus optional Sentry vars. The uploader no-ops unless Sentry auth/org/project/release are configured. +# plus optional PostHog vars. The uploader no-ops unless POSTHOG_CLI_API_KEY/PROJECT_ID/POSTHOG_RELEASE are configured. CMD ["sh", "-c", "node dist/upload-sourcemaps.js && find dist -type f -name '*.map' -delete && node dist/server.js"] diff --git a/review-enrichment/README.md b/review-enrichment/README.md index 27cc52e9a4..3af623576c 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -4,7 +4,7 @@ A standalone microservice that produces a structured **review brief** for the lo in-network alongside a self-hosted engine via the repo-root `docker-compose --profile rees` service (the simplest path, no separate hosting to manage — see the [self-hosting REES docs](https://loopover.ai/docs/self-hosting-rees)), or deploy it as its own service on any platform that can run a Dockerfile-based Node service — see -[Deploy (Railway)](#deploy-railway) below for one example. +[Deploy standalone](#deploy-standalone) below. The engine reviews PRs by running a headless `claude --print` subprocess with `Bash`/`WebFetch` disallowed and **no repo checkout**, so it cannot run a linter, hit a CVE database, resolve a dependency tree, or query git history. REES @@ -200,7 +200,7 @@ analyzers should prefer it for shared PR facts instead of reparsing the envelope Context caches are request-scoped only. They are for avoiding duplicate work inside one enrichment run, not for cross-request TTL storage. Cache metrics are aggregate and public-safe: hit/miss counts, external-call counts by category, skipped/capped work counts by category, and elapsed time. Never put request bodies, diffs, prompts, -comments, tokens, private configs, or raw external payloads into cache categories, metric keys, Sentry tags, or logs. +comments, tokens, private configs, or raw external payloads into cache categories, metric keys, PostHog tags, or logs. The engine also sends `budget.timeoutMs` with one second of headroom below `REES_TIMEOUT_MS`, so REES can return a partial/degraded brief before the caller aborts the HTTP request. If your REES deployment is still running an older @@ -217,68 +217,58 @@ curl -XPOST localhost:8080/v1/enrich -H 'authorization: Bearer dev' \ -H 'content-type: application/json' -d '{"repoFullName":"o/r","prNumber":1}' ``` -## Deploy (Railway) +## Deploy standalone For a self-hosted engine, `docker compose --profile rees up -d` from the repo root (see the [self-hosting REES docs](https://loopover.ai/docs/self-hosting-rees)) is the simplest path — no separate service to host. If you'd rather run REES on its own outside that compose network, it's a plain -Dockerfile-based Node service and can go anywhere that builds one; Railway is one option this repo has release -tooling for (the Sentry/source-map wiring below). Point **Root Directory = `review-enrichment`** at a `railway.json` -you add there (see Railway's Dockerfile-builder docs) and set `REES_SHARED_SECRET` (same value the engine holds) as a -service variable — never commit it. The engine reaches the service over Railway **private networking** -(`.railway.internal`); no public domain is required. - -## Sentry releases and source maps - -REES supports optional Sentry error reporting and source-map upload for Railway deployments. The Docker image builds -`dist/*.js.map` with embedded `sourcesContent`, then the runtime startup command injects Sentry debug ids, uploads the -exact post-injection `dist/` files, records a deploy, removes source maps from the running filesystem, and starts -`dist/server.js`. - -Set these Railway service variables: - -| Variable | Purpose | -| ------------------------------ | ----------------------------------------------------------------------- | -| `SENTRY_DSN` | Enables REES error capture. Unset means the SDK is a no-op. | -| `SENTRY_AUTH_TOKEN` | Allows the runtime uploader to create releases and upload source maps. | -| `SENTRY_ORG` | Sentry organization slug. | -| `SENTRY_PROJECT` | Sentry project slug. | -| `SENTRY_ENVIRONMENT` | Optional; defaults to Railway's environment name, then `production`. | -| `SENTRY_TRACES_SAMPLE_RATE` | Optional; defaults to `0`, so errors report without tracing. | -| `SENTRY_RELEASE` | Optional override. Only set it when that exact REES bundle is uploaded. | -| `SENTRY_URL` | Optional Sentry API URL; defaults to `https://sentry.io`. | -| `SENTRY_REPOSITORY` | Optional; defaults to `JSONbored/loopover` for commit association. | -| `REES_SENTRY_UPLOAD_STRICT` | Optional. Set `true` to fail startup if source-map upload fails. | -| `REES_SENTRY_VALIDATE_RELEASE` | Optional. Set `false` only to disable post-upload release validation. | - -By default the release id is `loopover-rees@`, using Railway's Git metadata. The Sentry -GitHub code mapping should be: - -| Sentry field | Value | -| ---------------- | ------------------- | -| Stack Trace Root | `/app` | -| Source Code Root | `review-enrichment` | -| Branch | `main` | - -Do **not** pass `SENTRY_AUTH_TOKEN` as a Docker build arg. Railway deploys this service from Git, and Docker build args -can leak through image metadata. Keeping the upload at runtime means Sentry sees the same `dist/` files that the service -executes, without exposing source maps over HTTP. - -After upload, startup validates the exact `loopover-rees@` release through the Sentry API: -the release must exist, be finalized, include the deployed commit, and include the Railway deploy id/environment. If -`REES_SENTRY_UPLOAD_STRICT=true`, a failed upload or failed validation stops the Railway deployment; otherwise it logs a -`rees_sentry_sourcemap_upload_failed` warning so the problem is visible without blocking startup. - -Analyzer failures are still fail-open: the `/v1/enrich` response marks the analyzer as `degraded` and returns a partial -brief. When Sentry is enabled, those degradations are captured as `rees_analyzer_degraded` events with tags/context for -`analyzer`, requested analyzer list, `repo`, `pullNumber`, head SHA prefix, `release`, `environment`, timeout budget, -elapsed time, partial/analyzer status, history lookup counts, GitHub endpoint category, request id, and trace id. Use -those fields to spot a broken analyzer without exposing request bodies, diffs, tokens, prompts, comments, or private -config. - -### REES Sentry queries - -REES keeps its indexed Sentry tags intentionally small and stable: +Dockerfile-based Node service and can go anywhere that builds one — point your platform's Dockerfile builder at +this directory (**Root Directory = `review-enrichment`**) and set `REES_SHARED_SECRET` (same value the engine +holds) as a runtime env var — never commit it. Reach the service however your platform does private +networking, or over a public domain with the shared secret as the only gate. + +## PostHog releases and source maps + +REES supports optional PostHog error reporting and source-map upload for standalone deployments. The Docker +image builds `dist/*.js.map` with embedded `sourcesContent`, then the runtime startup command injects PostHog +chunk ids, uploads the exact post-injection `dist/` files, removes source maps from the running filesystem, and +starts `dist/server.js`. + +Set these runtime env vars: + +| Variable | Purpose | +| -------------------------------- | ---------------------------------------------------------------------------- | +| `POSTHOG_API_KEY` | Enables REES error capture (a PostHog project token). Unset means a no-op. | +| `POSTHOG_HOST` | Optional PostHog ingestion host override (EU region). | +| `POSTHOG_ENVIRONMENT` | Optional; defaults to `production`. | +| `POSTHOG_RELEASE` | Optional override. Only set it when that exact REES bundle is uploaded. | +| `POSTHOG_COMMIT_SHA` | Optional; used to derive `POSTHOG_RELEASE` (`loopover-rees@`) when unset. | +| `POSTHOG_CLI_API_KEY` | A PostHog **personal** API key (error-tracking write + organization read scopes) -- allows the runtime uploader to upload source maps. Distinct from `POSTHOG_API_KEY`. | +| `POSTHOG_CLI_PROJECT_ID` | The PostHog project's numeric id, required alongside `POSTHOG_CLI_API_KEY`. | +| `POSTHOG_CLI_HOST` | Optional posthog-cli host override (EU region). | +| `REES_POSTHOG_UPLOAD_STRICT` | Optional. Set `true` to fail startup if source-map upload fails. | +| `REES_POSTHOG_VALIDATE_RELEASE` | Optional. Set `false` only to disable post-upload release validation. | + +Do **not** pass `POSTHOG_CLI_API_KEY` as a Docker build arg on any platform that deploys this service from Git -- +build args can leak through image metadata. Keeping the upload at runtime means PostHog sees the same `dist/` +files that the service executes, without exposing source maps over HTTP. + +After upload, startup validates the release by querying PostHog's `error_tracking/symbol_sets` API: at least one +symbol set must exist for the release, with no recorded `failure_reason`. PostHog's release model has no +Sentry-style commits/deploys/finalize lifecycle to check beyond that. If `REES_POSTHOG_UPLOAD_STRICT=true`, a +failed upload or failed validation stops startup; otherwise it logs a `rees_posthog_sourcemap_upload_failed` +warning so the problem is visible without blocking startup. + +Analyzer failures are still fail-open: the `/v1/enrich` response marks the analyzer as `degraded` and returns a +partial brief. When PostHog is enabled, those degradations are captured as `rees_analyzer_degraded` events with +properties for `analyzer`, requested analyzer list, `repo`, `pullNumber`, head SHA prefix, `release`, +`environment`, timeout budget, elapsed time, partial/analyzer status, history lookup counts, GitHub endpoint +category, request id, and trace id. Use those fields to spot a broken analyzer without exposing request bodies, +diffs, tokens, prompts, comments, or private config. + +### REES PostHog queries + +REES keeps its indexed PostHog properties intentionally small and stable: - `event` - `route` @@ -288,7 +278,7 @@ REES keeps its indexed Sentry tags intentionally small and stable: - `analyzer` - `release` - `environment` -- `railwayDeploymentId` +- `deploymentId` Useful production queries: @@ -297,17 +287,16 @@ Useful production queries: - Analyzer failures grouped by analyzer: - `event:rees_analyzer_degraded analyzer:history` - `event:rees_analyzer_degraded analyzer:dependency repo:JSONbored/loopover` -- Source-map upload/startup failures on a Railway deploy: - - `event:rees_sourcemap_upload_failed railwayDeploymentId:` +- Source-map upload/startup failures: + - `event:rees_sourcemap_upload_failed` - Process-level crashes: - `event:rees_uncaught_exception` - `event:rees_unhandled_rejection` -If Sentry still shows frames such as `/app/dist/server.js`, check: +If PostHog still shows frames such as `/app/dist/server.js`, check: -1. The event's `release` is `loopover-rees@` or your exact `SENTRY_RELEASE` override. -2. The Sentry release has an artifact bundle uploaded for the REES project. -3. Railway has `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` set on the REES service. -4. Startup logs include `sentry_release_validation_complete` for the same release id and Railway deployment id. -5. The Sentry code mapping is `/app` → `review-enrichment` on branch `main`. -6. `npm --prefix review-enrichment run validate:sourcemaps` passes locally. +1. The event's `release` is `loopover-rees@` or your exact `POSTHOG_RELEASE` override. +2. The PostHog project has a symbol set uploaded for that release. +3. The deployment has `POSTHOG_CLI_API_KEY` and `POSTHOG_CLI_PROJECT_ID` set. +4. Startup logs include `rees_posthog_release_validation_complete` for the same release id. +5. `npm --prefix review-enrichment run validate:sourcemaps` passes locally. diff --git a/review-enrichment/package-lock.json b/review-enrichment/package-lock.json index a45d4c7648..d23a78b8d6 100644 --- a/review-enrichment/package-lock.json +++ b/review-enrichment/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "^2.0.0", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "@posthog/cli": "0.9.1", + "hono": "^4.12.27", + "posthog-node": "^5.44.0" }, "devDependencies": { "@types/node": "^22.20.0", @@ -23,49 +23,6 @@ "node": ">=20" } }, - "node_modules/@apm-js-collab/code-transformer": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", - "integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==", - "license": "Apache-2.0", - "dependencies": { - "@types/estree": "^1.0.8", - "astring": "^1.9.0", - "esquery": "^1.7.0", - "meriyah": "^6.1.4", - "semifies": "^1.0.0", - "source-map": "^0.6.0" - }, - "bin": { - "code-transformer": "cli.js" - } - }, - "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", - "integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==", - "license": "MIT", - "dependencies": { - "@apm-js-collab/code-transformer": "^0.15.0", - "es-module-lexer": "^2.1.0", - "magic-string": "^0.30.21", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@apm-js-collab/tracing-hooks": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz", - "integrity": "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==", - "license": "Apache-2.0", - "dependencies": { - "@apm-js-collab/code-transformer": "^0.15.0", - "debug": "^4.4.1", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -130,6 +87,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -143,101 +101,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", - "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", - "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", - "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", - "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -249,287 +112,62 @@ "node": ">=14" } }, - "node_modules/@sentry/cli": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.6.0.tgz", - "integrity": "sha512-79XC8o59G/i3VqmnoQD74a/QD/422eQQlUqiPd3sEvcYCxnaZialRVAsxuNEFd6sx4aVGpqt775MMDT9cV/lMg==", + "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, - "license": "FSL-1.1-MIT", + "hasShrinkwrap": true, + "license": "MIT", "dependencies": { - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "undici": "^6.22.0", - "which": "^2.0.2" + "detect-libc": "^2.1.2" }, "bin": { - "sentry-cli": "bin/sentry-cli" + "posthog-cli": "run-posthog-cli.js" }, "engines": { - "node": ">= 18" - }, - "optionalDependencies": { - "@sentry/cli-darwin": "3.6.0", - "@sentry/cli-linux-arm": "3.6.0", - "@sentry/cli-linux-arm64": "3.6.0", - "@sentry/cli-linux-i686": "3.6.0", - "@sentry/cli-linux-x64": "3.6.0", - "@sentry/cli-win32-arm64": "3.6.0", - "@sentry/cli-win32-i686": "3.6.0", - "@sentry/cli-win32-x64": "3.6.0" - } - }, - "node_modules/@sentry/cli-darwin": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.6.0.tgz", - "integrity": "sha512-C2SWHKaEP8NoYkLDr5jwrzklTwlJkzPIx7lu2LZrwBuHG/sNhWdili0ED2mD86b6Q90hlcMtuBm5IHUwcZ6FTQ==", - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-linux-arm": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.6.0.tgz", - "integrity": "sha512-S9xsDZTvybOGbrqqZ7DvF7JCNKp4cakDWJ4LdvQX+z82cHQSoLkYOXkA3EafDfWV9BGIRMIXitMMiSsV2PMU4g==", - "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.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.6.0.tgz", - "integrity": "sha512-Rc+DB8vuTDpwqY2BxIPnooYk2ZDYQytF+n4nfi6pZZsJtr3SFFe+3wIWVmCVqxiHkaISb33+iJIDxOOqhkSNbQ==", - "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.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.6.0.tgz", - "integrity": "sha512-PQ7+ctNmWtHgmbKa+rJheHU7D9GHJXafgWYfVW6gt7R0Ag9LxiDVYLGjrL4G/i7AGFNgudXFaLkGKNX7HUjc+g==", - "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.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.6.0.tgz", - "integrity": "sha512-c+7xNg5BAaPE8N2Q6pg3Q/kt97JSaskuQIjRxHaFuDbCkJvww4VozY6mW5NUMJaW48rEs3mahWKamTLqJsO3wQ==", - "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.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.6.0.tgz", - "integrity": "sha512-zhZ7YyGreHSKZ92Mwb9h4cEyL0I/eND7W6XIUXUW0BCCmxFOMc71vlQpUw8gijHIsFDbv8c8a6VOSkeRuwbSwQ==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-win32-i686": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.6.0.tgz", - "integrity": "sha512-JaxzVDdyetrPBp8NHh2yNAYdDk79ROXqfAfjQwG5z6V764MMMrf2WrhQ7EwoKXOPtBLm/drbOcYgaxHuDZKGRw==", - "cpu": [ - "x86", - "ia32" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/cli-win32-x64": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.6.0.tgz", - "integrity": "sha512-LW0078VlxaUeVMMLA15A1zhkvZ5vby/lwthtBXPoBSDdTcgbTE4D4gQXM9vEapV28SvCO3fVIek3pbtrkaeDMw==", - "cpu": [ - "x64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/conventions": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", - "integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@sentry/core": { - "version": "10.63.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.63.0.tgz", - "integrity": "sha512-OtUbsrnbEHffOF2S2+M5zXa3HIM0U2b4CDVLKMY1dgS0J3ivRF8XvkjvyIcEG/y8JXnwXbnprLyjhG+AqMdUZQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node": { - "version": "10.63.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.63.0.tgz", - "integrity": "sha512-E+JfDTdUDGQPRsAfCTR2YgmQgxYdoxk4ks6niHN+ByW8alEZL+nXlcN9vI57qj1LsS4v2jjfLxJf1/cMMt84YA==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.63.0", - "@sentry/node-core": "10.63.0", - "@sentry/opentelemetry": "10.63.0", - "@sentry/server-utils": "10.63.0", - "import-in-the-middle": "^3.0.0" - }, - "engines": { - "node": ">=18" + "node": ">=14.14", + "npm": ">=6" } }, - "node_modules/@sentry/node-core": { - "version": "10.63.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.63.0.tgz", - "integrity": "sha512-TaNtkGDRNxH3SjOea2PDtaebkNjMbAH8ZFsEcwlqmadpS7nqSR7z6slZy/iu7y1nLiUdbmcM5JmXwxksy52WRQ==", - "license": "MIT", - "dependencies": { - "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.63.0", - "@sentry/opentelemetry": "10.63.0", - "import-in-the-middle": "^3.0.0" - }, + "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": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-http": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - } + "node": ">=8" } }, - "node_modules/@sentry/opentelemetry": { - "version": "10.63.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.63.0.tgz", - "integrity": "sha512-8yqi8+Ej/anmMn82blXA0BNMeAMs4av6nx0DzhxDrFya28ZaYOn19PChd3erMidfU0HnLLFNqWiFlYxBKq+/KA==", + "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", - "dependencies": { - "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.63.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=18" + "node": ">=14" }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/@sentry/server-utils": { - "version": "10.63.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.63.0.tgz", - "integrity": "sha512-7NN//DG9Yak8t2+6WiEcNmN269iHRVdtZtZIwucEd0OXyZ3FEBBDaBF+bT9V6H/kPtUvVMkHQ72Bn2Xs5JYGxg==", + "node_modules/@posthog/core": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.45.1.tgz", + "integrity": "sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==", "license": "MIT", "dependencies": { - "@apm-js-collab/code-transformer": "^0.15.0", - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", - "@apm-js-collab/tracing-hooks": "^0.10.0", - "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.63.0", - "magic-string": "~0.30.0" - }, - "engines": { - "node": ">=18" + "@posthog/types": "^1.398.0" } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@posthog/types": { + "version": "1.398.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.398.0.tgz", + "integrity": "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==", "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -549,27 +187,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -596,15 +213,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/astring": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", - "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", - "license": "MIT", - "bin": { - "astring": "bin/astring" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -662,12 +270,6 @@ } } }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -804,23 +406,6 @@ "node": ">= 8" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -835,12 +420,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", - "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", - "license": "MIT" - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -851,27 +430,6 @@ "node": ">=6" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -997,21 +555,6 @@ "dev": true, "license": "MIT" }, - "node_modules/import-in-the-middle": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", - "integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1026,6 +569,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -1106,15 +650,6 @@ "dev": true, "license": "ISC" }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -1131,15 +666,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/meriyah": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", - "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", - "license": "ISC", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -1166,18 +692,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -1254,6 +768,26 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/posthog-node": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.46.1.tgz", + "integrity": "sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.45.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/prettier": { "version": "3.9.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", @@ -1270,21 +804,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1295,25 +814,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } - }, - "node_modules/semifies": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", - "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", - "license": "Apache-2.0" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1363,15 +863,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -1518,15 +1009,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1553,6 +1035,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/review-enrichment/package.json b/review-enrichment/package.json index db58815414..07c884be77 100644 --- a/review-enrichment/package.json +++ b/review-enrichment/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "LoopOver review-enrichment service (REES) — heavy/external/historical PR analysis returned as a structured brief the review engine splices into the prompt. Deploys standalone on Railway; see #1473.", + "description": "LoopOver review-enrichment service (REES) — heavy/external/historical PR analysis returned as a structured brief the review engine splices into the prompt. Deploys standalone, operator-hosted; see #1473.", "engines": { "node": ">=20" }, @@ -19,9 +19,9 @@ }, "dependencies": { "@hono/node-server": "^2.0.0", - "@sentry/cli": "^3.6.0", - "@sentry/node": "^10.63.0", - "hono": "^4.12.27" + "@posthog/cli": "0.9.1", + "hono": "^4.12.27", + "posthog-node": "^5.44.0" }, "devDependencies": { "@types/node": "^22.20.0", diff --git a/review-enrichment/scripts/validate-posthog-release.mjs b/review-enrichment/scripts/validate-posthog-release.mjs new file mode 100644 index 0000000000..9d2a0deb07 --- /dev/null +++ b/review-enrichment/scripts/validate-posthog-release.mjs @@ -0,0 +1,122 @@ +// PostHog release/sourcemap-upload verification (#8290), replacing the old 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: that at least one symbol set exists for our release, and none +// of them recorded a failure_reason. Mirrors packages/discovery-index/scripts/validate-posthog-release.mjs. +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("rees_posthog_release_validation_complete", result); + } catch (error) { + const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; + logError("rees_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/review-enrichment/scripts/validate-sentry-release.mjs b/review-enrichment/scripts/validate-sentry-release.mjs deleted file mode 100644 index dad650902f..0000000000 --- a/review-enrichment/scripts/validate-sentry-release.mjs +++ /dev/null @@ -1,294 +0,0 @@ -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) ?? - nonBlank(env.RAILWAY_GIT_COMMIT_SHA), - expectedDeployName: nonBlank(env.SENTRY_DEPLOY_NAME) ?? nonBlank(env.RAILWAY_DEPLOYMENT_ID), - expectedEnvironment: - nonBlank(env.SENTRY_ENVIRONMENT) ?? - nonBlank(env.RAILWAY_ENVIRONMENT_NAME) ?? - "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 = []; - // Gated on requireCommits ALONE (not `|| config.expectedCommitSha`): upload-sourcemaps.ts always passes - // SENTRY_COMMIT_SHA (the deploy's actual git SHA, not itself a strictness signal), so expectedCommitSha is - // essentially always set -- fetching here whenever it was merely present, independent of requireCommits, meant - // a non-strict deploy still depended on the /commits/ endpoint being reachable (sentryJson throws on any non-OK - // response) even though the checks that consume the result are now all requireCommits-gated below. Skipping the - // fetch entirely in non-strict mode is the only way "non-strict" actually means "commits don't matter." - 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("sentry_release_validation_complete", result); - } catch (error) { - const failures = Array.isArray(error?.failures) ? error.failures : [String(error)]; - logError("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/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 0fe9c9e348..3b9e21ec9e 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -34,7 +34,7 @@ import { shouldStartAnalyzer, type AnalyzerPlanItem, } from "./scheduler.js"; -import { captureAnalyzerDegradation } from "./sentry.js"; +import { captureAnalyzerDegradationPostHog } from "./posthog.js"; const DEFAULT_ANALYZER_TIMEOUT_MS = 8000; const MIN_ANALYZER_TIMEOUT_MS = 1; @@ -164,7 +164,7 @@ function captureDegradation( options: BuildBriefOptions; }, ): void { - captureAnalyzerDegradation(error, { + captureAnalyzerDegradationPostHog(error, { analyzer: input.analyzer, requestedAnalyzers: input.requested, repoFullName: input.req.repoFullName, diff --git a/review-enrichment/src/posthog.ts b/review-enrichment/src/posthog.ts new file mode 100644 index 0000000000..aeacf0a23f --- /dev/null +++ b/review-enrichment/src/posthog.ts @@ -0,0 +1,256 @@ +// PostHog error tracking for review-enrichment (#8290, epic #8286). REPLACES Sentry entirely -- per the +// epic's revised strategy (2026-07-25 correction on #8286), PostHog is a straight swap-in, not a parallel +// sink; there is no more sentry.ts in this package. Kept structurally in lockstep with +// packages/discovery-index/src/posthog.ts's shape (redaction primitives, captureException(error, distinctId, +// properties)-based capture, fingerprint/tag structure) since the two services' Sentry modules used to mirror +// each other and there's no reason for their replacements not to. The one REES-specific addition, +// captureAnalyzerDegradationPostHog, mirrors the old captureAnalyzerDegradation's #5010 grouping choice +// (fingerprint by WHY -- partialReason -- not WHICH analyzer). +// +// No Railway-specific config: this repo no longer deploys anything on Railway. Release/environment are plain +// operator-set POSTHOG_RELEASE/POSTHOG_ENVIRONMENT with no platform-specific fallback derivation, matching +// how src/selfhost/posthog.ts's own release/environment resolution makes no deploy-platform assumptions. +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 every other PostHog sink in this repo's identical choice. */ +const DISTINCT_ID = "loopover-rees"; + +/** PostHog US-cloud ingestion host, matching every other PostHog sink in this repo's default. */ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +// Redaction rules preserved verbatim from the old sentry.ts (capture/redaction parity is an #8290 +// deliverable) -- kept as a literal copy rather than an import so this module has no unnecessary +// compile-time coupling to discovery-index's sibling module, matching that module's own independence choice. +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_]+|gts_[a-f0-9]{64}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g; +const REES_POSTHOG_TAG_KEYS = ["event", "route", "method", "repo", "pullNumber", "analyzer", "release", "environment", "deploymentId"] as const; + +type ReesPostHogTagKey = (typeof REES_POSTHOG_TAG_KEYS)[number]; +type ReesPostHogTags = Partial>; +type CaptureOptions = { + fingerprint: string[]; + tags: ReesPostHogTags; + /** Additional diagnostic properties outside the fixed tag allowlist (analyzer-degradation's rich + * diagnostics, sourcemap-upload's stage/sha) -- still scrubbed like everything else. */ + extra?: Record; +}; + +function nonBlank(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +export function resolveReesPostHogRelease(env: NodeJS.ProcessEnv): string | undefined { + return nonBlank(env.POSTHOG_RELEASE) ?? (nonBlank(env.POSTHOG_COMMIT_SHA) ? `loopover-rees@${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: ReesPostHogTags): Record { + const properties: Record = {}; + for (const key of REES_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 initReesPostHog(env: NodeJS.ProcessEnv): Promise { + const apiKey = nonBlank(env.POSTHOG_API_KEY); + if (!apiKey) return false; + try { + const { PostHog } = await import("posthog-node"); + activeRelease = resolveReesPostHogRelease(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("rees_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: ["rees-route-error", context.route, context.method], + tags: { event: "rees_route_error", route: context.route, method: context.method }, + }); +} + +export function captureUnhandledPostHogError(error: unknown, context: { event: "rees_unhandled_rejection" | "rees_uncaught_exception" }): void { + captureScopedError(error, { + fingerprint: ["rees-process-error", context.event], + tags: { event: context.event }, + }); +} + +export function captureSourcemapUploadPostHogFailure( + error: unknown, + context: { release?: string; deploymentId?: string; strict?: boolean; sha?: string; stage?: string }, +): void { + captureScopedError(error, { + fingerprint: ["rees-sourcemap-upload-failed"], + tags: { event: "rees_sourcemap_upload_failed", release: context.release ?? activeRelease, deploymentId: context.deploymentId }, + extra: { strict: context.strict, sha: context.sha, stage: context.stage }, + }); +} + +export interface AnalyzerDegradationContext { + analyzer: string; + requestedAnalyzers?: string[]; + repoFullName: string; + prNumber: number; + headSha?: string; + timeoutMs?: number; + elapsedMs?: number; + analyzerStatus?: string; + profile?: string; + costClass?: string; + responseReserveMs?: number; + partialStatus?: string; + partialReason?: string; + phase?: string; + subcall?: string; + endpointCategory?: string; + externalFailureReason?: string; + externalElapsedMs?: number; + fileLookupCount?: number; + commitLookupCount?: number; + prLookupCount?: number; + skippedFileCount?: number; + githubEndpointCategory?: string; + capped?: boolean; + cacheHits?: number; + cacheMisses?: number; + externalCallsByCategory?: Record; + skippedWorkByCategory?: Record; + cappedWorkByCategory?: Record; + analysisElapsedMs?: number; + requestId?: string; + traceId?: string; +} + +/** Mirrors sentry.ts's captureAnalyzerDegradation exactly, including its #5010 grouping choice: fingerprint by + * WHY (partialReason) rather than WHICH analyzer, since the generic reasons share one root cause (the shared, + * dynamically-shrinking per-analyzer time budget) regardless of which analyzer's turn it was. */ +export function captureAnalyzerDegradationPostHog(error: unknown, context: AnalyzerDegradationContext): void { + const headShaPrefix = nonBlank(context.headSha)?.slice(0, 12); + captureScopedError(error, { + fingerprint: ["rees-analyzer-degraded", context.partialReason ?? context.analyzer], + tags: { event: "rees_analyzer_degraded", analyzer: context.analyzer, repo: context.repoFullName, pullNumber: context.prNumber }, + extra: { + requestedAnalyzers: context.requestedAnalyzers, + headShaPrefix, + timeoutMs: context.timeoutMs, + elapsedMs: context.elapsedMs, + analyzerStatus: context.analyzerStatus, + profile: context.profile, + costClass: context.costClass, + responseReserveMs: context.responseReserveMs, + partialStatus: context.partialStatus, + partialReason: context.partialReason, + phase: context.phase, + subcall: context.subcall, + endpointCategory: context.endpointCategory, + externalFailureReason: context.externalFailureReason, + externalElapsedMs: context.externalElapsedMs, + fileLookupCount: context.fileLookupCount, + commitLookupCount: context.commitLookupCount, + prLookupCount: context.prLookupCount, + skippedFileCount: context.skippedFileCount, + githubEndpointCategory: context.githubEndpointCategory, + capped: context.capped, + cacheHits: context.cacheHits, + cacheMisses: context.cacheMisses, + externalCallsByCategory: context.externalCallsByCategory, + skippedWorkByCategory: context.skippedWorkByCategory, + cappedWorkByCategory: context.cappedWorkByCategory, + analysisElapsedMs: context.analysisElapsedMs, + requestId: context.requestId, + traceId: context.traceId, + }, + }); +} + +export async function flushReesPostHog(): Promise { + if (!active || !client) return; + await client.flush().catch(() => undefined); +} + +export async function shutdownReesPostHog(): Promise { + if (!active || !client) return; + await client.shutdown().catch(() => undefined); +} + +export function resetReesPostHogForTest(): void { + client = undefined; + active = false; + activeRelease = undefined; + activeEnvironment = "production"; +} + +export function setReesPostHogForTest(posthog: PostHogClient, options: { release?: string; environment?: string } = {}): void { + client = posthog; + active = true; + activeRelease = options.release; + activeEnvironment = options.environment ?? "production"; +} diff --git a/review-enrichment/src/sentry.ts b/review-enrichment/src/sentry.ts deleted file mode 100644 index ffe5e5e040..0000000000 --- a/review-enrichment/src/sentry.ts +++ /dev/null @@ -1,336 +0,0 @@ -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"; - -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_]+|gts_[a-f0-9]{64}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g; -const REES_SENTRY_TAG_KEYS = [ - "event", - "route", - "method", - "repo", - "pullNumber", - "analyzer", - "release", - "environment", - "railwayDeploymentId", -] as const; - -type ReesSentryTagKey = (typeof REES_SENTRY_TAG_KEYS)[number]; -type ReesSentryTags = Partial>; -type ReesCaptureOptions = { - contextName: string; - context: Record; - fingerprint: string[]; - level?: "error" | "warning"; - tags: ReesSentryTags; -}; - -function nonBlank(value: string | undefined): string | undefined { - const text = value?.trim(); - return text ? text : undefined; -} - -export function resolveReesSentryRelease(env: NodeJS.ProcessEnv): string | undefined { - return ( - nonBlank(env.SENTRY_RELEASE) ?? - (nonBlank(env.RAILWAY_GIT_COMMIT_SHA) - ? `loopover-rees@${nonBlank(env.RAILWAY_GIT_COMMIT_SHA)}` - : undefined) - ); -} - -export function resolveSentryEnvironment(env: NodeJS.ProcessEnv): string { - return nonBlank(env.SENTRY_ENVIRONMENT) ?? nonBlank(env.RAILWAY_ENVIRONMENT_NAME) ?? "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)); - 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: ReesSentryTags): void { - for (const key of REES_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: ReesCaptureOptions): 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, - event: options.tags.event, - 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 = resolveReesSentryRelease(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("rees_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: "rees_route", - context: { - event: "rees_route_error", - route: context.route, - method: context.method, - release: activeRelease, - environment: activeEnvironment, - }, - fingerprint: ["rees-route-error", context.route, context.method], - tags: { - event: "rees_route_error", - route: context.route, - method: context.method, - }, - }); -} - -export function captureUnhandledError( - error: unknown, - context: { event: "rees_unhandled_rejection" | "rees_uncaught_exception" }, -): void { - captureScopedError(error, { - contextName: "rees_process", - context: { - event: context.event, - release: activeRelease, - environment: activeEnvironment, - }, - fingerprint: ["rees-process-error", context.event], - tags: { - event: context.event, - }, - }); -} - -export function captureSourcemapUploadFailure( - error: unknown, - context: { - release?: string; - railwayDeploymentId?: string; - strict?: boolean; - sha?: string; - stage?: string; - }, -): void { - captureScopedError(error, { - contextName: "rees_sourcemap_upload", - context: { - event: "rees_sourcemap_upload_failed", - release: context.release ?? activeRelease, - railwayDeploymentId: context.railwayDeploymentId, - strict: context.strict, - sha: context.sha, - stage: context.stage, - environment: activeEnvironment, - }, - fingerprint: ["rees-sourcemap-upload-failed"], - tags: { - event: "rees_sourcemap_upload_failed", - release: context.release ?? activeRelease, - railwayDeploymentId: context.railwayDeploymentId, - }, - }); -} - -export interface AnalyzerDegradationContext { - analyzer: string; - requestedAnalyzers?: string[]; - repoFullName: string; - prNumber: number; - headSha?: string; - timeoutMs?: number; - elapsedMs?: number; - analyzerStatus?: string; - profile?: string; - costClass?: string; - responseReserveMs?: number; - partialStatus?: string; - partialReason?: string; - phase?: string; - subcall?: string; - endpointCategory?: string; - externalFailureReason?: string; - externalElapsedMs?: number; - fileLookupCount?: number; - commitLookupCount?: number; - prLookupCount?: number; - skippedFileCount?: number; - githubEndpointCategory?: string; - capped?: boolean; - cacheHits?: number; - cacheMisses?: number; - externalCallsByCategory?: Record; - skippedWorkByCategory?: Record; - cappedWorkByCategory?: Record; - analysisElapsedMs?: number; - requestId?: string; - traceId?: string; -} - -export function captureAnalyzerDegradation(error: unknown, context: AnalyzerDegradationContext): void { - const headShaPrefix = nonBlank(context.headSha)?.slice(0, 12); - captureScopedError(error, { - contextName: "rees_analyzer", - context: { - event: "rees_analyzer_degraded", - analyzer: context.analyzer, - requestedAnalyzers: context.requestedAnalyzers, - repoFullName: context.repoFullName, - prNumber: context.prNumber, - headShaPrefix, - timeoutMs: context.timeoutMs, - elapsedMs: context.elapsedMs, - analyzerStatus: context.analyzerStatus, - profile: context.profile, - costClass: context.costClass, - responseReserveMs: context.responseReserveMs, - partialStatus: context.partialStatus, - partialReason: context.partialReason, - phase: context.phase, - subcall: context.subcall, - endpointCategory: context.endpointCategory, - externalFailureReason: context.externalFailureReason, - externalElapsedMs: context.externalElapsedMs, - fileLookupCount: context.fileLookupCount, - commitLookupCount: context.commitLookupCount, - prLookupCount: context.prLookupCount, - skippedFileCount: context.skippedFileCount, - githubEndpointCategory: context.githubEndpointCategory, - capped: context.capped, - cacheHits: context.cacheHits, - cacheMisses: context.cacheMisses, - externalCallsByCategory: context.externalCallsByCategory, - skippedWorkByCategory: context.skippedWorkByCategory, - cappedWorkByCategory: context.cappedWorkByCategory, - analysisElapsedMs: context.analysisElapsedMs, - requestId: context.requestId, - traceId: context.traceId, - release: activeRelease, - environment: activeEnvironment, - }, - // Group by WHY (partialReason, e.g. "analyzer_timeout"), not WHICH analyzer hit it (#5010): the generic - // reasons genuinely share one root cause (the shared, dynamically-shrinking per-analyzer time budget) - // regardless of which analyzer's turn it was, so grouping by analyzer name fragmented one condition into - // N issues (one per analyzer) that each individually looked small. A reason that IS inherently - // analyzer-specific (e.g. "bundlephobia-size_http_error") stays its own issue either way, since the - // reason string itself already encodes that specificity -- falls back to analyzer name only on the - // defensive case where partialReason is somehow absent. - fingerprint: ["rees-analyzer-degraded", context.partialReason ?? context.analyzer], - tags: { - event: "rees_analyzer_degraded", - analyzer: context.analyzer, - repo: context.repoFullName, - pullNumber: context.prNumber, - }, - }); -} - -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/review-enrichment/src/server.ts b/review-enrichment/src/server.ts index a8929636cc..7931a1e720 100644 --- a/review-enrichment/src/server.ts +++ b/review-enrichment/src/server.ts @@ -17,15 +17,16 @@ import { readEnrichRequestText, } from "./request-guardrails.js"; import { - captureRouteError, - captureUnhandledError, - flushSentry, - initSentry, - resolveSentryEnvironment, -} from "./sentry.js"; + captureRoutePostHogError, + captureUnhandledPostHogError, + flushReesPostHog, + initReesPostHog, + resolvePostHogEnvironment, + shutdownReesPostHog, +} from "./posthog.js"; const app = new Hono(); -const sentryEnabled = await initSentry(process.env); +const posthogEnabled = await initReesPostHog(process.env); const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/i; function traceIdFromTraceparent(value: string | undefined): string | undefined { @@ -33,13 +34,8 @@ function traceIdFromTraceparent(value: string | undefined): string | undefined { return match?.[1]?.toLowerCase(); } -if (sentryEnabled) { - console.log( - JSON.stringify({ - event: "rees_sentry", - environment: resolveSentryEnvironment(process.env), - }), - ); +if (posthogEnabled) { + console.log(JSON.stringify({ event: "rees_posthog", environment: resolvePostHogEnvironment(process.env) })); } app.get("/health", (c) => @@ -54,7 +50,7 @@ function recordEnrichOutcome(status: string, startedAtMs: number): void { } app.onError((error, c) => { - captureRouteError(error, { method: c.req.method, route: c.req.path }); + captureRoutePostHogError(error, { method: c.req.method, route: c.req.path }); return c.json({ error: "internal_error" }, 500); }); @@ -101,7 +97,7 @@ app.post("/v1/enrich", async (c) => { recordEnrichOutcome("ok", startedAtMs); return c.json(brief); } catch (error) { - // Rethrow to app.onError below, which still owns the 500 response + Sentry capture -- this catch exists + // Rethrow to app.onError below, which still owns the 500 response + PostHog capture -- this catch exists // only to record the outcome with the duration/startedAtMs this route handler has and onError doesn't. recordEnrichOutcome("error", startedAtMs); throw error; @@ -114,16 +110,16 @@ serve({ fetch: app.fetch, port }, (info) => { }); process.on("unhandledRejection", (reason) => { - captureUnhandledError(reason, { event: "rees_unhandled_rejection" }); + captureUnhandledPostHogError(reason, { event: "rees_unhandled_rejection" }); }); process.on("uncaughtException", (error) => { - captureUnhandledError(error, { event: "rees_uncaught_exception" }); - void flushSentry().finally(() => process.exit(1)); + captureUnhandledPostHogError(error, { event: "rees_uncaught_exception" }); + void flushReesPostHog().finally(() => process.exit(1)); }); process.on("SIGTERM", () => { - void flushSentry().finally(() => process.exit(0)); + void shutdownReesPostHog().finally(() => process.exit(0)); }); export { app }; diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index f2d4776ffe..1f1def5155 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -799,7 +799,7 @@ export interface DuplicationDeltaFinding { export type AnalyzerStatus = "ok" | "degraded" | "skipped" | "capped" | "timeout"; -/** Internal, public-safe analyzer diagnostics for Sentry. Never attach request bodies, diffs, tokens, or raw prompts. */ +/** Internal, public-safe analyzer diagnostics for error-tracking capture (PostHog). Never attach request bodies, diffs, tokens, or raw prompts. */ export interface AnalyzerDiagnostics { phase?: string; subcall?: string; diff --git a/review-enrichment/src/upload-sourcemaps.ts b/review-enrichment/src/upload-sourcemaps.ts index ce043ee8ed..19ec720ff2 100644 --- a/review-enrichment/src/upload-sourcemaps.ts +++ b/review-enrichment/src/upload-sourcemaps.ts @@ -1,3 +1,12 @@ +// Uploads this build's source maps to PostHog at container startup, then deletes them before the real server +// starts (see the Dockerfile/entrypoint's runtime CMD) -- mirrors packages/discovery-index/src/upload-sourcemaps.ts's +// PostHog leg. REPLACES the old Sentry-based pipeline entirely (#8290, epic #8286's revised strategy, +// 2026-07-25 correction on #8286): no more @sentry/cli, no more Railway-specific env vars (this repo no +// longer deploys anything on Railway). +// +// Running this at process startup rather than at build time is deliberate: POSTHOG_CLI_API_KEY is a real +// secret, injected however this service's operator provisions runtime env vars -- it is never a 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 { dirname, relative, resolve } from "node:path"; @@ -5,17 +14,11 @@ import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { - captureSourcemapUploadFailure, - flushSentry, - initSentry, - resolveReesSentryRelease, - resolveSentryEnvironment, -} from "./sentry.js"; - -type RunOptions = { - allowExistingRelease?: boolean; - allowFailure?: boolean; -}; + captureSourcemapUploadPostHogFailure, + flushReesPostHog, + initReesPostHog, + resolveReesPostHogRelease, +} from "./posthog.js"; const distDir = dirname(fileURLToPath(import.meta.url)); const appDir = resolve(distDir, ".."); @@ -78,35 +81,8 @@ function validateSourceMaps(): void { if (!sawServerSource) throw new Error("source maps do not include src/server.ts"); } -function sentryCliPath(): string { - return nonBlank(process.env.SENTRY_CLI_PATH) ?? resolve(appDir, "node_modules/.bin/sentry-cli"); -} - -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("rees_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("rees_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.REES_SENTRY_VALIDATE_RELEASE ?? ""); + return !/^(0|false|no|off)$/i.test(process.env.REES_POSTHOG_VALIDATE_RELEASE ?? ""); } function numericEnv(name: string, fallback: number, max: number): number { @@ -114,129 +90,85 @@ 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; deployName: string; environment: string; strict: boolean }, -): Promise { +async function runReleaseValidation(release: string): Promise { if (!shouldValidateRelease()) return; - const attempts = Math.max(1, numericEnv("REES_SENTRY_VALIDATE_ATTEMPTS", 5, 20)); - const retryDelayMs = numericEnv("REES_SENTRY_VALIDATE_RETRY_DELAY_MS", 1_000, 30_000); + const attempts = Math.max(1, numericEnv("REES_POSTHOG_VALIDATE_ATTEMPTS", 5, 20)); + const retryDelayMs = numericEnv("REES_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("rees_sentry_release_validation", { output: output.slice(0, 500), attempt }); + if (output) log("rees_posthog_release_validation", { output: output.slice(0, 500), attempt }); return; } if (attempt < attempts) { - warn("rees_sentry_release_validation_retry", { - attempt, - attempts, - retryDelayMs, - message: output.slice(0, 500), - }); + warn("rees_posthog_release_validation_retry", { attempt, attempts, retryDelayMs, message: output.slice(0, 500) }); if (retryDelayMs > 0) await sleep(retryDelayMs); } } - throw new Error(`Sentry release validation failed (${status}): ${output.slice(0, 500)}`); + throw new Error(`PostHog release validation failed (${status}): ${output.slice(0, 500)}`); +} + +// review-enrichment is a standalone, non-workspace package, so node_modules/.bin/posthog-cli is genuinely +// the right (non-hoisted) location -- unlike discovery-index's require.resolve-based approach, which exists +// specifically because THAT package IS an npm workspace member with hoisting to worry about. +function postHogCliPath(): string { + return nonBlank(process.env.POSTHOG_CLI_PATH) ?? resolve(appDir, "node_modules/.bin/posthog-cli"); +} + +function runPostHog(args: string[]): void { + 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("rees_posthog_cli", { command: args.slice(0, 2).join(" "), output: output.slice(0, 300) }); + return; + } + throw new Error(`posthog-cli ${args.join(" ")} failed (${result.status}): ${output.slice(0, 500)}`); } async function main(): Promise { - await initSentry(process.env).catch(() => false); - const release = resolveReesSentryRelease(process.env); + await initReesPostHog(process.env).catch(() => false); + const release = resolveReesPostHogRelease(process.env); const required = { - SENTRY_AUTH_TOKEN: nonBlank(process.env.SENTRY_AUTH_TOKEN), - SENTRY_ORG: nonBlank(process.env.SENTRY_ORG), - SENTRY_PROJECT: nonBlank(process.env.SENTRY_PROJECT), - SENTRY_RELEASE: release, + POSTHOG_CLI_API_KEY: nonBlank(process.env.POSTHOG_CLI_API_KEY), + POSTHOG_CLI_PROJECT_ID: nonBlank(process.env.POSTHOG_CLI_PROJECT_ID), + POSTHOG_RELEASE: release, }; const missing = Object.entries(required) .filter(([, value]) => !value) .map(([key]) => key); if (missing.length > 0) { - log("rees_sentry_sourcemap_upload_skipped", { reason: "missing_config", missing }); + log("rees_posthog_sourcemap_upload_skipped", { reason: "missing_config", missing }); return 0; } - const strict = /^(1|true|yes|on)$/i.test(process.env.REES_SENTRY_UPLOAD_STRICT ?? ""); + const strict = /^(1|true|yes|on)$/i.test(process.env.REES_POSTHOG_UPLOAD_STRICT ?? ""); try { validateSourceMaps(); - const projectArgs = ["--org", required.SENTRY_ORG!, "--project", required.SENTRY_PROJECT!]; - runSentry(["releases", ...projectArgs, "new", release!], { allowExistingRelease: true }); - - const sha = nonBlank(process.env.SENTRY_COMMIT_SHA) ?? nonBlank(process.env.RAILWAY_GIT_COMMIT_SHA); - if (sha) { - const repo = nonBlank(process.env.SENTRY_REPOSITORY) ?? "JSONbored/loopover"; - const previous = nonBlank(process.env.SENTRY_PREVIOUS_COMMIT_SHA); - const spec = previous ? `${repo}@${previous}..${sha}` : `${repo}@${sha}`; - runSentry(["releases", ...projectArgs, "set-commits", release!, "--commit", spec, "--ignore-missing"], { - allowFailure: !strict, - }); - } - - runSentry(["sourcemaps", ...projectArgs, "inject", "dist"]); + // No separate "create release" step (PostHog release metadata is a byproduct of inject/upload) and an + // explicit --release-version rather than posthog-cli's own git-metadata auto-detection -- the deploy + // environment isn't guaranteed to have a usable .git checkout at this step. + runPostHog(["sourcemap", "inject", "--directory", "dist", "--release-version", release!]); validateSourceMaps(); - runSentry([ - "sourcemaps", - ...projectArgs, - "upload", - "--release", - release!, - "--validate", - "--wait", - ...(strict ? ["--strict"] : []), - "dist", - ]); - runSentry([ - "releases", - ...projectArgs, - "deploys", - "new", - "--release", - release!, - "--env", - resolveSentryEnvironment(process.env), - "--name", - nonBlank(process.env.RAILWAY_DEPLOYMENT_ID) ?? "railway", - ]); - runSentry(["releases", ...projectArgs, "finalize", release!]); - await runReleaseValidation(release!, { - sha, - deployName: nonBlank(process.env.RAILWAY_DEPLOYMENT_ID) ?? "railway", - environment: resolveSentryEnvironment(process.env), - strict, - }); - log("rees_sentry_sourcemap_upload_complete", { release }); + runPostHog(["sourcemap", "upload", "--directory", "dist", "--release-version", release!]); + await runReleaseValidation(release!); + log("rees_posthog_sourcemap_upload_complete", { release }); return 0; } catch (error) { - captureSourcemapUploadFailure(error, { - release, - railwayDeploymentId: nonBlank(process.env.RAILWAY_DEPLOYMENT_ID), - strict, - sha: nonBlank(process.env.SENTRY_COMMIT_SHA) ?? nonBlank(process.env.RAILWAY_GIT_COMMIT_SHA), - }); - await flushSentry(); - warn("rees_sentry_sourcemap_upload_failed", { + captureSourcemapUploadPostHogFailure(error, { release, - message: error instanceof Error ? error.message : String(error), strict, + sha: nonBlank(process.env.POSTHOG_COMMIT_SHA), }); + await flushReesPostHog(); + warn("rees_posthog_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); return strict ? 1 : 0; } } diff --git a/review-enrichment/test/posthog-degradation.test.ts b/review-enrichment/test/posthog-degradation.test.ts new file mode 100644 index 0000000000..ab8600a256 --- /dev/null +++ b/review-enrichment/test/posthog-degradation.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; + +import { buildBrief } from "../dist/brief.js"; +import { + captureAnalyzerDegradationPostHog, + captureRoutePostHogError, + captureSourcemapUploadPostHogFailure, + captureUnhandledPostHogError, + resetReesPostHogForTest, + setReesPostHogForTest, +} from "../dist/posthog.js"; + +function postHogHarness() { + const captured: Array<{ error: Error; distinctId: string; properties: Record }> = []; + setReesPostHogForTest( + { + captureException: (error: unknown, distinctId: string, properties: Record) => { + captured.push({ error: error instanceof Error ? error : new Error(String(error)), distinctId, properties }); + }, + flush: async () => undefined, + shutdown: async () => undefined, + }, + { release: "loopover-rees@test", environment: "test" }, + ); + return { captured }; +} + +afterEach(() => { + resetReesPostHogForTest(); +}); + +test("captureAnalyzerDegradationPostHog is inert when PostHog is disabled", () => { + assert.doesNotThrow(() => + captureAnalyzerDegradationPostHog(new Error("boom"), { + analyzer: "dependency", + repoFullName: "JSONbored/loopover", + prNumber: 7, + headSha: "abc123", + timeoutMs: 8000, + }), + ); +}); + +test("captureAnalyzerDegradationPostHog tags and fingerprints sanitized analyzer failures", () => { + const posthog = postHogHarness(); + const fakeGithubPat = ["github", "pat", "should_never_be_attached"].join("_"); + const fakeGhp = ["ghp", "should_never_be_attached"].join("_"); + + captureAnalyzerDegradationPostHog(new Error("registry timeout"), { + analyzer: "dependency", + repoFullName: "JSONbored/loopover", + prNumber: 7, + headSha: "abc123", + timeoutMs: 8000, + diff: fakeGithubPat, + githubToken: fakeGhp, + authorization: "Bearer should_never_be_attached", + } as never); + + const [capture] = posthog.captured; + assert.equal(capture.error.message, "registry timeout"); + assert.equal(capture.distinctId, "loopover-rees"); + assert.equal(capture.properties.event, "rees_analyzer_degraded"); + assert.equal(capture.properties.analyzer, "dependency"); + assert.equal(capture.properties.repo, "JSONbored/loopover"); + assert.equal(capture.properties.pullNumber, "7"); + assert.equal(capture.properties.release, "loopover-rees@test"); + assert.equal(capture.properties.environment, "test"); + assert.equal(capture.properties.$exception_fingerprint, "rees-analyzer-degraded|dependency"); + + const serialized = JSON.stringify(capture.properties); + assert.equal(serialized.includes(fakeGithubPat), false); + assert.equal(serialized.includes(fakeGhp), false); + assert.equal(serialized.includes("Bearer should_never_be_attached"), false); + assert.equal(serialized.includes("diff"), false); + assert.equal(serialized.includes("githubToken"), false); +}); + +test("captureAnalyzerDegradationPostHog groups by partialReason (WHY), not analyzer name (WHICH), matching sentry's old #5010 fix", () => { + const posthog = postHogHarness(); + + captureAnalyzerDegradationPostHog(new Error("analyzer_timeout"), { + analyzer: "installScript", + repoFullName: "JSONbored/loopover", + prNumber: 7, + headSha: "abc123", + timeoutMs: 1400, + partialReason: "analyzer_timeout", + } as never); + captureAnalyzerDegradationPostHog(new Error("analyzer_timeout"), { + analyzer: "nativeBuild", + repoFullName: "JSONbored/loopover", + prNumber: 8, + headSha: "def456", + timeoutMs: 1400, + partialReason: "analyzer_timeout", + } as never); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-analyzer-degraded|analyzer_timeout"); + assert.equal(posthog.captured[1].properties.$exception_fingerprint, "rees-analyzer-degraded|analyzer_timeout"); + // The specific analyzer is still fully visible via the tag -- only the GROUPING changed. + assert.equal(posthog.captured[1].properties.analyzer, "nativeBuild"); +}); + +test("captureAnalyzerDegradationPostHog falls back to analyzer name when partialReason is absent", () => { + const posthog = postHogHarness(); + + captureAnalyzerDegradationPostHog(new Error("boom"), { + analyzer: "dependency", + repoFullName: "JSONbored/loopover", + prNumber: 7, + headSha: "abc123", + timeoutMs: 8000, + }); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-analyzer-degraded|dependency"); +}); + +test("captureAnalyzerDegradationPostHog filters tag values before sending them", () => { + const posthog = postHogHarness(); + const secretLikeValue = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + + captureAnalyzerDegradationPostHog(new Error("registry timeout"), { + analyzer: secretLikeValue, + repoFullName: `JSONbored/${secretLikeValue}`, + prNumber: 7, + headSha: secretLikeValue, + timeoutMs: 8000, + }); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-analyzer-degraded|[Filtered]"); + assert.equal(posthog.captured[0].properties.analyzer, "[Filtered]"); + assert.equal(posthog.captured[0].properties.repo, "JSONbored/[Filtered]"); +}); + +test("buildBrief stays fail-open and captures a degraded analyzer", async () => { + const posthog = postHogHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + + const brief = await buildBrief( + { + repoFullName: "JSONbored/loopover", + prNumber: 42, + headSha: "head-sha", + analyzers: ["dependency"], + files: [{ path: "package.json", patch: '+ "lodash": "4.17.20",' }], + budget: { timeoutMs: 2000 }, + }, + { + dependency: async () => { + throw new Error(`osv unavailable for ${fakeToken}`); + }, + }, + ); + + assert.equal(brief.partial, true); + assert.equal(brief.analyzerStatus.dependency, "degraded"); + assert.deepEqual(brief.findings, {}); + assert.equal(JSON.stringify(brief.telemetry).includes(fakeToken), false); + assert.equal(posthog.captured.length, 1); + assert.equal(posthog.captured[0].error.message, "analyzer_error"); + assert.equal(posthog.captured[0].properties.analyzer, "dependency"); + assert.equal(posthog.captured[0].properties.repo, "JSONbored/loopover"); + assert.equal(posthog.captured[0].properties.pullNumber, "42"); + assert.equal(posthog.captured[0].properties.event, "rees_analyzer_degraded"); +}); + +test("captureRoutePostHogError applies the route-level fingerprint and allowlisted tags", () => { + const posthog = postHogHarness(); + + captureRoutePostHogError(new Error("boom"), { route: "/v1/enrich", method: "POST" }); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-route-error|/v1/enrich|POST"); + assert.equal(posthog.captured[0].properties.event, "rees_route_error"); + assert.equal(posthog.captured[0].properties.route, "/v1/enrich"); + assert.equal(posthog.captured[0].properties.method, "POST"); + assert.equal(posthog.captured[0].properties.release, "loopover-rees@test"); + assert.equal(posthog.captured[0].properties.environment, "test"); +}); + +test("captureUnhandledPostHogError fingerprints process-level failures by event class", () => { + const posthog = postHogHarness(); + + captureUnhandledPostHogError(new Error("kaboom"), { event: "rees_uncaught_exception" }); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-process-error|rees_uncaught_exception"); + assert.equal(posthog.captured[0].properties.event, "rees_uncaught_exception"); + assert.equal(posthog.captured[0].properties.release, "loopover-rees@test"); + assert.equal(posthog.captured[0].properties.environment, "test"); +}); + +test("captureUnhandledPostHogError covers the unhandled_rejection event branch too", () => { + const posthog = postHogHarness(); + captureUnhandledPostHogError(new Error("rejected"), { event: "rees_unhandled_rejection" }); + assert.equal(posthog.captured[0].properties.event, "rees_unhandled_rejection"); +}); + +test("captureSourcemapUploadPostHogFailure applies stable upload grouping and safe extra fields", () => { + const posthog = postHogHarness(); + + captureSourcemapUploadPostHogFailure(new Error("upload failed"), { + release: "loopover-rees@test", + deploymentId: "deploy-123", + strict: true, + sha: "abcdef1234567890", + stage: "upload", + }); + + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-sourcemap-upload-failed"); + assert.equal(posthog.captured[0].properties.event, "rees_sourcemap_upload_failed"); + assert.equal(posthog.captured[0].properties.release, "loopover-rees@test"); + assert.equal(posthog.captured[0].properties.environment, "test"); + assert.equal(posthog.captured[0].properties.deploymentId, "deploy-123"); + assert.equal(posthog.captured[0].properties.strict, true); + assert.equal(posthog.captured[0].properties.sha, "abcdef1234567890"); + assert.equal(posthog.captured[0].properties.stage, "upload"); +}); + +test("captureSourcemapUploadPostHogFailure falls back to the active release when no explicit release is given", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("upload failed"), {}); + assert.equal(posthog.captured[0].properties.release, "loopover-rees@test"); + assert.equal(posthog.captured[0].properties.deploymentId, undefined); +}); + +test("secret scrubbing: redacts a nested-object extra field by KEY name regardless of its value", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("boom"), { sha: { authorization: "innocuous-looking-value" } } as never); + assert.deepEqual(posthog.captured[0].properties.sha, { authorization: "[Filtered]" }); +}); + +test("secret scrubbing: redacts secret-named keys inside a nested array value", () => { + const posthog = postHogHarness(); + captureSourcemapUploadPostHogFailure(new Error("boom"), { sha: [{ token: "should-be-filtered" }, "plain-string"] } as never); + assert.deepEqual(posthog.captured[0].properties.sha, [{ token: "[Filtered]" }, "plain-string"]); +}); + +test("secret scrubbing: redacts a GitHub-token-shaped VALUE (not just key name)", () => { + const posthog = postHogHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadPostHogFailure(new Error(`upload failed for ${fakeToken}`), { sha: fakeToken }); + assert.equal(JSON.stringify(posthog.captured[0].properties).includes(fakeToken), false); + assert.equal(posthog.captured[0].properties.sha, "[Filtered]"); +}); + +test("secret scrubbing: filters a secret-shaped tag value down to [Filtered]", () => { + const posthog = postHogHarness(); + const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); + captureSourcemapUploadPostHogFailure(new Error("boom"), { release: fakeToken }); + assert.equal(posthog.captured[0].properties.release, "[Filtered]"); +}); + +test("secret scrubbing: 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" }); + assert.equal(posthog.captured[0].properties.route, undefined); + assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-route-error|unknown|GET"); +}); diff --git a/review-enrichment/test/posthog-release-validation.test.ts b/review-enrichment/test/posthog-release-validation.test.ts new file mode 100644 index 0000000000..9569c76692 --- /dev/null +++ b/review-enrichment/test/posthog-release-validation.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + loadPostHogReleaseValidationConfig, + PostHogReleaseValidationError, + validatePostHogRelease, +} from "../scripts/validate-posthog-release.mjs"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function validationEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + ...overrides, + }; +} + +test("loadPostHogReleaseValidationConfig resolves exact release validation defaults", () => { + assert.deepEqual( + loadPostHogReleaseValidationConfig({ + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + }), + { + apiKey: "phx_test", + projectId: "42", + release: "loopover-rees@abc123", + baseUrl: "https://us.posthog.com", + }, + ); +}); + +test("loadPostHogReleaseValidationConfig uses POSTHOG_CLI_HOST when set and strips a trailing slash", () => { + const config = loadPostHogReleaseValidationConfig({ POSTHOG_CLI_HOST: "https://eu.posthog.com/" }); + assert.equal(config.baseUrl, "https://eu.posthog.com"); +}); + +test("validatePostHogRelease throws when fetch is unavailable", async () => { + await assert.rejects( + () => validatePostHogRelease(validationEnv(), null as never), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["Node 20+ fetch support is required"]); + return true; + }, + ); +}); + +test("validatePostHogRelease throws with all missing fields listed when config is incomplete", async () => { + await assert.rejects( + () => validatePostHogRelease({}, async () => response({})), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["missing POSTHOG_CLI_API_KEY, POSTHOG_CLI_PROJECT_ID, POSTHOG_RELEASE"]); + return true; + }, + ); +}); + +test("validatePostHogRelease queries the correct URL with the bearer auth header", async () => { + let capturedUrl: string | undefined; + let capturedAuth: string | undefined; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { + capturedUrl = String(input); + capturedAuth = (init?.headers as Record).authorization; + return response({ results: [] }); + }; + + await assert.rejects(() => validatePostHogRelease(validationEnv(), fetchImpl)); + assert.equal(capturedUrl, "https://us.posthog.com/api/projects/42/error_tracking/symbol_sets?limit=100"); + assert.equal(capturedAuth, "Bearer phx_test"); +}); + +test("validatePostHogRelease throws with the response status/message when the API request fails", async () => { + const fetchImpl = async (): Promise => response({ detail: "invalid token" }, 401); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.equal(error.failures[0].includes("returned HTTP 401 (invalid token)"), true); + return true; + }, + ); +}); + +test("validatePostHogRelease falls back to statusText when a failed response body isn't valid JSON", async () => { + const fetchImpl = async (): Promise => new Response("not json", { status: 500, statusText: "Internal Server Error" }); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.equal(error.failures[0].includes("returned HTTP 500 (Internal Server Error)"), true); + return true; + }, + ); +}); + +test("validatePostHogRelease accepts a bare array response body (not wrapped in {results})", async () => { + const fetchImpl = async (): Promise => response([{ release: "loopover-rees@abc123", failure_reason: null }]); + const result = await validatePostHogRelease(validationEnv(), fetchImpl); + assert.deepEqual(result, { release: "loopover-rees@abc123", symbolSetCount: 1 }); +}); + +test("validatePostHogRelease treats a response body that's neither an array nor {results} as an empty list", async () => { + const fetchImpl = async (): Promise => response({ unexpected: "shape" }); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["no symbol sets found for release loopover-rees@abc123"]); + return true; + }, + ); +}); + +test("validatePostHogRelease fails when no symbol sets match the target release", async () => { + const fetchImpl = async (): Promise => response({ results: [{ release: "some-other-release" }] }); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["no symbol sets found for release loopover-rees@abc123"]); + return true; + }, + ); +}); + +test("validatePostHogRelease fails when a matching symbol set recorded a failure_reason", async () => { + const fetchImpl = async (): Promise => + response({ results: [{ release: "loopover-rees@abc123", failure_reason: "could not parse sourcemap" }] }); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["1 symbol set(s) for release loopover-rees@abc123 recorded a failure_reason"]); + return true; + }, + ); +}); + +test("validatePostHogRelease succeeds when at least one matching symbol set has no failure_reason", async () => { + const fetchImpl = async (): Promise => + response({ + results: [ + { release: "some-other-release", failure_reason: "unrelated" }, + { release: "loopover-rees@abc123", failure_reason: null }, + { release: "loopover-rees@abc123" }, + ], + }); + const result = await validatePostHogRelease(validationEnv(), fetchImpl); + assert.deepEqual(result, { release: "loopover-rees@abc123", symbolSetCount: 2 }); +}); + +test("validatePostHogRelease counts every failed symbol set, not just the first one found", async () => { + const fetchImpl = async (): Promise => + response({ + results: [ + { release: "loopover-rees@abc123", failure_reason: "a" }, + { release: "loopover-rees@abc123", failure_reason: "b" }, + ], + }); + await assert.rejects( + () => validatePostHogRelease(validationEnv(), fetchImpl), + (error) => { + assert(error instanceof PostHogReleaseValidationError); + assert.deepEqual(error.failures, ["2 symbol set(s) for release loopover-rees@abc123 recorded a failure_reason"]); + return true; + }, + ); +}); diff --git a/review-enrichment/test/posthog-upload.test.ts b/review-enrichment/test/posthog-upload.test.ts new file mode 100644 index 0000000000..e9f7abd163 --- /dev/null +++ b/review-enrichment/test/posthog-upload.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { resolveReesPostHogRelease, resolvePostHogEnvironment } from "../src/posthog.ts"; + +test("resolveReesPostHogRelease prefers explicit releases and falls back to a commit sha, with no platform-specific derivation", () => { + assert.equal( + resolveReesPostHogRelease({ + POSTHOG_RELEASE: "custom-release", + POSTHOG_COMMIT_SHA: "abc123", + }), + "custom-release", + ); + assert.equal(resolveReesPostHogRelease({ POSTHOG_COMMIT_SHA: "abc123" }), "loopover-rees@abc123"); + assert.equal(resolveReesPostHogRelease({}), undefined); +}); + +test("resolvePostHogEnvironment defaults to production with no platform-specific fallback", () => { + assert.equal(resolvePostHogEnvironment({}), "production"); + assert.equal(resolvePostHogEnvironment({ POSTHOG_ENVIRONMENT: "staging" }), "staging"); +}); + +function postHogCliStub() { + const dir = mkdtempSync(resolve(tmpdir(), "rees-posthog-cli-")); + const logPath = resolve(dir, "calls.jsonl"); + const cliPath = resolve(dir, "posthog-cli"); + writeFileSync( + cliPath, + `#!/bin/sh\nnode -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${logPath}' "$@"\n`, + ); + chmodSync(cliPath, 0o755); + return { cliPath, logPath }; +} + +async function runUploadSourcemaps(env: NodeJS.ProcessEnv) { + return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveRun, reject) => { + const child = spawn(process.execPath, ["dist/upload-sourcemaps.js"], { + cwd: resolve(import.meta.dirname, ".."), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (status) => resolveRun({ status, stdout, stderr })); + }); +} + +test("upload-sourcemaps skips the PostHog upload and exits 0 when required config is missing", async () => { + const { cliPath, logPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /rees_posthog_sourcemap_upload_skipped/); + assert.throws(() => readFileSync(logPath, "utf8")); +}); + +test("upload-sourcemaps calls posthog-cli inject then upload with an explicit --release-version", async () => { + const { cliPath, logPath } = postHogCliStub(); + + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + + assert.deepEqual(calls[0], ["sourcemap", "inject", "--directory", "dist", "--release-version", "loopover-rees@abc123"]); + assert.deepEqual(calls[1], ["sourcemap", "upload", "--directory", "dist", "--release-version", "loopover-rees@abc123"]); + assert.match(result.stdout, /rees_posthog_sourcemap_upload_complete/); +}); + +test("upload-sourcemaps propagates a strict posthog-cli failure as a hard failure and exits 1", async () => { + const dir = mkdtempSync(resolve(tmpdir(), "rees-posthog-cli-fail-")); + const cliPath = resolve(dir, "posthog-cli"); + writeFileSync(cliPath, `#!/bin/sh\necho "upload rejected" 1>&2\nexit 1\n`); + chmodSync(cliPath, 0o755); + + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + REES_POSTHOG_UPLOAD_STRICT: "true", + }); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /rees_posthog_sourcemap_upload_failed/); +}); + +test("upload-sourcemaps treats a non-strict posthog-cli failure as a soft failure and exits 0", async () => { + const dir = mkdtempSync(resolve(tmpdir(), "rees-posthog-cli-fail-")); + const cliPath = resolve(dir, "posthog-cli"); + writeFileSync(cliPath, `#!/bin/sh\necho "upload rejected" 1>&2\nexit 1\n`); + chmodSync(cliPath, 0o755); + + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /rees_posthog_sourcemap_upload_failed/); +}); diff --git a/review-enrichment/test/sentry-degradation.test.ts b/review-enrichment/test/sentry-degradation.test.ts deleted file mode 100644 index 64c938c8d1..0000000000 --- a/review-enrichment/test/sentry-degradation.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import assert from "node:assert/strict"; -import test, { afterEach } from "node:test"; - -import { buildBrief } from "../dist/brief.js"; -import { - captureAnalyzerDegradation, - captureRouteError, - captureSourcemapUploadFailure, - captureUnhandledError, - resetSentryForTest, - setSentryForTest, -} from "../dist/sentry.js"; - -function sentryHarness() { - const tags: Record = {}; - const contexts: Record = {}; - const fingerprints: unknown[][] = []; - const levels: string[] = []; - const captured: Error[] = []; - 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( - { - 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 () => true, - }, - { release: "loopover-rees@test", environment: "test" }, - ); - return { tags, contexts, fingerprints, levels, captured }; -} - -afterEach(() => { - resetSentryForTest(); -}); - -test("captureAnalyzerDegradation is inert when Sentry is disabled", () => { - assert.doesNotThrow(() => - captureAnalyzerDegradation(new Error("boom"), { - analyzer: "dependency", - repoFullName: "JSONbored/loopover", - prNumber: 7, - headSha: "abc123", - timeoutMs: 8000, - }), - ); -}); - -test("captureAnalyzerDegradation tags and fingerprints sanitized analyzer failures", () => { - const sentry = sentryHarness(); - const fakeGithubPat = ["github", "pat", "should_never_be_attached"].join("_"); - const fakeGhp = ["ghp", "should_never_be_attached"].join("_"); - - captureAnalyzerDegradation(new Error("registry timeout"), { - analyzer: "dependency", - repoFullName: "JSONbored/loopover", - prNumber: 7, - headSha: "abc123", - timeoutMs: 8000, - diff: fakeGithubPat, - githubToken: fakeGhp, - authorization: "Bearer should_never_be_attached", - } as never); - - assert.deepEqual(sentry.levels, ["error"]); - assert.deepEqual(sentry.fingerprints, [["rees-analyzer-degraded", "dependency"]]); - assert.equal(sentry.tags.event, "rees_analyzer_degraded"); - assert.equal(sentry.tags.analyzer, "dependency"); - assert.equal(sentry.tags.repo, "JSONbored/loopover"); - assert.equal(sentry.tags.pullNumber, "7"); - assert.equal(sentry.tags.release, "loopover-rees@test"); - assert.equal(sentry.tags.environment, "test"); - assert.equal(sentry.captured[0].message, "registry timeout"); - - const analyzerContext = sentry.contexts.rees_analyzer as Record; - assert.deepEqual(analyzerContext, { - event: "rees_analyzer_degraded", - analyzer: "dependency", - repoFullName: "JSONbored/loopover", - prNumber: 7, - headShaPrefix: "abc123", - timeoutMs: 8000, - release: "loopover-rees@test", - environment: "test", - }); - const serializedContext = JSON.stringify(analyzerContext); - assert.equal(serializedContext.includes(fakeGithubPat), false); - assert.equal(serializedContext.includes(fakeGhp), false); - assert.equal(serializedContext.includes("Bearer should_never_be_attached"), false); -}); - -test("captureAnalyzerDegradation groups by partialReason (WHY), not analyzer name (WHICH), so the same reason from different analyzers is one issue (#5010)", () => { - const sentry = sentryHarness(); - - captureAnalyzerDegradation(new Error("analyzer_timeout"), { - analyzer: "installScript", - repoFullName: "JSONbored/loopover", - prNumber: 7, - headSha: "abc123", - timeoutMs: 1400, - partialReason: "analyzer_timeout", - } as never); - captureAnalyzerDegradation(new Error("analyzer_timeout"), { - analyzer: "nativeBuild", - repoFullName: "JSONbored/loopover", - prNumber: 8, - headSha: "def456", - timeoutMs: 1400, - partialReason: "analyzer_timeout", - } as never); - - // Same fingerprint from two DIFFERENT analyzers: both group into one Sentry issue. - assert.deepEqual(sentry.fingerprints, [ - ["rees-analyzer-degraded", "analyzer_timeout"], - ["rees-analyzer-degraded", "analyzer_timeout"], - ]); - // The specific analyzer is still fully visible via the tag -- only the GROUPING changed. - assert.equal(sentry.tags.analyzer, "nativeBuild"); -}); - -test("captureAnalyzerDegradation falls back to analyzer name when partialReason is absent", () => { - const sentry = sentryHarness(); - - captureAnalyzerDegradation(new Error("boom"), { - analyzer: "dependency", - repoFullName: "JSONbored/loopover", - prNumber: 7, - headSha: "abc123", - timeoutMs: 8000, - }); - - assert.deepEqual(sentry.fingerprints, [["rees-analyzer-degraded", "dependency"]]); -}); - -test("captureAnalyzerDegradation filters tag values before sending them", () => { - const sentry = sentryHarness(); - const secretLikeValue = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); - - captureAnalyzerDegradation(new Error("registry timeout"), { - analyzer: secretLikeValue, - repoFullName: `JSONbored/${secretLikeValue}`, - prNumber: 7, - headSha: secretLikeValue, - timeoutMs: 8000, - }); - - assert.deepEqual(sentry.fingerprints, [["rees-analyzer-degraded", "[Filtered]"]]); - assert.equal(sentry.tags.analyzer, "[Filtered]"); - assert.equal(sentry.tags.repo, "JSONbored/[Filtered]"); -}); - -test("captureAnalyzerDegradation attaches safe attribution context for history failures", () => { - const sentry = sentryHarness(); - const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); - - captureAnalyzerDegradation(new Error("history budget exhausted"), { - analyzer: "history", - requestedAnalyzers: ["secret", "history"], - repoFullName: "JSONbored/metagraphed", - prNumber: 2359, - headSha: "abcdef1234567890", - timeoutMs: 7000, - elapsedMs: 6812, - analyzerStatus: "degraded", - profile: "balanced", - costClass: "github-heavy", - responseReserveMs: 750, - partialStatus: "partial", - partialReason: "history_budget_exhausted", - phase: "similar_past_prs", - subcall: "commit_pulls", - endpointCategory: "github-commit-pulls", - externalFailureReason: "timeout", - externalElapsedMs: 1200, - fileLookupCount: 5, - commitLookupCount: 13, - prLookupCount: 12, - skippedFileCount: 2, - githubEndpointCategory: "commit_pulls", - capped: true, - cacheHits: 4, - cacheMisses: 9, - externalCallsByCategory: { osv: 3, commit_pulls: 12 }, - skippedWorkByCategory: { history_budget: 2 }, - cappedWorkByCategory: { history_files: 2 }, - analysisElapsedMs: 6812, - requestId: "req-123", - traceId: "0123456789abcdef0123456789abcdef", - diff: `+${fakeToken}`, - githubToken: fakeToken, - } as never); - - assert.equal(sentry.tags.analyzer, "history"); - assert.equal(sentry.tags.repo, "JSONbored/metagraphed"); - assert.equal(sentry.tags.pullNumber, "2359"); - const analyzerContext = sentry.contexts.rees_analyzer as Record; - assert.deepEqual(analyzerContext, { - event: "rees_analyzer_degraded", - analyzer: "history", - requestedAnalyzers: ["secret", "history"], - repoFullName: "JSONbored/metagraphed", - prNumber: 2359, - headShaPrefix: "abcdef123456", - timeoutMs: 7000, - elapsedMs: 6812, - analyzerStatus: "degraded", - profile: "balanced", - costClass: "github-heavy", - responseReserveMs: 750, - partialStatus: "partial", - partialReason: "history_budget_exhausted", - phase: "similar_past_prs", - subcall: "commit_pulls", - endpointCategory: "github-commit-pulls", - externalFailureReason: "timeout", - externalElapsedMs: 1200, - fileLookupCount: 5, - commitLookupCount: 13, - prLookupCount: 12, - skippedFileCount: 2, - githubEndpointCategory: "commit_pulls", - capped: true, - cacheHits: 4, - cacheMisses: 9, - externalCallsByCategory: { osv: 3, commit_pulls: 12 }, - skippedWorkByCategory: { history_budget: 2 }, - cappedWorkByCategory: { history_files: 2 }, - analysisElapsedMs: 6812, - requestId: "req-123", - traceId: "0123456789abcdef0123456789abcdef", - release: "loopover-rees@test", - environment: "test", - }); - const serializedContext = JSON.stringify(analyzerContext); - assert.equal(serializedContext.includes(fakeToken), false); - assert.equal(serializedContext.includes("diff"), false); - assert.equal(serializedContext.includes("githubToken"), false); -}); - -test("buildBrief stays fail-open and captures a degraded analyzer", async () => { - const sentry = sentryHarness(); - const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); - - const brief = await buildBrief( - { - repoFullName: "JSONbored/loopover", - prNumber: 42, - headSha: "head-sha", - analyzers: ["dependency"], - files: [{ path: "package.json", patch: '+ "lodash": "4.17.20",' }], - budget: { timeoutMs: 2000 }, - }, - { - dependency: async () => { - throw new Error(`osv unavailable for ${fakeToken}`); - }, - }, - ); - - assert.equal(brief.partial, true); - assert.equal(brief.analyzerStatus.dependency, "degraded"); - assert.deepEqual(brief.findings, {}); - assert.equal(brief.repoFullName, "JSONbored/loopover"); - assert.equal(brief.prNumber, 42); - assert.equal(brief.telemetry.analyzers.dependency.partialReason, "analyzer_error"); - assert.equal(JSON.stringify(brief.telemetry).includes(fakeToken), false); - assert.equal(JSON.stringify(brief.telemetry).includes("osv unavailable"), false); - assert.equal(sentry.captured.length, 1); - assert.equal(sentry.captured[0].message, "analyzer_error"); - assert.equal(sentry.tags.analyzer, "dependency"); - assert.equal(sentry.tags.repo, "JSONbored/loopover"); - assert.equal(sentry.tags.pullNumber, "42"); - assert.equal(sentry.tags.event, "rees_analyzer_degraded"); - const analyzerContext = sentry.contexts.rees_analyzer as Record; - const capturedTimeoutMs = Number(analyzerContext.timeoutMs); - assert.ok(capturedTimeoutMs > 0); - assert.ok(capturedTimeoutMs <= 2000); -}); - -test("captureRouteError applies the route-level fingerprint and allowlisted tags", () => { - const sentry = sentryHarness(); - - captureRouteError(new Error("boom"), { - route: "/v1/enrich", - method: "POST", - }); - - assert.deepEqual(sentry.levels, ["error"]); - assert.deepEqual(sentry.fingerprints, [["rees-route-error", "/v1/enrich", "POST"]]); - assert.equal(sentry.tags.event, "rees_route_error"); - assert.equal(sentry.tags.route, "/v1/enrich"); - assert.equal(sentry.tags.method, "POST"); - assert.equal(sentry.tags.release, "loopover-rees@test"); - assert.equal(sentry.tags.environment, "test"); - assert.deepEqual(sentry.contexts.rees_route, { - event: "rees_route_error", - route: "/v1/enrich", - method: "POST", - release: "loopover-rees@test", - environment: "test", - }); -}); - -test("captureUnhandledError fingerprints process-level failures by event class", () => { - const sentry = sentryHarness(); - - captureUnhandledError(new Error("kaboom"), { event: "rees_uncaught_exception" }); - - assert.deepEqual(sentry.fingerprints, [["rees-process-error", "rees_uncaught_exception"]]); - assert.equal(sentry.tags.event, "rees_uncaught_exception"); - assert.equal(sentry.tags.release, "loopover-rees@test"); - assert.equal(sentry.tags.environment, "test"); - assert.deepEqual(sentry.contexts.rees_process, { - event: "rees_uncaught_exception", - release: "loopover-rees@test", - environment: "test", - }); -}); - -test("captureSourcemapUploadFailure applies stable upload grouping and safe tags", () => { - const sentry = sentryHarness(); - - captureSourcemapUploadFailure(new Error("upload failed"), { - release: "loopover-rees@test", - railwayDeploymentId: "railway-deploy-123", - strict: true, - sha: "abcdef1234567890", - stage: "upload", - }); - - assert.deepEqual(sentry.fingerprints, [["rees-sourcemap-upload-failed"]]); - assert.equal(sentry.tags.event, "rees_sourcemap_upload_failed"); - assert.equal(sentry.tags.release, "loopover-rees@test"); - assert.equal(sentry.tags.environment, "test"); - assert.equal(sentry.tags.railwayDeploymentId, "railway-deploy-123"); - assert.deepEqual(sentry.contexts.rees_sourcemap_upload, { - event: "rees_sourcemap_upload_failed", - release: "loopover-rees@test", - railwayDeploymentId: "railway-deploy-123", - strict: true, - sha: "abcdef1234567890", - stage: "upload", - environment: "test", - }); -}); - -test("buildBrief normalizes unsafe analyzer partial reasons before response telemetry", async () => { - const fakeToken = ["ghp", "abcdefghijklmnopqrstuvwxyz1234567890"].join("_"); - - const brief = await buildBrief( - { - repoFullName: "JSONbored/loopover", - prNumber: 42, - analyzers: ["history"], - linkedIssue: { number: 9, title: "add history context" }, - diff: `+${fakeToken}`, - budget: { timeoutMs: 200 }, - }, - { - history: async (_req, context) => { - context.diagnostics.partialReason = `unsafe ${fakeToken}`; - return [{ author: null, similarPastPrs: [], linkedIssueAlignment: null, partial: true }]; - }, - }, - ); - - assert.equal(brief.partial, true); - assert.equal(brief.analyzerStatus.history, "degraded"); - assert.equal(brief.telemetry.analyzers.history.partialReason, "analyzer_partial"); - assert.equal(JSON.stringify(brief.telemetry).includes(fakeToken), false); -}); - -test("buildBrief returns a timed-out partial response before the caller timeout budget is spent", async () => { - const started = Date.now(); - const brief = await buildBrief( - { - repoFullName: "JSONbored/metagraphed", - prNumber: 2359, - headSha: "abcdef1234567890", - analyzers: ["history"], - githubToken: "token", - author: "jsonbored", - files: [{ path: "src/a.ts", patch: "@@ -1,0 +1,1 @@\n+export const a = 1;" }], - budget: { timeoutMs: 300 }, - }, - { - history: async () => new Promise(() => undefined), - }, - { requestId: "req-timeout" }, - ); - - assert.equal(brief.partial, true); - assert.equal(brief.analyzerStatus.history, "timeout"); - assert.deepEqual(brief.findings, {}); - assert.ok(Date.now() - started < 500); - assert.ok(brief.elapsedMs < 500); -}); - -test("buildBrief marks analyzer-provided partial findings as degraded while keeping the brief", async () => { - const brief = await buildBrief( - { - repoFullName: "JSONbored/metagraphed", - prNumber: 2359, - analyzers: ["history"], - linkedIssue: { number: 9, title: "add history context" }, - diff: "+history context", - }, - { - history: async (_req, context) => { - context.diagnostics.partialReason = "history_budget_exhausted"; - context.diagnostics.captureDegradation = true; - context.diagnostics.phase = "similar_past_prs"; - context.diagnostics.githubEndpointCategory = "commit_pulls"; - context.diagnostics.fileLookupCount = 1; - return [ - { - author: null, - similarPastPrs: [], - linkedIssueAlignment: { issue: 9, statedRequirement: "add history context", diffCovers: "full" }, - partial: true, - }, - ]; - }, - }, - { requestId: "req-partial", traceId: "0123456789abcdef0123456789abcdef" }, - ); - - assert.equal(brief.partial, true); - assert.equal(brief.analyzerStatus.history, "degraded"); - assert.equal(brief.findings.history?.[0]?.partial, true); - assert.match(brief.promptSection, /Author & change-area history/); -}); - -test("buildBrief treats an explicit empty analyzer list as run none", async () => { - let ran = false; - - const brief = await buildBrief( - { - repoFullName: "JSONbored/loopover", - prNumber: 42, - headSha: "head-sha", - analyzers: [], - }, - { - secret: async () => { - ran = true; - return [ - { - file: "src/config.ts", - line: 7, - kind: "generic_secret_assignment", - confidence: "high", - }, - ]; - }, - redos: async () => { - ran = true; - return [ - { - file: "src/regex.ts", - line: 3, - kind: "nested-quantifier", - pattern: "(a+)+", - }, - ]; - }, - }, - ); - - assert.equal(ran, false); - assert.equal(brief.partial, false); - assert.deepEqual(brief.findings, {}); - assert.equal(brief.analyzerStatus.secret, "skipped"); - assert.equal(brief.analyzerStatus.redos, "skipped"); - assert.equal(brief.promptSection, ""); - assert.equal(brief.systemSuffix, ""); -}); diff --git a/review-enrichment/test/sentry-release-validation.test.ts b/review-enrichment/test/sentry-release-validation.test.ts deleted file mode 100644 index 142e095e55..0000000000 --- a/review-enrichment/test/sentry-release-validation.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - loadSentryReleaseValidationConfig, - SentryReleaseValidationError, - validateSentryRelease, -} from "../scripts/validate-sentry-release.mjs"; - -function response(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function validationEnv(overrides: Record = {}): NodeJS.ProcessEnv { - return { - SENTRY_AUTH_TOKEN: "test-token", - SENTRY_ORG: "jsonbored", - SENTRY_PROJECT: "loopover", - SENTRY_RELEASE: "loopover-rees@abc123", - SENTRY_COMMIT_SHA: "abc123", - SENTRY_DEPLOY_NAME: "deploy-1", - SENTRY_ENVIRONMENT: "production", - SENTRY_REQUIRE_DEPLOY: "true", - ...overrides, - }; -} - -test("loadSentryReleaseValidationConfig resolves exact release validation defaults", () => { - assert.deepEqual( - loadSentryReleaseValidationConfig({ - SENTRY_AUTH_TOKEN: "token", - SENTRY_ORG: "jsonbored", - SENTRY_PROJECT: "loopover", - SENTRY_RELEASE: "loopover-rees@abc123", - RAILWAY_GIT_COMMIT_SHA: "abc123", - RAILWAY_DEPLOYMENT_ID: "deploy-1", - RAILWAY_ENVIRONMENT_NAME: "production", - }), - { - authToken: "token", - org: "jsonbored", - project: "loopover", - release: "loopover-rees@abc123", - baseUrl: "https://sentry.io", - expectedCommitSha: "abc123", - expectedDeployName: "deploy-1", - expectedEnvironment: "production", - requireCommits: true, - requireDeploy: false, - requireFinalized: true, - requireReleaseFiles: false, - }, - ); -}); - -test("validateSentryRelease verifies finalized release, commits, and deploy", async () => { - const calls: string[] = []; - const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { - assert.equal((init?.headers as Record).authorization, "Bearer test-token"); - const path = new URL(String(input)).pathname; - calls.push(path); - if (path === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/") { - return response({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: 1, - deployCount: 1, - projects: [{ slug: "loopover" }], - lastDeploy: { name: "deploy-1", environment: "production" }, - }); - } - if (path === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/") { - return response([{ id: "abc123" }]); - } - if (path === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/deploys/") { - return response([{ name: "deploy-1", environment: "production" }]); - } - return response({ detail: "not found" }, 404); - }; - - const result = await validateSentryRelease(validationEnv(), fetchImpl); - - assert.equal(result.release, "loopover-rees@abc123"); - assert.equal(result.finalized, true); - assert.equal(result.commitCount, 1); - assert.equal(result.deployCount, 1); - assert.deepEqual(calls, [ - "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/", - "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/", - "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/deploys/", - ]); -}); - -test("validateSentryRelease rejects a release missing the expected commit", async () => { - const fetchImpl = async (input: string | URL | Request): Promise => { - const path = new URL(String(input)).pathname; - if (path.endsWith("/commits/")) return response([{ id: "def456" }]); - if (path.endsWith("/deploys/")) return response([{ name: "deploy-1", environment: "production" }]); - return response({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: 1, - deployCount: 1, - projects: [{ slug: "loopover" }], - }); - }; - - await assert.rejects( - () => validateSentryRelease(validationEnv(), fetchImpl), - (error) => { - assert(error instanceof SentryReleaseValidationError); - assert.deepEqual(error.failures, ["release commits do not include expected commit abc123"]); - assert.equal(JSON.stringify(error.failures).includes("test-token"), false); - return true; - }, - ); -}); - -test("REGRESSION: validateSentryRelease does NOT enforce the expected-commit match when SENTRY_REQUIRE_COMMITS=false", async () => { - // Same mismatched-commit fixture as the strict-mode test above ("def456" vs the expected "abc123"), but with - // requireCommits off (upload-sourcemaps.ts's non-strict deploy path: SENTRY_REQUIRE_COMMITS: fields.strict ? - // "true" : "false"). SENTRY_COMMIT_SHA is still passed unconditionally (it's the deploy's actual git SHA, not - // itself a strictness signal), so expectedCommitSha stays set -- the bug this guards was that the match check - // read only `config.expectedCommitSha`, never `config.requireCommits`, so it fired regardless of strict mode. - const calls: string[] = []; - const fetchImpl = async (input: string | URL | Request): Promise => { - const path = new URL(String(input)).pathname; - calls.push(path); - if (path.endsWith("/commits/")) return response([{ id: "def456" }]); - if (path.endsWith("/deploys/")) return response([{ name: "deploy-1", environment: "production" }]); - return response({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: 1, - deployCount: 1, - projects: [{ slug: "loopover" }], - }); - }; - - const result = await validateSentryRelease(validationEnv({ SENTRY_REQUIRE_COMMITS: "false" }), fetchImpl); - assert.equal(result.release, "loopover-rees@abc123"); - // Non-strict mode must not even CALL the commits endpoint -- confirmed below with a second regression pinning - // this exact call-skip, since a real Sentry API hiccup on /commits/ would otherwise still fail a non-strict - // deploy (sentryJson throws on any non-OK response) even though no commit check would run on the result. - assert.equal(calls.includes("/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/"), false); -}); - -test("REGRESSION: validateSentryRelease never calls the commits endpoint at all when SENTRY_REQUIRE_COMMITS=false, even if that endpoint is unhealthy", async () => { - // The specific gap the AI reviewer caught on the first pass of this fix: gating only the FAILURE pushes on - // requireCommits, while leaving the fetch itself conditioned on `requireCommits || expectedCommitSha`, meant a - // non-strict deploy still depended on /commits/ succeeding even though nothing would ever fail from its result. - // Prove it directly: the endpoint returns a hard 500, and non-strict validation must still succeed. - const fetchImpl = async (input: string | URL | Request): Promise => { - const path = new URL(String(input)).pathname; - if (path.endsWith("/commits/")) return response({ detail: "internal error" }, 500); - if (path.endsWith("/deploys/")) return response([{ name: "deploy-1", environment: "production" }]); - return response({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: 1, - deployCount: 1, - projects: [{ slug: "loopover" }], - }); - }; - - const result = await validateSentryRelease(validationEnv({ SENTRY_REQUIRE_COMMITS: "false" }), fetchImpl); - assert.equal(result.release, "loopover-rees@abc123"); -}); - -test("validateSentryRelease rejects a release missing the required deploy", async () => { - const fetchImpl = async (input: string | URL | Request): Promise => { - const path = new URL(String(input)).pathname; - if (path.endsWith("/commits/")) return response([{ id: "abc123" }]); - if (path.endsWith("/deploys/")) return response([]); - return response({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: 1, - deployCount: 0, - projects: [{ slug: "loopover" }], - }); - }; - - await assert.rejects( - () => validateSentryRelease(validationEnv(), fetchImpl), - (error) => { - assert(error instanceof SentryReleaseValidationError); - assert.deepEqual(error.failures, ["release has no associated deploys"]); - return true; - }, - ); -}); diff --git a/review-enrichment/test/sentry-upload.test.ts b/review-enrichment/test/sentry-upload.test.ts deleted file mode 100644 index 8080864d4b..0000000000 --- a/review-enrichment/test/sentry-upload.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import test from "node:test"; - -import { resolveReesSentryRelease, resolveTracesSampleRate } from "../src/sentry.ts"; - -test("resolveReesSentryRelease prefers explicit releases and falls back to Railway commit shas", () => { - assert.equal( - resolveReesSentryRelease({ - SENTRY_RELEASE: "custom-release", - RAILWAY_GIT_COMMIT_SHA: "abc123", - }), - "custom-release", - ); - assert.equal(resolveReesSentryRelease({ RAILWAY_GIT_COMMIT_SHA: "abc123" }), "loopover-rees@abc123"); - assert.equal(resolveReesSentryRelease({}), undefined); -}); - -test("resolveTracesSampleRate clamps malformed or out-of-range config", () => { - assert.equal(resolveTracesSampleRate({}), 0); - assert.equal(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "0.25" }), 0.25); - assert.equal(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "nope" }), 0); - assert.equal(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "-1" }), 0); - assert.equal(resolveTracesSampleRate({ SENTRY_TRACES_SAMPLE_RATE: "2" }), 1); -}); - -async function sentryApiServer(options: { missingCommitOnFirstValidation?: boolean } = {}) { - const seen: string[] = []; - let releaseReads = 0; - const server = createServer((req, res) => { - seen.push(req.url ?? ""); - res.setHeader("content-type", "application/json"); - if (req.url === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/") { - releaseReads += 1; - const missingCommit = options.missingCommitOnFirstValidation && releaseReads === 1; - res.end( - JSON.stringify({ - version: "loopover-rees@abc123", - dateReleased: "2026-06-29T00:00:00Z", - commitCount: missingCommit ? 0 : 1, - deployCount: 1, - projects: [{ slug: "rees" }], - lastDeploy: { name: "deploy-1", environment: "production" }, - }), - ); - return; - } - if (req.url === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/") { - res.end( - JSON.stringify( - options.missingCommitOnFirstValidation && releaseReads === 1 ? [] : [{ id: "abc123" }], - ), - ); - return; - } - if (req.url === "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/deploys/") { - res.end(JSON.stringify([{ name: "deploy-1", environment: "production" }])); - return; - } - res.statusCode = 404; - res.end(JSON.stringify({ detail: "not found" })); - }); - await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); - const address = server.address(); - assert(address && typeof address === "object"); - return { - url: `http://127.0.0.1:${address.port}`, - seen, - close: () => new Promise((resolveClose, reject) => { - server.close((error) => (error ? reject(error) : resolveClose())); - }), - }; -} - -function sentryCliStub() { - const dir = mkdtempSync(resolve(tmpdir(), "rees-sentry-cli-")); - const logPath = resolve(dir, "calls.jsonl"); - const cliPath = resolve(dir, "sentry-cli"); - writeFileSync( - cliPath, - `#!/bin/sh\nnode -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${logPath}' "$@"\n`, - ); - chmodSync(cliPath, 0o755); - return { cliPath, logPath }; -} - -async function runUploadSourcemaps(env: NodeJS.ProcessEnv) { - return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveRun, reject) => { - const child = spawn(process.execPath, ["dist/upload-sourcemaps.js"], { - cwd: resolve(import.meta.dirname, ".."), - env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (status) => resolveRun({ status, stdout, stderr })); - }); -} - -test("upload-sourcemaps calls Sentry CLI with release association on upload", async () => { - const api = await sentryApiServer(); - const { cliPath, logPath } = sentryCliStub(); - - try { - const result = await runUploadSourcemaps({ - ...process.env, - SENTRY_AUTH_TOKEN: "test-token", - SENTRY_ORG: "jsonbored", - SENTRY_PROJECT: "rees", - SENTRY_CLI_PATH: cliPath, - SENTRY_URL: api.url, - RAILWAY_GIT_COMMIT_SHA: "abc123", - RAILWAY_DEPLOYMENT_ID: "deploy-1", - RAILWAY_ENVIRONMENT_NAME: "production", - REES_SENTRY_UPLOAD_STRICT: "true", - }); - - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - const calls = readFileSync(logPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as string[]); - - assert.deepEqual(calls[0], [ - "releases", - "--org", - "jsonbored", - "--project", - "rees", - "new", - "loopover-rees@abc123", - ]); - assert.deepEqual(calls[1], [ - "releases", - "--org", - "jsonbored", - "--project", - "rees", - "set-commits", - "loopover-rees@abc123", - "--commit", - "JSONbored/loopover@abc123", - "--ignore-missing", - ]); - assert.deepEqual(calls[2], ["sourcemaps", "--org", "jsonbored", "--project", "rees", "inject", "dist"]); - assert.deepEqual(calls[3], [ - "sourcemaps", - "--org", - "jsonbored", - "--project", - "rees", - "upload", - "--release", - "loopover-rees@abc123", - "--validate", - "--wait", - "--strict", - "dist", - ]); - assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/"), true); - assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/"), true); - assert.equal(api.seen.includes("/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/deploys/"), true); - } finally { - await api.close(); - } -}); - -test("upload-sourcemaps retries release validation until Sentry exposes associated commits", async () => { - const api = await sentryApiServer({ missingCommitOnFirstValidation: true }); - const { cliPath } = sentryCliStub(); - - try { - const result = await runUploadSourcemaps({ - ...process.env, - SENTRY_AUTH_TOKEN: "test-token", - SENTRY_ORG: "jsonbored", - SENTRY_PROJECT: "rees", - SENTRY_CLI_PATH: cliPath, - SENTRY_URL: api.url, - RAILWAY_GIT_COMMIT_SHA: "abc123", - RAILWAY_DEPLOYMENT_ID: "deploy-1", - RAILWAY_ENVIRONMENT_NAME: "production", - REES_SENTRY_UPLOAD_STRICT: "true", - REES_SENTRY_VALIDATE_ATTEMPTS: "2", - REES_SENTRY_VALIDATE_RETRY_DELAY_MS: "0", - }); - - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); - assert.match(result.stderr, /rees_sentry_release_validation_retry/); - const commitPath = "/api/0/organizations/jsonbored/releases/loopover-rees%40abc123/commits/"; - assert.equal( - api.seen.filter((path) => path === commitPath).length, - 2, - ); - } finally { - await api.close(); - } -}); From 68ff7231cdc44d5cde5b250925e8fc0b62febe55 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:15:23 -0700 Subject: [PATCH 2/2] fix(rees): close the codecov/patch gap on PostHog error tracking Adds the missing review-enrichment/src/server.ts codecov.yml ignore entry (a pre-existing gap another file's comment incorrectly claimed was already handled), plus real-subprocess and real-library tests covering initReesPostHog's success path and upload-sourcemaps.ts's release-validation retry/exhaustion and source-map-corruption branches. Remaining gaps are v8-ignored with justification where discovery-index's twin can only reach them via child_process/fs mocking that this package's node:test suite deliberately avoids. --- codecov.yml | 7 + review-enrichment/src/posthog.ts | 13 + review-enrichment/src/upload-sourcemaps.ts | 16 + .../test/posthog-degradation.test.ts | 57 ++++ review-enrichment/test/posthog-upload.test.ts | 284 +++++++++++++++++- 5 files changed, 374 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index d3214494cd..7a38b3570f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -67,6 +67,13 @@ ignore: # Self-host process entry + build-time stubs: exercised by the Docker build+boot smoke test # (.github/workflows/selfhost.yml), not unit-coverable without booting a server/subprocess. - "src/server.ts" + # review-enrichment's process entrypoint (#1473) -- same "Docker build+boot, not unit-coverable" reasoning + # as src/server.ts above; module-scope serve() binds a real port as a side effect of importing this file, + # so it's never imported by test/**. Pre-existing gap: this entry was missing even though + # discovery-index/src/server.ts's own header comment already claimed review-enrichment/src/server.ts got + # "the same treatment" -- surfaced by #8290 finally touching enough of this file's lines to trip + # codecov/patch on it. + - "review-enrichment/src/server.ts" - "src/selfhost/cf-workers-shim.ts" - "src/selfhost/stubs/**" # Postgres runtime adapters: validated by the real-Postgres integration test (test/integration/selfhost-pg.ts, diff --git a/review-enrichment/src/posthog.ts b/review-enrichment/src/posthog.ts index aeacf0a23f..ff67a5a8c4 100644 --- a/review-enrichment/src/posthog.ts +++ b/review-enrichment/src/posthog.ts @@ -56,9 +56,12 @@ export function resolvePostHogEnvironment(env: NodeJS.ProcessEnv): string { return nonBlank(env.POSTHOG_ENVIRONMENT) ?? "production"; } +/* v8 ignore start -- @preserve untestable without module mocking: warn()'s only call site is + * initReesPostHog's catch branch below, itself ignored for the same reason (see that block's comment). */ function warn(event: string, fields: Record = {}): void { console.error(JSON.stringify({ level: "warn", event, ...fields })); } +/* v8 ignore stop */ function scrubValue(value: unknown): unknown { if (Array.isArray(value)) return value.map((entry) => scrubValue(entry)); @@ -119,6 +122,15 @@ export async function initReesPostHog(env: NodeJS.ProcessEnv): Promise }); active = true; return true; + /* v8 ignore start -- @preserve untestable without module mocking: the sibling PostHog sinks in this repo + * (src/selfhost/posthog.ts, src/api/worker-posthog.ts, packages/discovery-index/src/posthog.ts) exercise + * this identical catch branch via vitest's vi.doMock, forcing posthog-node's dynamic import to throw. + * review-enrichment uses node:test, whose equivalent (mock.module) requires + * --experimental-test-module-mocks -- a flag this package's own "node": ">=20" engines range can't assume + * (the feature needs Node 22.3+), and unconditionally calling mock.module() in a test file would crash + * npm test outright for anyone running the flagless, Node-20-compatible path. No way found to make the + * REAL posthog-node's PostHog constructor throw synchronously (a malformed host string doesn't) without + * network access, which a unit test must not depend on. */ } catch (error) { active = false; client = undefined; @@ -127,6 +139,7 @@ export async function initReesPostHog(env: NodeJS.ProcessEnv): Promise warn("rees_posthog_init_failed", { message: error instanceof Error ? error.message : String(error) }); return false; } + /* v8 ignore stop */ } export function captureRoutePostHogError(error: unknown, context: { route: string; method: string }): void { diff --git a/review-enrichment/src/upload-sourcemaps.ts b/review-enrichment/src/upload-sourcemaps.ts index 19ec720ff2..4532124786 100644 --- a/review-enrichment/src/upload-sourcemaps.ts +++ b/review-enrichment/src/upload-sourcemaps.ts @@ -56,6 +56,11 @@ function validateSourceMaps(): void { } const maps = listFiles(distDir).filter((path) => path.endsWith(".js.map")); + // Unreachable on a real filesystem: the existsSync(serverMap) check above already guarantees + // dist/server.js.map exists, and listFiles(distDir) always picks it up, so `maps` can never be empty + // here. Only forceable by mocking readdirSync independently of existsSync (as discovery-index's twin + // does) -- this package's tests spawn the real built subprocess against the real filesystem instead. + /* v8 ignore next -- @preserve unreachable without mocking fs: see comment above */ if (maps.length === 0) throw new Error("dist has no JavaScript source maps"); let sawServerSource = false; @@ -103,6 +108,12 @@ async function runReleaseValidation(release: string): Promise { encoding: "utf8", }); status = result.status; + // process.execPath is the real running Node binary, so spawnSync here always spawns successfully -- + // the null-stdout/stderr case only exists when spawnSync itself fails to launch (e.g. ENOENT on the + // command). Unlike discovery-index's twin (test/unit/discovery-index/upload-sourcemaps.test.ts), this + // package's node:test suite spawns the real built subprocess with no child_process mocking, so this + // fallback can't be exercised without breaking that testing strategy. + /* v8 ignore next -- @preserve unreachable without mocking child_process: see comment above */ output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); if (result.status === 0) { if (output) log("rees_posthog_release_validation", { output: output.slice(0, 500), attempt }); @@ -168,6 +179,11 @@ async function main(): Promise { sha: nonBlank(process.env.POSTHOG_COMMIT_SHA), }); await flushReesPostHog(); + // Every throw site in this file's try block above (validateSourceMaps, runPostHog, runReleaseValidation) + // throws a real Error, so the String(error) arm below only exists for the general catch-clause-is-typed- + // unknown case. Discovery-index's twin covers the equivalent line by mocking child_process.spawnSync to + // throw a bare string; this package's real-subprocess test strategy has no equivalent hook. + /* v8 ignore next -- @preserve unreachable without mocking a non-Error throw: see comment above */ warn("rees_posthog_sourcemap_upload_failed", { release, message: error instanceof Error ? error.message : String(error), strict }); return strict ? 1 : 0; } diff --git a/review-enrichment/test/posthog-degradation.test.ts b/review-enrichment/test/posthog-degradation.test.ts index ab8600a256..2ab07a553a 100644 --- a/review-enrichment/test/posthog-degradation.test.ts +++ b/review-enrichment/test/posthog-degradation.test.ts @@ -7,8 +7,11 @@ import { captureRoutePostHogError, captureSourcemapUploadPostHogFailure, captureUnhandledPostHogError, + flushReesPostHog, + initReesPostHog, resetReesPostHogForTest, setReesPostHogForTest, + shutdownReesPostHog, } from "../dist/posthog.js"; function postHogHarness() { @@ -257,3 +260,57 @@ test("secret scrubbing: drops an empty tag value instead of setting a blank prop assert.equal(posthog.captured[0].properties.route, undefined); assert.equal(posthog.captured[0].properties.$exception_fingerprint, "rees-route-error|unknown|GET"); }); + +// initReesPostHog's real dynamic-import success path, exercised with the REAL posthog-node package rather +// than a mock: node:test's mock.module needs --experimental-test-module-mocks, which review-enrichment's own +// "node": ">=20" engines range can't assume (the flag needs Node 22.3+). The real PostHog client with an +// empty event queue makes zero network I/O on shutdown -- verified empirically (constructed + shut down +// against an unreachable host in ~2ms, no hang, no error) -- so this is safe: never call a capture* function +// in these tests (that would actually queue a real event), and always shutdownReesPostHog() before +// resetReesPostHogForTest() so the real client's internal flush timer doesn't dangle into later tests. +test("initReesPostHog stays inert (returns false) when POSTHOG_API_KEY is unset", async () => { + assert.equal(await initReesPostHog({} as NodeJS.ProcessEnv), false); +}); + +test("initReesPostHog stays inert when POSTHOG_API_KEY is blank/whitespace", async () => { + assert.equal(await initReesPostHog({ POSTHOG_API_KEY: " " } as NodeJS.ProcessEnv), false); +}); + +test("initReesPostHog activates a real client with the default host for a configured key", async () => { + const activated = await initReesPostHog({ POSTHOG_API_KEY: "phc_test_fake_key_coverage_only" } as NodeJS.ProcessEnv); + assert.equal(activated, true); + await shutdownReesPostHog(); + resetReesPostHogForTest(); +}); + +test("initReesPostHog uses POSTHOG_HOST when set", async () => { + const activated = await initReesPostHog({ POSTHOG_API_KEY: "phc_test", POSTHOG_HOST: "https://eu.i.posthog.com" } as NodeJS.ProcessEnv); + assert.equal(activated, true); + await shutdownReesPostHog(); + resetReesPostHogForTest(); +}); + +test("initReesPostHog activates with POSTHOG_COMMIT_SHA/POSTHOG_ENVIRONMENT set (release/environment resolution itself is covered separately by resolveReesPostHogRelease/resolvePostHogEnvironment's own unit tests)", async () => { + const activated = await initReesPostHog({ POSTHOG_API_KEY: "phc_test", POSTHOG_COMMIT_SHA: "abc123", POSTHOG_ENVIRONMENT: "staging" } as NodeJS.ProcessEnv); + assert.equal(activated, true); + // Deliberately does NOT call a capture* function here -- that would queue a real event on the real + // client, and shutdownReesPostHog() below would then attempt to flush it over the real network + // (unlike the empty-queue case, which is verified network-free). Keeping the queue empty for every + // init test in this file, not just this one, is what keeps them all safe to run without a live + // PostHog endpoint or network access. + await shutdownReesPostHog(); + resetReesPostHogForTest(); +}); + +test("flushReesPostHog and shutdownReesPostHog are inert when PostHog is disabled", async () => { + await assert.doesNotReject(() => flushReesPostHog()); + await assert.doesNotReject(() => shutdownReesPostHog()); +}); + +test("flushReesPostHog drains a real, empty-queue client without throwing", async () => { + const activated = await initReesPostHog({ POSTHOG_API_KEY: "phc_test_fake_key_coverage_only" } as NodeJS.ProcessEnv); + assert.equal(activated, true); + await assert.doesNotReject(() => flushReesPostHog()); + await shutdownReesPostHog(); + resetReesPostHogForTest(); +}); diff --git a/review-enrichment/test/posthog-upload.test.ts b/review-enrichment/test/posthog-upload.test.ts index e9f7abd163..4fb07d91ec 100644 --- a/review-enrichment/test/posthog-upload.test.ts +++ b/review-enrichment/test/posthog-upload.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; @@ -24,18 +25,48 @@ test("resolvePostHogEnvironment defaults to production with no platform-specific assert.equal(resolvePostHogEnvironment({ POSTHOG_ENVIRONMENT: "staging" }), "staging"); }); -function postHogCliStub() { +function postHogCliStub(options: { echoOutput?: string } = {}) { const dir = mkdtempSync(resolve(tmpdir(), "rees-posthog-cli-")); const logPath = resolve(dir, "calls.jsonl"); const cliPath = resolve(dir, "posthog-cli"); + const echo = options.echoOutput ? `echo '${options.echoOutput}'\n` : ""; writeFileSync( cliPath, - `#!/bin/sh\nnode -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${logPath}' "$@"\n`, + `#!/bin/sh\n${echo}node -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${logPath}' "$@"\n`, ); chmodSync(cliPath, 0o755); return { cliPath, logPath }; } +/** A real local HTTP server standing in for PostHog's error_tracking/symbol_sets API, so + * runReleaseValidation's actual retry/attempts/success logic (validate-posthog-release.mjs, spawned as a + * real subprocess-of-a-subprocess by upload-sourcemaps.js) gets exercised for real rather than always being + * skipped via REES_POSTHOG_VALIDATE_RELEASE=0. Mirrors the pre-Sentry-removal sentryApiServer() test helper + * this file's deleted predecessor (sentry-upload.test.ts) used for the identical purpose. */ +async function postHogApiServer(options: { failFirstAttempt?: boolean } = {}) { + const seen: string[] = []; + let reads = 0; + const server = createServer((req, res) => { + seen.push(req.url ?? ""); + res.setHeader("content-type", "application/json"); + reads += 1; + if (options.failFirstAttempt && reads === 1) { + res.statusCode = 500; + res.end(JSON.stringify({ detail: "internal error" })); + return; + } + res.end(JSON.stringify({ results: [{ release: "loopover-rees@abc123", failure_reason: null }] })); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + assert(address && typeof address === "object"); + return { + url: `http://127.0.0.1:${address.port}`, + seen, + close: () => new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose()))), + }; +} + async function runUploadSourcemaps(env: NodeJS.ProcessEnv) { return new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveRun, reject) => { const child = spawn(process.execPath, ["dist/upload-sourcemaps.js"], { @@ -92,6 +123,42 @@ test("upload-sourcemaps calls posthog-cli inject then upload with an explicit -- assert.match(result.stdout, /rees_posthog_sourcemap_upload_complete/); }); +test("upload-sourcemaps logs posthog-cli's own stdout/stderr when it writes any", async () => { + const { cliPath, logPath } = postHogCliStub({ echoOutput: "posthog-cli: uploaded 3 sourcemaps" }); + + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /rees_posthog_cli.*uploaded 3 sourcemaps/); + assert.equal(readFileSync(logPath, "utf8").trim().split("\n").length, 2); +}); + +test("upload-sourcemaps falls back to the default node_modules/.bin/posthog-cli path when POSTHOG_CLI_PATH is blank", async () => { + const result = await runUploadSourcemaps({ + ...process.env, + // Blank, not unset: nonBlank() treats "" the same as absent, forcing postHogCliPath()'s + // resolve(appDir, "node_modules/.bin/posthog-cli") fallback -- which genuinely doesn't exist in this + // standalone package's node_modules (no real @posthog/cli binary installed here), so spawnSync fails + // to launch at all (ENOENT), giving undefined stdout/stderr for free on the same call. + POSTHOG_CLI_PATH: "", + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /rees_posthog_sourcemap_upload_failed/); + assert.match(result.stderr, /posthog-cli.*failed/); +}); + test("upload-sourcemaps propagates a strict posthog-cli failure as a hard failure and exits 1", async () => { const dir = mkdtempSync(resolve(tmpdir(), "rees-posthog-cli-fail-")); const cliPath = resolve(dir, "posthog-cli"); @@ -130,3 +197,214 @@ test("upload-sourcemaps treats a non-strict posthog-cli failure as a soft failur assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stderr, /rees_posthog_sourcemap_upload_failed/); }); + +test("upload-sourcemaps runs real release validation once and succeeds on the first attempt", async () => { + const { cliPath, logPath } = postHogCliStub(); + const api = await postHogApiServer(); + try { + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_CLI_HOST: api.url, + POSTHOG_RELEASE: "loopover-rees@abc123", + // Deliberately unset (not "1"): shouldValidateRelease() defaults to enabled when the env var is + // absent, so leaving it out exercises that `?? ""` fallback while still running real validation. + REES_POSTHOG_VALIDATE_RELEASE: undefined, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /rees_posthog_release_validation_complete|rees_posthog_sourcemap_upload_complete/); + assert.equal(api.seen.length, 1); + // The CLI itself still ran (inject/upload), independent of the validation step's own separate real HTTP call. + assert.equal(readFileSync(logPath, "utf8").trim().split("\n").length, 2); + } finally { + await api.close(); + } +}); + +test("upload-sourcemaps retries real release validation until it succeeds, logging a retry warning", async () => { + const { cliPath } = postHogCliStub(); + const api = await postHogApiServer({ failFirstAttempt: true }); + try { + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_CLI_HOST: api.url, + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "1", + REES_POSTHOG_VALIDATE_ATTEMPTS: "3", + // Nonzero (not "0") so the real `await sleep(retryDelayMs)` call between attempts is actually + // exercised -- 1ms keeps the test fast while still hitting that branch instead of skipping it. + REES_POSTHOG_VALIDATE_RETRY_DELAY_MS: "1", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /rees_posthog_release_validation_retry/); + assert.equal(api.seen.length, 2); + } finally { + await api.close(); + } +}); + +// validateSourceMaps() runs against the REAL dist/server.js.map this file's own build just produced -- +// unlike discovery-index's vitest-based fs-mocking, subprocess-spawn tests can't inject a fake bad fixture +// without touching the real file, so these tests do exactly that: back up the real map, corrupt it, spawn, +// restore in a finally. Real code, real subprocess, no coverage gap on validateSourceMaps()'s failure paths. +const REAL_SERVER_MAP = resolve(import.meta.dirname, "..", "dist", "server.js.map"); + +async function withCorruptedServerMap(badMap: unknown, run: () => Promise): Promise { + const original = readFileSync(REAL_SERVER_MAP, "utf8"); + writeFileSync(REAL_SERVER_MAP, JSON.stringify(badMap)); + try { + await run(); + } finally { + writeFileSync(REAL_SERVER_MAP, original); + } +} + +test("upload-sourcemaps fails when the source map has no original sources", async () => { + await withCorruptedServerMap({ sources: [], sourcesContent: [] }, async () => { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /has no original sources/); + }); +}); + +test("upload-sourcemaps fails when sourcesContent doesn't match the sources length", async () => { + await withCorruptedServerMap({ sources: ["../src/server.ts"], sourcesContent: [] }, async () => { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /does not embed sourcesContent for every source/); + }); +}); + +test("upload-sourcemaps fails when sourcesContent is present but entirely blank", async () => { + await withCorruptedServerMap({ sources: ["../src/server.ts"], sourcesContent: [" "] }, async () => { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /has empty sourcesContent/); + }); +}); + +test("upload-sourcemaps fails when no source map includes src/server.ts", async () => { + await withCorruptedServerMap({ sources: ["../src/other.ts"], sourcesContent: ["export const y = 1;"] }, async () => { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /source maps do not include src\/server\.ts/); + }); +}); + +test("upload-sourcemaps fails when dist/server.js is missing", async () => { + const REAL_SERVER_JS = resolve(import.meta.dirname, "..", "dist", "server.js"); + const backupPath = `${REAL_SERVER_JS}.bak`; + renameSync(REAL_SERVER_JS, backupPath); + try { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /dist\/server\.js is missing/); + } finally { + renameSync(backupPath, REAL_SERVER_JS); + } +}); + +test("upload-sourcemaps fails when dist/server.js.map is missing", async () => { + const backupPath = `${REAL_SERVER_MAP}.bak`; + renameSync(REAL_SERVER_MAP, backupPath); + try { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /dist\/server\.js\.map is missing/); + } finally { + renameSync(backupPath, REAL_SERVER_MAP); + } +}); + +test("upload-sourcemaps fails when the server bundle is missing its sourceMappingURL comment", async () => { + const REAL_SERVER_JS = resolve(import.meta.dirname, "..", "dist", "server.js"); + const originalBundle = readFileSync(REAL_SERVER_JS, "utf8"); + writeFileSync(REAL_SERVER_JS, originalBundle.replace(/\/\/# sourceMappingURL=server\.js\.map\s*$/, "")); + try { + const { cliPath } = postHogCliStub(); + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "0", + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /missing the server\.js\.map sourceMappingURL/); + } finally { + writeFileSync(REAL_SERVER_JS, originalBundle); + } +}); + +test("upload-sourcemaps exhausts real release-validation attempts and fails strictly (exit 1)", async () => { + const { cliPath } = postHogCliStub(); + // No real server at all: every validation attempt hits connection-refused, matching the exhaustion path. + const result = await runUploadSourcemaps({ + ...process.env, + POSTHOG_CLI_PATH: cliPath, + POSTHOG_CLI_API_KEY: "phx_test", + POSTHOG_CLI_PROJECT_ID: "42", + POSTHOG_CLI_HOST: "http://127.0.0.1:1", + POSTHOG_RELEASE: "loopover-rees@abc123", + REES_POSTHOG_VALIDATE_RELEASE: "1", + REES_POSTHOG_VALIDATE_ATTEMPTS: "2", + REES_POSTHOG_VALIDATE_RETRY_DELAY_MS: "0", + REES_POSTHOG_UPLOAD_STRICT: "true", + }); + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /rees_posthog_sourcemap_upload_failed/); +});