From f00a079a2e9dc70171b0a1f4471dd3072ec05802 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:20:57 -0700 Subject: [PATCH] fix(rees): upload Sentry source maps for Railway deploys --- .../contributing-to-gittensory/SKILL.md | 2 +- .../contributing-to-gittensory/reference.md | 4 +- .github/workflows/ci.yml | 27 +- .gittensory.yml | 1 + package.json | 5 +- review-enrichment/Dockerfile | 12 +- review-enrichment/README.md | 42 ++ review-enrichment/package-lock.json | 626 ++++++++++++++++++ review-enrichment/package.json | 9 +- .../scripts/validate-sourcemaps.mjs | 63 ++ review-enrichment/src/sentry.ts | 89 +++ review-enrichment/src/server.ts | 29 + review-enrichment/src/upload-sourcemaps.ts | 171 +++++ review-enrichment/test/sentry-upload.test.ts | 86 +++ review-enrichment/tsconfig.json | 3 +- src/config/gittensory-repo-focus-manifest.ts | 1 + test/unit/gittensory-focus-manifest.test.ts | 1 + 17 files changed, 1157 insertions(+), 14 deletions(-) create mode 100644 review-enrichment/scripts/validate-sourcemaps.mjs create mode 100644 review-enrichment/src/sentry.ts create mode 100644 review-enrichment/src/upload-sourcemaps.ts create mode 100644 review-enrichment/test/sentry-upload.test.ts diff --git a/.claude/skills/contributing-to-gittensory/SKILL.md b/.claude/skills/contributing-to-gittensory/SKILL.md index 19ae5ab806..d0eb44f82f 100644 --- a/.claude/skills/contributing-to-gittensory/SKILL.md +++ b/.claude/skills/contributing-to-gittensory/SKILL.md @@ -124,7 +124,7 @@ pasted JSON block will not work in Codex). You'll use these tools in Phases 1 an - **Run the pre-start checks** via MCP: `gittensory_check_before_start` (is it claimed / a duplicate cluster / already solved?) and, if linking an issue, `gittensory_validate_linked_issue`. - **Stay in scope.** The gate's `wantedPaths` are `src/`, `packages/`, `test/`, `migrations/`, - `scripts/`, `.github/workflows/`, `wrangler.jsonc`, `apps/gittensory-ui/`. Avoid `blockedPaths` + `scripts/`, `review-enrichment/`, `.github/workflows/`, `wrangler.jsonc`, `apps/gittensory-ui/`. Avoid `blockedPaths` (`site/`, `CNAME`, `**/lovable/**`). Keep the PR narrow — one coherent change. --- diff --git a/.claude/skills/contributing-to-gittensory/reference.md b/.claude/skills/contributing-to-gittensory/reference.md index a5c7b460fc..6666fb3421 100644 --- a/.claude/skills/contributing-to-gittensory/reference.md +++ b/.claude/skills/contributing-to-gittensory/reference.md @@ -91,8 +91,8 @@ Implications for you: `.gittensory.yml` (the public config you can predict against) sets the gate *modes* (`linkedIssue: advisory`, `duplicates: block`, `readiness: advisory/60`, AI review off) and the focus manifest -(`wantedPaths`: `src/ packages/ test/ migrations/ scripts/ .github/workflows/ wrangler.jsonc -apps/gittensory-ui/`; `blockedPaths`: `site/ CNAME **/lovable/**`; `linkedIssuePolicy: preferred`; +(`wantedPaths`: `src/ packages/ test/ migrations/ scripts/ review-enrichment/ .github/workflows/ +wrangler.jsonc apps/gittensory-ui/`; `blockedPaths`: `site/ CNAME **/lovable/**`; `linkedIssuePolicy: preferred`; `testExpectations: npm run test:ci`). But the **modes are inputs to the disposition above** — the engine still auto-merges the clean case and auto-closes the adverse case. The MCP `predict_gate` uses the public config + safe defaults; a clean prediction is necessary but not sufficient (it can't see diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67d75441e7..3d92db831e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: backend: ${{ steps.filter.outputs.backend }} ui: ${{ steps.filter.outputs.ui }} mcp: ${{ steps.filter.outputs.mcp }} + rees: ${{ steps.filter.outputs.rees }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -65,6 +66,9 @@ jobs: - 'packages/gittensory-mcp/**' - 'scripts/check-mcp-package.mjs' - 'package-lock.json' + rees: + - 'review-enrichment/**' + - '.github/workflows/ci.yml' # Fast-failing checks first: workflow lint + typecheck. lint: @@ -305,6 +309,27 @@ jobs: - name: MCP package check run: npm run test:mcp-pack + # Standalone Railway review-enrichment service. + rees: + name: rees + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} + runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }} + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: review-enrichment/package-lock.json + - name: REES build, source-map validation, and tests + run: npm run rees:test + # UI checks: each split into its own step for legible failures. ui: name: ui @@ -373,7 +398,7 @@ jobs: # Path-filtered jobs report "skipped", which is treated as success. validate: name: validate - needs: [changes, lint, test, coverage-upload, workers, mcp, ui, security] + needs: [changes, lint, test, coverage-upload, workers, mcp, rees, ui, security] if: ${{ always() }} runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }} timeout-minutes: 2 diff --git a/.gittensory.yml b/.gittensory.yml index 8c9aa0954f..de51b2d149 100644 --- a/.gittensory.yml +++ b/.gittensory.yml @@ -9,6 +9,7 @@ wantedPaths: - test/ - migrations/ - scripts/ + - review-enrichment/ - .github/workflows/ - wrangler.jsonc - apps/gittensory-ui/ diff --git a/package.json b/package.json index 42b76fe873..e250774d21 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "drizzle:generate": "drizzle-kit generate", "build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build", "test:mcp-pack": "node scripts/check-mcp-package.mjs", + "rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund", + "rees:test": "npm run rees:install && npm --prefix review-enrichment test", + "rees:validate-sourcemaps": "npm --prefix review-enrichment run validate:sourcemaps", "db:migrations:check": "node scripts/check-migrations.mjs", "actionlint": "node scripts/actionlint.mjs", "ui:dev": "npm run ui:preview", @@ -53,7 +56,7 @@ "test:smoke:production": "node scripts/smoke-production.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:version-audit && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci && npm run changelog:check:mcp", "test:watch": "vitest", diff --git a/review-enrichment/Dockerfile b/review-enrichment/Dockerfile index c7f2cab3f9..fce8a8849b 100644 --- a/review-enrichment/Dockerfile +++ b/review-enrichment/Dockerfile @@ -6,7 +6,8 @@ COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY src ./src -RUN npm run build && npm prune --omit=dev +COPY scripts ./scripts +RUN npm run build && npm run validate:sourcemaps && npm prune --omit=dev FROM node:22-slim AS runtime # Least privilege: run as a non-root user. @@ -14,10 +15,11 @@ RUN useradd --create-home --uid 10001 rees WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist -COPY --from=build /app/package.json ./package.json +COPY --from=build --chown=rees:rees /app/dist ./dist +COPY --from=build --chown=rees:rees /app/package.json ./package.json USER rees ENV PORT=8080 EXPOSE 8080 -# Provide at runtime (NOT baked into the image): REES_SHARED_SECRET (shared bearer with the engine). -CMD ["node", "dist/server.js"] +# 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. +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 5804c86f7a..9a9cd0bc9d 100644 --- a/review-enrichment/README.md +++ b/review-enrichment/README.md @@ -43,3 +43,45 @@ Separate service from the engine. Set **Root Directory = `review-enrichment`** s `railway.json` + `Dockerfile`. 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_REPOSITORY` | Optional; defaults to `JSONbored/gittensory` for commit association. | +| `REES_SENTRY_UPLOAD_STRICT` | Optional. Set `true` to fail startup if source-map upload fails. | + +By default the release id is `gittensory-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. + +If Sentry still shows frames such as `/app/dist/server.js`, check: + +1. The event's `release` is `gittensory-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. The Sentry code mapping is `/app` → `review-enrichment` on branch `main`. +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 5175775c88..5d4b500539 100644 --- a/review-enrichment/package-lock.json +++ b/review-enrichment/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "^1.13.7", + "@sentry/cli": "^3.6.0", + "@sentry/node": "^10.62.0", "hono": "^4.6.14" }, "devDependencies": { @@ -19,6 +21,49 @@ "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/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -31,6 +76,389 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "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/@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==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.6.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.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.62.0.tgz", + "integrity": "sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==", + "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/core": "10.62.0", + "@sentry/node-core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "@sentry/server-utils": "10.62.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.62.0.tgz", + "integrity": "sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "import-in-the-middle": "^3.0.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_modules/@sentry/opentelemetry": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.62.0.tgz", + "integrity": "sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.62.0.tgz", + "integrity": "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==", + "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.62.0", + "magic-string": "~0.30.0" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -41,6 +469,86 @@ "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/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/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/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/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/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/hono": { "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", @@ -50,6 +558,100 @@ "node": ">=16.9.0" } }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "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/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/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/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-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/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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -64,12 +666,36 @@ "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", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } } } } diff --git a/review-enrichment/package.json b/review-enrichment/package.json index b0468948da..0b919e0d61 100644 --- a/review-enrichment/package.json +++ b/review-enrichment/package.json @@ -9,16 +9,19 @@ }, "scripts": { "build": "tsc -p tsconfig.json", + "validate:sourcemaps": "node scripts/validate-sourcemaps.mjs", "start": "node dist/server.js", "dev": "node --experimental-strip-types --watch src/server.ts", - "test": "npm run build && node --test --experimental-strip-types \"test/**/*.test.ts\"" + "test": "npm run build && npm run validate:sourcemaps && node --test --experimental-strip-types \"test/**/*.test.ts\"" }, "dependencies": { "@hono/node-server": "^1.13.7", + "@sentry/cli": "^3.6.0", + "@sentry/node": "^10.62.0", "hono": "^4.6.14" }, "devDependencies": { - "typescript": "^5.7.2", - "@types/node": "^22.10.2" + "@types/node": "^22.10.2", + "typescript": "^5.7.2" } } diff --git a/review-enrichment/scripts/validate-sourcemaps.mjs b/review-enrichment/scripts/validate-sourcemaps.mjs new file mode 100644 index 0000000000..610cd3a7db --- /dev/null +++ b/review-enrichment/scripts/validate-sourcemaps.mjs @@ -0,0 +1,63 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = resolve(root, "dist"); + +function fail(message) { + console.error(message); + process.exit(1); +} + +function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +if (!existsSync(distDir)) fail("review-enrichment/dist is missing; run npm run build first"); + +const serverBundle = resolve(distDir, "server.js"); +const serverMap = resolve(distDir, "server.js.map"); +if (!existsSync(serverBundle)) fail("review-enrichment/dist/server.js is missing"); +if (!existsSync(serverMap)) fail("review-enrichment/dist/server.js.map is missing"); + +const bundle = readFileSync(serverBundle, "utf8"); +if (!bundle.includes("//# sourceMappingURL=server.js.map")) { + fail("review-enrichment/dist/server.js is missing the server.js.map sourceMappingURL"); +} + +const maps = listFiles(distDir).filter((path) => path.endsWith(".js.map")); +if (maps.length === 0) fail("review-enrichment/dist has no JavaScript source maps"); + +let sawServerSource = false; +for (const path of maps) { + let map; + try { + map = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`${relative(root, path)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!Array.isArray(map.sources) || map.sources.length === 0) { + fail(`${relative(root, path)} has no original sources`); + } + if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + fail(`${relative(root, path)} does not embed sourcesContent for every source`); + } + if (!map.sourcesContent.some((source) => typeof source === "string" && source.trim().length > 0)) { + fail(`${relative(root, path)} has empty sourcesContent`); + } + if (map.sources.some((source) => String(source).replaceAll("\\", "/").endsWith("src/server.ts"))) { + sawServerSource = true; + } +} + +if (!sawServerSource) fail("review-enrichment source maps do not include src/server.ts"); + +console.log(`validated ${maps.length} REES source map${maps.length === 1 ? "" : "s"}`); diff --git a/review-enrichment/src/sentry.ts b/review-enrichment/src/sentry.ts new file mode 100644 index 0000000000..9730d20d2a --- /dev/null +++ b/review-enrichment/src/sentry.ts @@ -0,0 +1,89 @@ +import type { ErrorEvent, EventHint } from "@sentry/node"; + +type SentryNs = typeof import("@sentry/node"); + +let Sentry: SentryNs | undefined; +let active = false; + +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; + +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) + ? `gittensory-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 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"); + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: resolveSentryEnvironment(env), + release: resolveReesSentryRelease(env), + tracesSampleRate: resolveTracesSampleRate(env), + beforeSend: (event: ErrorEvent, _hint: EventHint) => scrubEvent(event), + }); + active = true; + return true; + } catch (error) { + active = false; + Sentry = undefined; + warn("rees_sentry_init_failed", { message: error instanceof Error ? error.message : String(error) }); + return false; + } +} + +export function captureError(error: unknown, context?: Record): void { + if (!active || !Sentry) return; + Sentry.withScope((scope) => { + if (context) scope.setContext("rees", scrubValue(context) as Record); + Sentry!.captureException(error instanceof Error ? error : new Error(String(error))); + }); +} + +export async function flushSentry(timeoutMs = 2000): Promise { + if (!active || !Sentry) return; + await Sentry.flush(timeoutMs).catch(() => undefined); +} diff --git a/review-enrichment/src/server.ts b/review-enrichment/src/server.ts index ea41382444..6635419fe7 100644 --- a/review-enrichment/src/server.ts +++ b/review-enrichment/src/server.ts @@ -12,14 +12,30 @@ import { Hono } from "hono"; import { verifyBearer } from "./auth.js"; import type { EnrichRequest } from "./types.js"; import { buildBrief } from "./brief.js"; +import { captureError, flushSentry, initSentry, resolveSentryEnvironment } from "./sentry.js"; const app = new Hono(); +const sentryEnabled = await initSentry(process.env); + +if (sentryEnabled) { + console.log( + JSON.stringify({ + event: "rees_sentry", + environment: resolveSentryEnvironment(process.env), + }), + ); +} app.get("/health", (c) => c.json({ status: "ok", service: "review-enrichment" }), ); app.get("/ready", (c) => c.json({ ready: true })); +app.onError((error, c) => { + captureError(error, { method: c.req.method, path: c.req.path }); + return c.json({ error: "internal_error" }, 500); +}); + app.post("/v1/enrich", async (c) => { const secret = process.env.REES_SHARED_SECRET; // No secret configured ⇒ the service is not ready to authenticate anything; fail closed. @@ -47,4 +63,17 @@ serve({ fetch: app.fetch, port }, (info) => { console.log(JSON.stringify({ event: "rees_listening", port: info.port })); }); +process.on("unhandledRejection", (reason) => { + captureError(reason, { event: "unhandled_rejection" }); +}); + +process.on("uncaughtException", (error) => { + captureError(error, { event: "uncaught_exception" }); + void flushSentry().finally(() => process.exit(1)); +}); + +process.on("SIGTERM", () => { + void flushSentry().finally(() => process.exit(0)); +}); + export { app }; diff --git a/review-enrichment/src/upload-sourcemaps.ts b/review-enrichment/src/upload-sourcemaps.ts new file mode 100644 index 0000000000..18e3dfc019 --- /dev/null +++ b/review-enrichment/src/upload-sourcemaps.ts @@ -0,0 +1,171 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveReesSentryRelease, resolveSentryEnvironment } from "./sentry.js"; + +type RunOptions = { + allowExistingRelease?: boolean; + allowFailure?: boolean; +}; + +const distDir = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(distDir, ".."); + +function nonBlank(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +function log(event: string, fields: Record = {}): void { + console.log(JSON.stringify({ event, ...fields })); +} + +function warn(event: string, fields: Record = {}): void { + console.error(JSON.stringify({ level: "warn", event, ...fields })); +} + +function listFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : [path]; + }) + .filter((path) => statSync(path).isFile()) + .sort(); +} + +function validateSourceMaps(): void { + const serverBundle = resolve(distDir, "server.js"); + const serverMap = resolve(distDir, "server.js.map"); + if (!existsSync(serverBundle)) throw new Error("dist/server.js is missing"); + if (!existsSync(serverMap)) throw new Error("dist/server.js.map is missing"); + if (!readFileSync(serverBundle, "utf8").includes("//# sourceMappingURL=server.js.map")) { + throw new Error("dist/server.js is missing the server.js.map sourceMappingURL"); + } + + const maps = listFiles(distDir).filter((path) => path.endsWith(".js.map")); + if (maps.length === 0) throw new Error("dist has no JavaScript source maps"); + + let sawServerSource = false; + for (const path of maps) { + const map = JSON.parse(readFileSync(path, "utf8")) as { + sources?: unknown; + sourcesContent?: unknown; + }; + const label = relative(appDir, path); + if (!Array.isArray(map.sources) || map.sources.length === 0) { + throw new Error(`${label} has no original sources`); + } + if (!Array.isArray(map.sourcesContent) || map.sourcesContent.length !== map.sources.length) { + throw new Error(`${label} does not embed sourcesContent for every source`); + } + if (!map.sourcesContent.some((source) => typeof source === "string" && source.trim().length > 0)) { + throw new Error(`${label} has empty sourcesContent`); + } + if (map.sources.some((source) => String(source).replaceAll("\\", "/").endsWith("src/server.ts"))) { + sawServerSource = true; + } + } + if (!sawServerSource) throw new Error("source maps do not include src/server.ts"); +} + +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)}`); +} + +async function main(): Promise { + const release = resolveReesSentryRelease(process.env); + const required = { + SENTRY_AUTH_TOKEN: nonBlank(process.env.SENTRY_AUTH_TOKEN), + SENTRY_ORG: nonBlank(process.env.SENTRY_ORG), + SENTRY_PROJECT: nonBlank(process.env.SENTRY_PROJECT), + SENTRY_RELEASE: release, + }; + const missing = Object.entries(required) + .filter(([, value]) => !value) + .map(([key]) => key); + if (missing.length > 0) { + log("rees_sentry_sourcemap_upload_skipped", { reason: "missing_config", missing }); + return 0; + } + + const strict = /^(1|true|yes|on)$/i.test(process.env.REES_SENTRY_UPLOAD_STRICT ?? ""); + try { + validateSourceMaps(); + const projectArgs = ["--org", required.SENTRY_ORG!, "--project", required.SENTRY_PROJECT!]; + runSentry(["releases", ...projectArgs, "new", release!], { allowExistingRelease: true }); + + const sha = nonBlank(process.env.SENTRY_COMMIT_SHA) ?? nonBlank(process.env.RAILWAY_GIT_COMMIT_SHA); + if (sha) { + const repo = nonBlank(process.env.SENTRY_REPOSITORY) ?? "JSONbored/gittensory"; + const previous = nonBlank(process.env.SENTRY_PREVIOUS_COMMIT_SHA); + const spec = previous ? `${repo}@${previous}..${sha}` : `${repo}@${sha}`; + runSentry(["releases", ...projectArgs, "set-commits", release!, "--commit", spec, "--ignore-missing"], { + allowFailure: !strict, + }); + } + + runSentry(["sourcemaps", ...projectArgs, "inject", "dist"]); + validateSourceMaps(); + runSentry([ + "sourcemaps", + ...projectArgs, + "upload", + "--release", + release!, + "--validate", + "--wait", + ...(strict ? ["--strict"] : []), + "dist", + ]); + runSentry([ + "releases", + ...projectArgs, + "deploys", + "new", + "--release", + release!, + "--env", + resolveSentryEnvironment(process.env), + "--name", + nonBlank(process.env.RAILWAY_DEPLOYMENT_ID) ?? "railway", + ]); + runSentry(["releases", ...projectArgs, "finalize", release!]); + log("rees_sentry_sourcemap_upload_complete", { release }); + return 0; + } catch (error) { + warn("rees_sentry_sourcemap_upload_failed", { + release, + message: error instanceof Error ? error.message : String(error), + strict, + }); + return strict ? 1 : 0; + } +} + +process.exitCode = await main(); diff --git a/review-enrichment/test/sentry-upload.test.ts b/review-enrichment/test/sentry-upload.test.ts new file mode 100644 index 0000000000..af99869b69 --- /dev/null +++ b/review-enrichment/test/sentry-upload.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { spawnSync } 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 { 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" }), "gittensory-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); +}); + +test("upload-sourcemaps calls Sentry CLI with release association on upload", () => { + 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); + + const result = spawnSync(process.execPath, ["dist/upload-sourcemaps.js"], { + cwd: resolve(import.meta.dirname, ".."), + env: { + ...process.env, + SENTRY_AUTH_TOKEN: "test-token", + SENTRY_ORG: "jsonbored", + SENTRY_PROJECT: "rees", + SENTRY_CLI_PATH: cliPath, + RAILWAY_GIT_COMMIT_SHA: "abc123", + RAILWAY_DEPLOYMENT_ID: "deploy-1", + RAILWAY_ENVIRONMENT_NAME: "production", + REES_SENTRY_UPLOAD_STRICT: "true", + }, + encoding: "utf8", + }); + + 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", + "gittensory-rees@abc123", + ]); + assert.deepEqual(calls[2], ["sourcemaps", "--org", "jsonbored", "--project", "rees", "inject", "dist"]); + assert.deepEqual(calls[3], [ + "sourcemaps", + "--org", + "jsonbored", + "--project", + "rees", + "upload", + "--release", + "gittensory-rees@abc123", + "--validate", + "--wait", + "--strict", + "dist", + ]); +}); diff --git a/review-enrichment/tsconfig.json b/review-enrichment/tsconfig.json index 164fc3bc8a..eacb4ba98d 100644 --- a/review-enrichment/tsconfig.json +++ b/review-enrichment/tsconfig.json @@ -10,7 +10,8 @@ "esModuleInterop": true, "skipLibCheck": true, "declaration": false, - "sourceMap": true + "sourceMap": true, + "inlineSources": true }, "include": ["src"] } diff --git a/src/config/gittensory-repo-focus-manifest.ts b/src/config/gittensory-repo-focus-manifest.ts index 96a200c0d0..ff3ed134d5 100644 --- a/src/config/gittensory-repo-focus-manifest.ts +++ b/src/config/gittensory-repo-focus-manifest.ts @@ -13,6 +13,7 @@ wantedPaths: - test/ - migrations/ - scripts/ + - review-enrichment/ - .github/workflows/ - wrangler.jsonc - apps/gittensory-ui/ diff --git a/test/unit/gittensory-focus-manifest.test.ts b/test/unit/gittensory-focus-manifest.test.ts index 89792b1264..83ba243659 100644 --- a/test/unit/gittensory-focus-manifest.test.ts +++ b/test/unit/gittensory-focus-manifest.test.ts @@ -41,6 +41,7 @@ describe("Gittensory repo focus manifest", () => { const manifest = parseFocusManifestContent(GITTENSORY_REPO_FOCUS_MANIFEST_YAML, "repo_file"); expect(manifest.present).toBe(true); expect(manifest.wantedPaths).toContain("src/"); + expect(manifest.wantedPaths).toContain("review-enrichment/"); expect(manifest.wantedPaths).toContain("apps/gittensory-ui/"); expect(manifest.blockedPaths).toContain("site/"); expect(manifest.blockedPaths).not.toContain("apps/gittensory-ui/");