diff --git a/package-lock.json b/package-lock.json index f43fadd4a3..2e6c5a1f38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@hono/node-server": "^2.0.6", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", + "@sentry/node": "^10.62.0", "agents": "^0.16.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.26", @@ -404,6 +405,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -2982,11 +3026,96 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "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/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -4800,6 +4929,118 @@ "dev": true, "license": "MIT" }, + "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/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -5915,7 +6156,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -6495,6 +6735,15 @@ "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/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6809,6 +7058,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8312,7 +8570,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -8652,7 +8909,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -9923,6 +10179,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/import-in-the-middle/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/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -11024,6 +11301,15 @@ "node": ">= 8" } }, + "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/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -11219,6 +11505,12 @@ "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/motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", @@ -12884,6 +13176,19 @@ "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/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -13101,6 +13406,12 @@ "node": ">= 6" } }, + "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.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -13454,7 +13765,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 7f34039a5e..42b76fe873 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@hono/node-server": "^2.0.6", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", + "@sentry/node": "^10.62.0", "agents": "^0.16.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.26", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 04e7fc4771..385fd80027 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -321,6 +321,7 @@ import { buildReviewEnrichment, isEnrichmentEnabled, } from "../review/enrichment-wire"; +import { captureReviewFailure } from "../selfhost/sentry"; import { evaluateWithSurfaceLane } from "../review/content-lane-wire"; import { indexRepo, reindexChangedPaths } from "../review/rag-index"; import { @@ -1283,7 +1284,10 @@ async function maybeRunAgentMaintenance( // Contributor blacklist (#1425): resolve whether the PR author is on the repo's blacklist (the shared/global // list unions in once its table lands). A match short-circuits the planner to a deterministic label + close // ahead of merit/CI/AI; only the configured label (default "slop") reaches public actions. - const blacklistEntry = findBlacklistEntry(pr.authorLogin, settings.contributorBlacklist); + const blacklistEntry = findBlacklistEntry( + pr.authorLogin, + settings.contributorBlacklist, + ); const planned = planAgentMaintenanceActions({ conclusion: gate.conclusion, @@ -3608,6 +3612,12 @@ export async function runAiReviewForAdvisory( error: errorMessage(error), }), ); + captureReviewFailure(error, { + kind: "review", + repo: args.repoFullName, + pr: args.pr.number, + head_sha: args.advisory.headSha, + }); return undefined; } } diff --git a/src/selfhost/pg-queue.ts b/src/selfhost/pg-queue.ts index 163a087be9..37bd6ed2ad 100644 --- a/src/selfhost/pg-queue.ts +++ b/src/selfhost/pg-queue.ts @@ -5,6 +5,7 @@ import type { Pool } from "pg"; import { logAudit, extractPayloadType } from "./audit"; import { incr } from "./metrics"; +import { captureError } from "./sentry"; import type { JobMessage } from "../types"; const TABLE = "_selfhost_jobs"; @@ -46,11 +47,19 @@ export interface PgQueueOptions { concurrency?: number; } -export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Promise, opts: PgQueueOptions = {}): PgDurableQueue { +export function createPgQueue( + pool: Pool, + consume: (message: JobMessage) => Promise, + opts: PgQueueOptions = {}, +): PgDurableQueue { const maxRetries = opts.maxRetries ?? 5; const pollIntervalMs = opts.pollIntervalMs ?? 1000; - const backoff = opts.backoffMs ?? ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); - const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); + const backoff = + opts.backoffMs ?? + ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); + const concurrency = + opts.concurrency ?? + Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); let running = false; let active = 0; @@ -58,13 +67,27 @@ export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Prom async function init(): Promise { await pool.query(DDL); - const recovered = (await pool.query(`UPDATE ${TABLE} SET status='pending' WHERE status='processing'`)).rowCount ?? 0; - if (recovered) console.log(JSON.stringify({ event: "selfhost_queue_recovered", count: recovered })); + const recovered = + ( + await pool.query( + `UPDATE ${TABLE} SET status='pending' WHERE status='processing'`, + ) + ).rowCount ?? 0; + if (recovered) + console.log( + JSON.stringify({ event: "selfhost_queue_recovered", count: recovered }), + ); } - async function enqueue(message: JobMessage, delaySeconds: number): Promise { + async function enqueue( + message: JobMessage, + delaySeconds: number, + ): Promise { const now = Date.now(); - await pool.query(`INSERT INTO ${TABLE} (payload, status, attempts, run_after, created_at) VALUES ($1,'pending',0,$2,$3)`, [JSON.stringify(message), now + delaySeconds * 1000, now]); + await pool.query( + `INSERT INTO ${TABLE} (payload, status, attempts, run_after, created_at) VALUES ($1,'pending',0,$2,$3)`, + [JSON.stringify(message), now + delaySeconds * 1000, now], + ); incr("gittensory_jobs_enqueued_total"); void pump(); } @@ -88,28 +111,87 @@ export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Prom try { message = JSON.parse(job.payload) as JobMessage; } catch { - await pool.query(`UPDATE ${TABLE} SET status='dead', last_error='unparseable payload' WHERE id=$1`, [job.id]); + await pool.query( + `UPDATE ${TABLE} SET status='dead', last_error='unparseable payload' WHERE id=$1`, + [job.id], + ); incr("gittensory_jobs_dead_total"); - logAudit({ event: "job_dead", ts: Date.now(), job_id: job.id, latency_ms: Date.now() - claimedAt, attempts: Number(job.attempts) + 1, error: "unparseable payload" }); + logAudit({ + event: "job_dead", + ts: Date.now(), + job_id: job.id, + latency_ms: Date.now() - claimedAt, + attempts: Number(job.attempts) + 1, + error: "unparseable payload", + }); + captureError(new Error("unparseable queue payload"), { + kind: "job_dead", + reason: "unparseable_payload", + jobId: job.id, + }); return true; } try { await consume(message); await pool.query(`DELETE FROM ${TABLE} WHERE id=$1`, [job.id]); incr("gittensory_jobs_processed_total"); - logAudit({ event: "job_complete", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts: Number(job.attempts) + 1 }); + logAudit({ + event: "job_complete", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts: Number(job.attempts) + 1, + }); } catch (error) { const attempts = Number(job.attempts) + 1; const errMsg = error instanceof Error ? error.message : "unknown error"; incr("gittensory_jobs_failed_total"); if (attempts >= maxRetries) { - await pool.query(`UPDATE ${TABLE} SET status='dead', attempts=$1, last_error=$2 WHERE id=$3`, [attempts, errMsg, job.id]); + await pool.query( + `UPDATE ${TABLE} SET status='dead', attempts=$1, last_error=$2 WHERE id=$3`, + [attempts, errMsg, job.id], + ); incr("gittensory_jobs_dead_total"); - console.error(JSON.stringify({ level: "error", event: "selfhost_job_dead", id: job.id, attempts, error: errMsg })); - logAudit({ event: "job_dead", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts, error: errMsg }); + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_job_dead", + id: job.id, + attempts, + error: errMsg, + }), + ); + logAudit({ + event: "job_dead", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts, + error: errMsg, + }); + captureError(error, { + kind: "job_dead", + reason: "max_retries_exhausted", + jobType: extractPayloadType(job.payload), + jobId: job.id, + attempts, + }); } else { - await pool.query(`UPDATE ${TABLE} SET status='pending', attempts=$1, run_after=$2, last_error=$3 WHERE id=$4`, [attempts, Date.now() + backoff(attempts), errMsg, job.id]); - logAudit({ event: "job_error", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts, error: errMsg }); + await pool.query( + `UPDATE ${TABLE} SET status='pending', attempts=$1, run_after=$2, last_error=$3 WHERE id=$4`, + [attempts, Date.now() + backoff(attempts), errMsg, job.id], + ); + logAudit({ + event: "job_error", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts, + error: errMsg, + }); } } return true; @@ -128,10 +210,15 @@ export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Prom } const binding = { - async send(message: JobMessage, options?: { delaySeconds?: number }): Promise { + async send( + message: JobMessage, + options?: { delaySeconds?: number }, + ): Promise { await enqueue(message, options?.delaySeconds ?? 0); }, - async sendBatch(messages: Iterable<{ body: JobMessage; delaySeconds?: number }>): Promise { + async sendBatch( + messages: Iterable<{ body: JobMessage; delaySeconds?: number }>, + ): Promise { for (const m of messages) await enqueue(m.body, m.delaySeconds ?? 0); }, } as unknown as Queue; @@ -161,10 +248,22 @@ export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Prom await pump(); }, async size() { - return Number((await pool.query(`SELECT COUNT(*) AS c FROM ${TABLE} WHERE status IN ('pending','processing')`)).rows[0].c); + return Number( + ( + await pool.query( + `SELECT COUNT(*) AS c FROM ${TABLE} WHERE status IN ('pending','processing')`, + ) + ).rows[0].c, + ); }, async deadCount() { - return Number((await pool.query(`SELECT COUNT(*) AS c FROM ${TABLE} WHERE status='dead'`)).rows[0].c); + return Number( + ( + await pool.query( + `SELECT COUNT(*) AS c FROM ${TABLE} WHERE status='dead'`, + ) + ).rows[0].c, + ); }, }; } diff --git a/src/selfhost/sentry.ts b/src/selfhost/sentry.ts new file mode 100644 index 0000000000..7eee1af433 --- /dev/null +++ b/src/selfhost/sentry.ts @@ -0,0 +1,99 @@ +// Self-host-only error tracking (#1468). Opt-in: a complete NO-OP when SENTRY_DSN is unset, mirroring the +// env-gated, dynamically-imported selfhost-integration pattern (Redis/Qdrant/embed-provider in server.ts). +// @sentry/node is NEVER imported at module top level — it loads lazily inside initSentry(), so it never enters +// the Worker bundle (src/index.ts) and cloudflare:* stubbing stays clean. All helpers are safe to call when off. +type SentryNs = typeof import("@sentry/node"); +let Sentry: SentryNs | undefined; +let active = false; + +const SECRET_KEY = + /(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private)/i; + +/** beforeSend scrubber — redact anything token/secret-like before an event leaves the box (privacy boundary). */ +export function scrubEvent(event: T): T { + const redact = (obj: unknown, depth: number): void => { + if (!obj || typeof obj !== "object" || depth > 6) return; + for (const key of Object.keys(obj as Record)) { + const rec = obj as Record; + if (SECRET_KEY.test(key)) rec[key] = "[redacted]"; + else if (typeof rec[key] === "object") redact(rec[key], depth + 1); + } + }; + try { + const e = event as { + request?: { headers?: unknown }; + contexts?: unknown; + extra?: unknown; + }; + redact(e.request?.headers, 0); + redact(e.contexts, 0); + redact(e.extra, 0); + } catch { + /* scrubbing must never break the send */ + } + return event; +} + +/** Initialize Sentry from the environment. Returns false (and stays a no-op) when SENTRY_DSN is unset. */ +export async function initSentry(env: NodeJS.ProcessEnv): Promise { + if (!env.SENTRY_DSN) return false; + Sentry = await import("@sentry/node"); + Sentry.init({ + dsn: env.SENTRY_DSN, + environment: env.SENTRY_ENVIRONMENT ?? "production", + release: env.SENTRY_RELEASE ?? env.GITTENSORY_VERSION, + tracesSampleRate: Number(env.SENTRY_TRACES_SAMPLE_RATE ?? "0"), + serverName: env.PUBLIC_API_ORIGIN, + beforeSend: (e) => scrubEvent(e), + }); + active = true; + return true; +} + +/** Capture an error with optional structured context. No-op when Sentry is off. */ +export function captureError( + error: unknown, + context?: Record, +): void { + if (!active || !Sentry) return; + Sentry.withScope((scope) => { + if (context) scope.setContext("gittensory", context); + Sentry!.captureException( + error instanceof Error ? error : new Error(String(error)), + ); + }); +} + +/** Capture a degraded/failed review at WARNING level, tagged by repo/PR/SHA for triage. No-op when off. */ +export function captureReviewFailure( + error: unknown, + context?: Record, +): void { + if (!active || !Sentry) return; + Sentry.withScope((scope) => { + scope.setLevel("warning"); + if (context) { + scope.setContext("review", context); + for (const tag of ["owner", "repo", "pr", "head_sha"]) { + const value = context[tag]; + if (value !== undefined && value !== null) + scope.setTag(tag, String(value)); + } + } + Sentry!.captureException( + error instanceof Error ? error : new Error(String(error)), + ); + }); +} + +/** Flush buffered events before exit. No-op when off. */ +export async function flushSentry(timeoutMs = 2000): Promise { + if (!active || !Sentry) return; + await Sentry.flush(timeoutMs).catch(() => undefined); +} + +/** Test-only: reset module state between cases. */ +export function resetSentryForTest(): void { + Sentry = undefined; + active = false; +} diff --git a/src/selfhost/sqlite-queue.ts b/src/selfhost/sqlite-queue.ts index 710d0b36b4..db162196f0 100644 --- a/src/selfhost/sqlite-queue.ts +++ b/src/selfhost/sqlite-queue.ts @@ -6,6 +6,7 @@ import type { SqliteDriver } from "./d1-adapter"; import { logAudit, extractPayloadType } from "./audit"; import { incr } from "./metrics"; +import { captureError } from "./sentry"; import type { JobMessage } from "../types"; const TABLE = "_selfhost_jobs"; @@ -46,33 +47,56 @@ export interface SqliteQueueOptions { concurrency?: number; } -export function createSqliteQueue(driver: SqliteDriver, consume: (message: JobMessage) => Promise, opts: SqliteQueueOptions = {}): DurableQueue { +export function createSqliteQueue( + driver: SqliteDriver, + consume: (message: JobMessage) => Promise, + opts: SqliteQueueOptions = {}, +): DurableQueue { const maxRetries = opts.maxRetries ?? 5; const pollIntervalMs = opts.pollIntervalMs ?? 1000; - const backoff = opts.backoffMs ?? ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); - const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); + const backoff = + opts.backoffMs ?? + ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); + const concurrency = + opts.concurrency ?? + Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); driver.exec(DDL); // Recover jobs a crashed previous run left mid-flight → make them claimable again. - const recovered = driver.query(`UPDATE ${TABLE} SET status='pending' WHERE status='processing'`, []).changes; - if (recovered) console.log(JSON.stringify({ event: "selfhost_queue_recovered", count: recovered })); + const recovered = driver.query( + `UPDATE ${TABLE} SET status='pending' WHERE status='processing'`, + [], + ).changes; + if (recovered) + console.log( + JSON.stringify({ event: "selfhost_queue_recovered", count: recovered }), + ); let running = false; - let active = 0; // number of concurrent pump() loops currently draining jobs + let active = 0; // number of concurrent pump() loops currently draining jobs let timer: ReturnType | null = null; function enqueue(message: JobMessage, delaySeconds: number): void { const now = Date.now(); - driver.query(`INSERT INTO ${TABLE} (payload, status, attempts, run_after, created_at) VALUES (?, 'pending', 0, ?, ?)`, [JSON.stringify(message), now + delaySeconds * 1000, now]); + driver.query( + `INSERT INTO ${TABLE} (payload, status, attempts, run_after, created_at) VALUES (?, 'pending', 0, ?, ?)`, + [JSON.stringify(message), now + delaySeconds * 1000, now], + ); incr("gittensory_jobs_enqueued_total"); void pump(); } function claimNext(): JobRow | null { - const { rows } = driver.query(`SELECT id, payload, attempts FROM ${TABLE} WHERE status='pending' AND run_after<=? ORDER BY id LIMIT 1`, [Date.now()]); + const { rows } = driver.query( + `SELECT id, payload, attempts FROM ${TABLE} WHERE status='pending' AND run_after<=? ORDER BY id LIMIT 1`, + [Date.now()], + ); const row = rows[0] as JobRow | undefined; if (!row) return null; - const { changes } = driver.query(`UPDATE ${TABLE} SET status='processing' WHERE id=? AND status='pending'`, [row.id]); + const { changes } = driver.query( + `UPDATE ${TABLE} SET status='processing' WHERE id=? AND status='pending'`, + [row.id], + ); /* v8 ignore next */ // the no-rows branch is a multi-writer guard; unreachable in the single-process model return changes ? row : null; } @@ -85,28 +109,87 @@ export function createSqliteQueue(driver: SqliteDriver, consume: (message: JobMe try { message = JSON.parse(job.payload) as JobMessage; } catch { - driver.query(`UPDATE ${TABLE} SET status='dead', last_error='unparseable payload' WHERE id=?`, [job.id]); + driver.query( + `UPDATE ${TABLE} SET status='dead', last_error='unparseable payload' WHERE id=?`, + [job.id], + ); incr("gittensory_jobs_dead_total"); - logAudit({ event: "job_dead", ts: Date.now(), job_id: job.id, latency_ms: Date.now() - claimedAt, attempts: job.attempts + 1, error: "unparseable payload" }); + logAudit({ + event: "job_dead", + ts: Date.now(), + job_id: job.id, + latency_ms: Date.now() - claimedAt, + attempts: job.attempts + 1, + error: "unparseable payload", + }); + captureError(new Error("unparseable queue payload"), { + kind: "job_dead", + reason: "unparseable_payload", + jobId: job.id, + }); return true; } try { await consume(message); driver.query(`DELETE FROM ${TABLE} WHERE id=?`, [job.id]); incr("gittensory_jobs_processed_total"); - logAudit({ event: "job_complete", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts: job.attempts + 1 }); + logAudit({ + event: "job_complete", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts: job.attempts + 1, + }); } catch (error) { const attempts = job.attempts + 1; const errMsg = error instanceof Error ? error.message : "unknown error"; incr("gittensory_jobs_failed_total"); if (attempts >= maxRetries) { - driver.query(`UPDATE ${TABLE} SET status='dead', attempts=?, last_error=? WHERE id=?`, [attempts, errMsg, job.id]); + driver.query( + `UPDATE ${TABLE} SET status='dead', attempts=?, last_error=? WHERE id=?`, + [attempts, errMsg, job.id], + ); incr("gittensory_jobs_dead_total"); - console.error(JSON.stringify({ level: "error", event: "selfhost_job_dead", id: job.id, attempts, error: errMsg })); - logAudit({ event: "job_dead", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts, error: errMsg }); + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_job_dead", + id: job.id, + attempts, + error: errMsg, + }), + ); + logAudit({ + event: "job_dead", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts, + error: errMsg, + }); + captureError(error, { + kind: "job_dead", + reason: "max_retries_exhausted", + jobType: extractPayloadType(job.payload), + jobId: job.id, + attempts, + }); } else { - driver.query(`UPDATE ${TABLE} SET status='pending', attempts=?, run_after=?, last_error=? WHERE id=?`, [attempts, Date.now() + backoff(attempts), errMsg, job.id]); - logAudit({ event: "job_error", ts: Date.now(), job_id: job.id, payload_type: extractPayloadType(job.payload), latency_ms: Date.now() - claimedAt, attempts, error: errMsg }); + driver.query( + `UPDATE ${TABLE} SET status='pending', attempts=?, run_after=?, last_error=? WHERE id=?`, + [attempts, Date.now() + backoff(attempts), errMsg, job.id], + ); + logAudit({ + event: "job_error", + ts: Date.now(), + job_id: job.id, + payload_type: extractPayloadType(job.payload), + latency_ms: Date.now() - claimedAt, + attempts, + error: errMsg, + }); } } return true; @@ -128,10 +211,15 @@ export function createSqliteQueue(driver: SqliteDriver, consume: (message: JobMe } const binding = { - async send(message: JobMessage, options?: { delaySeconds?: number }): Promise { + async send( + message: JobMessage, + options?: { delaySeconds?: number }, + ): Promise { enqueue(message, options?.delaySeconds ?? 0); }, - async sendBatch(messages: Iterable<{ body: JobMessage; delaySeconds?: number }>): Promise { + async sendBatch( + messages: Iterable<{ body: JobMessage; delaySeconds?: number }>, + ): Promise { for (const m of messages) enqueue(m.body, m.delaySeconds ?? 0); }, } as unknown as Queue; @@ -161,10 +249,24 @@ export function createSqliteQueue(driver: SqliteDriver, consume: (message: JobMe await pump(); }, size() { - return Number((driver.query(`SELECT COUNT(*) AS c FROM ${TABLE} WHERE status IN ('pending','processing')`, []).rows[0] as { c: number }).c); + return Number( + ( + driver.query( + `SELECT COUNT(*) AS c FROM ${TABLE} WHERE status IN ('pending','processing')`, + [], + ).rows[0] as { c: number } + ).c, + ); }, deadCount() { - return Number((driver.query(`SELECT COUNT(*) AS c FROM ${TABLE} WHERE status='dead'`, []).rows[0] as { c: number }).c); + return Number( + ( + driver.query( + `SELECT COUNT(*) AS c FROM ${TABLE} WHERE status='dead'`, + [], + ).rows[0] as { c: number } + ).c, + ); }, }; } diff --git a/src/server.ts b/src/server.ts index 8c85ce6ca0..73027ea13d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,7 +28,11 @@ import { import { isOrbBrokerMode, registerOrbRelayTarget } from "./orb/broker-client"; import { exportOrbBatch } from "./selfhost/orb-collector"; import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter"; -import { readiness, sqliteBackupAdvisory, type ReadinessProbe } from "./selfhost/health"; +import { + readiness, + sqliteBackupAdvisory, + type ReadinessProbe, +} from "./selfhost/health"; import { gauge, incr, observe, renderMetrics } from "./selfhost/metrics"; import { runSelfHostMigrations } from "./selfhost/migrate"; import { createPgAdapter } from "./selfhost/pg-adapter"; @@ -38,6 +42,7 @@ import { createSqliteQueue } from "./selfhost/sqlite-queue"; import { createSqliteVectorize } from "./selfhost/vectorize"; import { createFsBlobStore } from "./selfhost/blob-store"; import { makeLocalManifestReader } from "./selfhost/private-config"; +import { captureError, flushSentry, initSentry } from "./selfhost/sentry"; import { setLocalManifestReader } from "./signals/focus-manifest-loader"; import type { JobMessage } from "./types"; @@ -48,16 +53,31 @@ function loadFileSecrets(): void { const target = key.slice(0, -"_FILE".length); if (process.env[target]) continue; // an explicit value wins try { - process.env[target] = readFileSync(process.env[key] as string, "utf8").trim(); + process.env[target] = readFileSync( + process.env[key] as string, + "utf8", + ).trim(); } catch { - console.error(JSON.stringify({ level: "error", event: "selfhost_secret_file_unreadable", var: key })); + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_secret_file_unreadable", + var: key, + }), + ); } } } interface Backend { db: D1Database; - queue: { binding: Queue; start(): void; stop(): Promise; size(): number | Promise; deadCount(): number | Promise }; + queue: { + binding: Queue; + start(): void; + stop(): Promise; + size(): number | Promise; + deadCount(): number | Promise; + }; vectorize?: Vectorize; shutdown(): Promise; } @@ -78,9 +98,19 @@ async function waitForPostgres(url: string, maxWaitMs = 30_000): Promise { await client.end().catch(() => undefined); attempt++; const elapsed = Date.now() - start; - if (elapsed >= maxWaitMs) throw new Error(`Postgres not ready after ${maxWaitMs}ms (${attempt} attempts)`); + if (elapsed >= maxWaitMs) + throw new Error( + `Postgres not ready after ${maxWaitMs}ms (${attempt} attempts)`, + ); const delay = Math.min(2000, 200 * attempt); - console.log(JSON.stringify({ event: "selfhost_pg_wait", attempt, elapsed_ms: elapsed, retry_in_ms: delay })); + console.log( + JSON.stringify({ + event: "selfhost_pg_wait", + attempt, + elapsed_ms: elapsed, + retry_in_ms: delay, + }), + ); await new Promise((r) => setTimeout(r, delay)); } } @@ -90,7 +120,11 @@ async function waitForPostgres(url: string, maxWaitMs = 30_000): Promise { * crash-restart loop when gittensory starts before a dependency (e.g. Qdrant) is accepting connections — * Qdrant's init is a single fetch with no retry, so a slow-starting --profile qdrant container would * otherwise take the whole process down. */ -async function retryUntilReady(name: string, op: () => Promise, maxWaitMs = 30_000): Promise { +async function retryUntilReady( + name: string, + op: () => Promise, + maxWaitMs = 30_000, +): Promise { const start = Date.now(); let attempt = 0; while (true) { @@ -101,17 +135,30 @@ async function retryUntilReady(name: string, op: () => Promise, maxWaitMs attempt++; const elapsed = Date.now() - start; if (elapsed >= maxWaitMs) { - throw new Error(`${name} not ready after ${maxWaitMs}ms (${attempt} attempts): ${error instanceof Error ? error.message : "unknown error"}`); + throw new Error( + `${name} not ready after ${maxWaitMs}ms (${attempt} attempts): ${error instanceof Error ? error.message : "unknown error"}`, + ); } const delay = Math.min(2000, 200 * attempt); - console.log(JSON.stringify({ event: "selfhost_dependency_wait", dependency: name, attempt, elapsed_ms: elapsed, retry_in_ms: delay })); + console.log( + JSON.stringify({ + event: "selfhost_dependency_wait", + dependency: name, + attempt, + elapsed_ms: elapsed, + retry_in_ms: delay, + }), + ); await new Promise((r) => setTimeout(r, delay)); } } } /** Build the Postgres backend (shared DB + queue) when DATABASE_URL is a postgres:// URL. */ -async function buildPostgresBackend(url: string, consume: (m: JobMessage) => Promise): Promise { +async function buildPostgresBackend( + url: string, + consume: (m: JobMessage) => Promise, +): Promise { await waitForPostgres(url); const pg = (await import("pg")).default; pg.types.setTypeParser(20, (v: string) => Number.parseInt(v, 10)); // int8 (COUNT) → number, like D1 @@ -136,9 +183,15 @@ async function buildPostgresBackend(url: string, consume: (m: JobMessage) => Pro } /** Build the SQLite backend (single file, default). */ -function buildSqliteBackend(consume: (m: JobMessage) => Promise): Backend { - const sqlite = new DatabaseSync(process.env.DATABASE_PATH ?? "/data/gittensory.sqlite"); - sqlite.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); +function buildSqliteBackend( + consume: (m: JobMessage) => Promise, +): Backend { + const sqlite = new DatabaseSync( + process.env.DATABASE_PATH ?? "/data/gittensory.sqlite", + ); + sqlite.exec( + "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;", + ); const driver = nodeSqliteDriver(sqlite as never); const db = createD1Adapter(driver); const queue = createSqliteQueue(driver, consume); @@ -164,7 +217,29 @@ async function main(): Promise { // Container-private per-repo config (self-host): register the GITTENSORY_REPO_CONFIG_DIR reader so the focus- // manifest loader prefers a mounted `{owner}__{repo}.yml` over the public `.gittensory.yml` (review policy stays // private). Unset dir ⇒ null reader ⇒ unchanged public-fetch behavior. - setLocalManifestReader(makeLocalManifestReader(process.env.GITTENSORY_REPO_CONFIG_DIR)); + setLocalManifestReader( + makeLocalManifestReader(process.env.GITTENSORY_REPO_CONFIG_DIR), + ); + // Error tracking (#1468): opt-in via SENTRY_DSN — a complete no-op when unset. When on, capture uncaught crashes + // + unhandled rejections (flush before exit for the fatal case); per-subsystem captures (queue dead-letter, + // review failures) are wired at their sites. + if (await initSentry(process.env)) { + console.log( + JSON.stringify({ + event: "selfhost_sentry", + environment: process.env.SENTRY_ENVIRONMENT ?? "production", + }), + ); + process.on("uncaughtException", (error) => { + captureError(error, { kind: "uncaughtException" }); + console.error(error); + void flushSentry().finally(() => process.exit(1)); + }); + process.on("unhandledRejection", (reason) => { + captureError(reason, { kind: "unhandledRejection" }); + console.error(reason); + }); + } const startedAt = Date.now(); // The queue consumer captures `env`, assigned below (the first job only runs once an HTTP/cron event @@ -176,28 +251,66 @@ async function main(): Promise { const databaseUrl = process.env.DATABASE_URL; const usePostgres = !!databaseUrl && /^postgres(ql)?:\/\//i.test(databaseUrl); - const backend = usePostgres ? await buildPostgresBackend(databaseUrl as string, consume) : buildSqliteBackend(consume); - console.log(JSON.stringify({ event: "selfhost_backend", backend: usePostgres ? "postgres" : "sqlite" })); + const backend = usePostgres + ? await buildPostgresBackend(databaseUrl as string, consume) + : buildSqliteBackend(consume); + console.log( + JSON.stringify({ + event: "selfhost_backend", + backend: usePostgres ? "postgres" : "sqlite", + }), + ); // Data-safety advisory (#8): warn LOUDLY at boot if running on a single SQLite file with no acknowledged backup, // so an operator doesn't run with zero durability while /ready answers 200. - const backupAdvisory = sqliteBackupAdvisory({ usingSqlite: !usePostgres, backupAcknowledged: process.env.BACKUP_ACKNOWLEDGED === "true" }); - if (backupAdvisory) console.warn(JSON.stringify({ level: "warn", event: "selfhost_backup_advisory", message: backupAdvisory })); + const backupAdvisory = sqliteBackupAdvisory({ + usingSqlite: !usePostgres, + backupAcknowledged: process.env.BACKUP_ACKNOWLEDGED === "true", + }); + if (backupAdvisory) + console.warn( + JSON.stringify({ + level: "warn", + event: "selfhost_backup_advisory", + message: backupAdvisory, + }), + ); - const applied = await runSelfHostMigrations(backend.db, process.env.MIGRATIONS_DIR ?? "migrations"); - console.log(JSON.stringify({ event: "selfhost_migrations_applied", count: applied })); + const applied = await runSelfHostMigrations( + backend.db, + process.env.MIGRATIONS_DIR ?? "migrations", + ); + console.log( + JSON.stringify({ event: "selfhost_migrations_applied", count: applied }), + ); const ai = createSelfHostAi(process.env); - if (ai) console.log(JSON.stringify({ event: "selfhost_ai_provider", provider: process.env.AI_PROVIDER })); + if (ai) + console.log( + JSON.stringify({ + event: "selfhost_ai_provider", + provider: process.env.AI_PROVIDER, + }), + ); // Dual-review plan (#dual-ai-combiner): resolve which provider(s) review + how to combine, attached to env // below so the review call site uses it. Undefined for a single provider's default review or no AI. const aiReviewPlan = resolveAiReviewerPlan(process.env); - if (aiReviewPlan) console.log(JSON.stringify({ event: "selfhost_ai_review_plan", reviewers: aiReviewPlan.reviewers.map((r) => r.model), combine: aiReviewPlan.combine })); + if (aiReviewPlan) + console.log( + JSON.stringify({ + event: "selfhost_ai_review_plan", + reviewers: aiReviewPlan.reviewers.map((r) => r.model), + combine: aiReviewPlan.combine, + }), + ); // /ready gates on every CONFIGURED optional backend (below) so a load balancer never routes to an instance whose // Redis/Qdrant is down. Each probe owns a short timeout so a hung backend can't hang the readiness check. const readinessProbes: ReadinessProbe[] = []; const withTimeout = (p: Promise, ms = 1500): Promise => - Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(false), ms))]); + Promise.race([ + p, + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]); // Redis fixed-window rate limiter + webhook dedup cache (else absent when REDIS_URL is unset). let rateLimiter: DurableObjectNamespace | undefined; @@ -205,24 +318,41 @@ async function main(): Promise { if (process.env.REDIS_URL) { const { Redis } = await import("ioredis"); const redisClient = new Redis(process.env.REDIS_URL); - const { createRedisRateLimiter } = await import("./selfhost/redis-ratelimit"); + const { createRedisRateLimiter } = + await import("./selfhost/redis-ratelimit"); const { createRedisCache } = await import("./selfhost/redis-cache"); rateLimiter = createRedisRateLimiter(redisClient); webhookCache = createRedisCache(redisClient); - readinessProbes.push({ name: "redis", check: () => withTimeout(redisClient.ping().then(() => true)) }); - console.log(JSON.stringify({ event: "selfhost_rate_limiter", backend: "redis" })); + readinessProbes.push({ + name: "redis", + check: () => withTimeout(redisClient.ping().then(() => true)), + }); + console.log( + JSON.stringify({ event: "selfhost_rate_limiter", backend: "redis" }), + ); } // Qdrant vector store — overrides the backend's built-in sqlite-vec / pgvector when QDRANT_URL is set. let vectorizeOverride: Vectorize | undefined; if (process.env.QDRANT_URL) { const qdrantUrl = process.env.QDRANT_URL; - const { createQdrantVectorize, initQdrantCollection } = await import("./selfhost/qdrant-vectorize"); + const { createQdrantVectorize, initQdrantCollection } = + await import("./selfhost/qdrant-vectorize"); // Retry until Qdrant accepts the collection PUT — the container may still be booting when we start. await retryUntilReady("qdrant", () => initQdrantCollection(qdrantUrl)); vectorizeOverride = createQdrantVectorize(qdrantUrl); - readinessProbes.push({ name: "qdrant", check: () => withTimeout(fetch(qdrantUrl, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok).catch(() => false)) }); - console.log(JSON.stringify({ event: "selfhost_vectorize", backend: "qdrant" })); + readinessProbes.push({ + name: "qdrant", + check: () => + withTimeout( + fetch(qdrantUrl, { signal: AbortSignal.timeout(1500) }) + .then((r) => r.ok) + .catch(() => false), + ), + }); + console.log( + JSON.stringify({ event: "selfhost_vectorize", backend: "qdrant" }), + ); } env = { @@ -233,7 +363,11 @@ async function main(): Promise { AI: ai, ...(aiReviewPlan ? { AI_REVIEW_PLAN: aiReviewPlan } : {}), // Qdrant takes priority; falls back to the backend's built-in vectorize (pgvector or sqlite-vec) - ...(vectorizeOverride ? { VECTORIZE: vectorizeOverride } : backend.vectorize ? { VECTORIZE: backend.vectorize } : {}), + ...(vectorizeOverride + ? { VECTORIZE: vectorizeOverride } + : backend.vectorize + ? { VECTORIZE: backend.vectorize } + : {}), ...(rateLimiter ? { RATE_LIMITER: rateLimiter } : {}), // Visual review: when BROWSER_WS_ENDPOINT is set, expose a truthy BROWSER binding so shot.ts's // `if (!env.BROWSER) return` guard is bypassed; the puppeteer stub then connects via WS. @@ -241,28 +375,38 @@ async function main(): Promise { // Visual screenshot persistence (#10): bind an fs-backed REVIEW_AUDIT store when REVIEW_AUDIT_DIR is set so // captured PNGs are cached + served from /gittensory/shot?key=… instead of re-rendering on demand. Unset ⇒ // no binding ⇒ on-demand behavior, byte-identical to before. - ...(process.env.REVIEW_AUDIT_DIR ? { REVIEW_AUDIT: createFsBlobStore(process.env.REVIEW_AUDIT_DIR) } : {}), + ...(process.env.REVIEW_AUDIT_DIR + ? { REVIEW_AUDIT: createFsBlobStore(process.env.REVIEW_AUDIT_DIR) } + : {}), } as unknown as Env; gauge("gittensory_queue_pending", () => backend.queue.size()); gauge("gittensory_queue_dead", () => backend.queue.deadCount()); - gauge("gittensory_uptime_seconds", () => Math.floor((Date.now() - startedAt) / 1000)); + gauge("gittensory_uptime_seconds", () => + Math.floor((Date.now() - startedAt) / 1000), + ); // Pre-initialize job counters to 0 so they appear in the first Prometheus scrape (lazy counters // created on first use would otherwise cause "No data" in Grafana until the first job event). for (const c of [ - "gittensory_jobs_enqueued_total", "gittensory_jobs_processed_total", - "gittensory_jobs_failed_total", "gittensory_jobs_dead_total", + "gittensory_jobs_enqueued_total", + "gittensory_jobs_processed_total", + "gittensory_jobs_failed_total", + "gittensory_jobs_dead_total", "gittensory_webhook_dedup_total", - "gittensory_qdrant_queries_total", "gittensory_qdrant_upserts_total", - "gittensory_orb_events_exported_total", "gittensory_orb_export_errors_total", + "gittensory_qdrant_queries_total", + "gittensory_qdrant_upserts_total", + "gittensory_orb_events_exported_total", + "gittensory_orb_export_errors_total", ]) incr(c, undefined, 0); // Seed gittensory_http_requests_total per status class so the breakdown panel has every series from the // first scrape (keeping the metric consistently labeled — never mix labeled and unlabeled samples). - for (const status of ["2xx", "3xx", "4xx", "5xx"]) incr("gittensory_http_requests_total", { status }, 0); + for (const status of ["2xx", "3xx", "4xx", "5xx"]) + incr("gittensory_http_requests_total", { status }, 0); const ctx = { - waitUntil: (p: Promise) => void Promise.resolve(p).catch(() => undefined), + waitUntil: (p: Promise) => + void Promise.resolve(p).catch(() => undefined), passThroughOnException: () => undefined, } as unknown as ExecutionContext; @@ -271,25 +415,48 @@ async function main(): Promise { { fetch: async (request: Request) => { const path = new URL(request.url).pathname; - if (path === "/health") return new Response(JSON.stringify({ status: "ok" }), { headers: { "content-type": "application/json" } }); + if (path === "/health") + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "content-type": "application/json" }, + }); if (path === "/ready") { const r = await readiness(backend.db, readinessProbes); - return new Response(JSON.stringify(r), { status: r.ok ? 200 : 503, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify(r), { + status: r.ok ? 200 : 503, + headers: { "content-type": "application/json" }, + }); } - if (path === "/metrics") return new Response(await renderMetrics(), { headers: { "content-type": "text/plain; version=0.0.4" } }); + if (path === "/metrics") + return new Response(await renderMetrics(), { + headers: { "content-type": "text/plain; version=0.0.4" }, + }); // Brokered mode (ORB_ENROLLMENT_SECRET set): the central Orb App provides credentials on demand, so // there is no own GitHub App to create — short-circuit the setup wizard to a brokered-mode page rather // than walking the operator through (and overriding with) an own-App setup they don't need. - if ((path === "/setup" || path === "/setup/callback") && isOrbBrokerMode({ ORB_ENROLLMENT_SECRET: process.env.ORB_ENROLLMENT_SECRET })) { + if ( + (path === "/setup" || path === "/setup/callback") && + isOrbBrokerMode({ + ORB_ENROLLMENT_SECRET: process.env.ORB_ENROLLMENT_SECRET, + }) + ) { return new Response(renderBrokeredSetupPage(), { - headers: { "content-type": "text/html; charset=utf-8", "Referrer-Policy": "no-referrer" }, + headers: { + "content-type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + }, }); } // First-run GitHub App setup wizard — only while no App is configured (can't rebind a live install). - if ((path === "/setup" || path === "/setup/callback") && !process.env.GITHUB_APP_ID) { + if ( + (path === "/setup" || path === "/setup/callback") && + !process.env.GITHUB_APP_ID + ) { const setupToken = process.env.SELFHOST_SETUP_TOKEN; if (!setupToken) { - return new Response("SELFHOST_SETUP_TOKEN must be set before using the setup wizard", { status: 400 }); + return new Response( + "SELFHOST_SETUP_TOKEN must be set before using the setup wizard", + { status: 400 }, + ); } // PUBLIC_API_ORIGIN is required: falling back to request.url.origin would let an attacker spoof // the Host header and redirect the App-creation callback to an attacker-controlled domain, where @@ -306,7 +473,9 @@ async function main(): Promise { // which would leak the secret to access logs, proxies, and browser history. let suppliedToken = request.headers.get("x-setup-token") ?? - request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? + request.headers + .get("authorization") + ?.replace(/^Bearer\s+/i, "") ?? ""; if (!suppliedToken && request.method === "POST") { const rejection = setupTokenFormRejection(request.headers); @@ -318,10 +487,16 @@ async function main(): Promise { if (!timingSafeStrEqual(suppliedToken, setupToken)) { // Not authenticated → show the token-entry form (token submitted via POST body, not the URL). // First visit (no token) is 200; a wrong submission is 403. - return new Response(renderTokenEntryPage(suppliedToken.length > 0), { - status: suppliedToken.length > 0 ? 403 : 200, - headers: { "content-type": "text/html; charset=utf-8", "Referrer-Policy": "no-referrer" }, - }); + return new Response( + renderTokenEntryPage(suppliedToken.length > 0), + { + status: suppliedToken.length > 0 ? 403 : 200, + headers: { + "content-type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + }, + }, + ); } // Generate a per-visit CSRF nonce, embed it in the manifest's redirect_url, and bind it to // this browser session via an HttpOnly signed cookie so the callback can validate it came @@ -342,30 +517,56 @@ async function main(): Promise { const stateParam = params.get("state"); const cookieHeader = request.headers.get("cookie") ?? ""; const setupAuth = cookieValue(cookieHeader, "setup_auth"); - if (!stateParam || !isValidSetupAuthCookie(setupToken, stateParam, setupAuth)) { + if ( + !stateParam || + !isValidSetupAuthCookie(setupToken, stateParam, setupAuth) + ) { return new Response("invalid state parameter", { status: 403 }); } try { const creds = await exchangeManifestCode(code); - const outPath = process.env.SETUP_OUTPUT_PATH ?? "/data/gittensory-app.env"; + const outPath = + process.env.SETUP_OUTPUT_PATH ?? "/data/gittensory-app.env"; writeFileSync(outPath, credentialsToEnv(creds), { mode: 0o600 }); - console.log(JSON.stringify({ event: "selfhost_app_created", slug: creds.slug, app_id: creds.id })); - return new Response(`

GitHub App created ✓

Credentials written to ${outPath}. Add them to your .env (or load the file), install the App on your repos, and restart the container.

`, { headers: { "content-type": "text/html; charset=utf-8" } }); + console.log( + JSON.stringify({ + event: "selfhost_app_created", + slug: creds.slug, + app_id: creds.id, + }), + ); + return new Response( + `

GitHub App created ✓

Credentials written to ${outPath}. Add them to your .env (or load the file), install the App on your repos, and restart the container.

`, + { headers: { "content-type": "text/html; charset=utf-8" } }, + ); } catch (error) { - return new Response(`setup failed: ${error instanceof Error ? error.message : "error"}`, { status: 500 }); + return new Response( + `setup failed: ${error instanceof Error ? error.message : "error"}`, + { status: 500 }, + ); } } // Instrument real app traffic — status-class counter + latency histogram. (Infra endpoints // /health /ready /metrics and the setup wizard already returned above and are not counted.) const startedReq = Date.now(); const record = (status: number): void => { - incr("gittensory_http_requests_total", { status: `${Math.floor(status / 100)}xx` }); - observe("gittensory_http_request_duration_seconds", (Date.now() - startedReq) / 1000); + incr("gittensory_http_requests_total", { + status: `${Math.floor(status / 100)}xx`, + }); + observe( + "gittensory_http_request_duration_seconds", + (Date.now() - startedReq) / 1000, + ); }; // Webhook delivery dedup: return 204 immediately for already-processed delivery IDs. // We mark only AFTER a successful response — failed/rejected webhooks must be retryable. - const isWebhook = webhookCache && path === "/v1/github/webhook" && request.method === "POST"; - const deliveryId = isWebhook ? request.headers.get("x-github-delivery") : null; + const isWebhook = + webhookCache && + path === "/v1/github/webhook" && + request.method === "POST"; + const deliveryId = isWebhook + ? request.headers.get("x-github-delivery") + : null; if (deliveryId) { const seen = await webhookCache!.get(`delivery:${deliveryId}`); if (seen) { @@ -377,7 +578,9 @@ async function main(): Promise { const response = await worker.fetch(request, env, ctx); if (deliveryId && response.ok) { // Best-effort — never block the response on a cache write failure - void webhookCache!.set(`delivery:${deliveryId}`, "1", 300).catch(() => undefined); + void webhookCache! + .set(`delivery:${deliveryId}`, "1", 300) + .catch(() => undefined); } record(response.status); return response; @@ -392,9 +595,19 @@ async function main(): Promise { // Cron — gittensory ticks ~every 2 minutes; drive the SAME scheduled handler. const intervalMs = Number(process.env.CRON_INTERVAL_MS ?? 120_000); const cron = setInterval(() => { - const controller = { scheduledTime: Date.now(), cron: "*/2 * * * *", noRetry: () => undefined } as unknown as ScheduledController; + const controller = { + scheduledTime: Date.now(), + cron: "*/2 * * * *", + noRetry: () => undefined, + } as unknown as ScheduledController; Promise.resolve(worker.scheduled(controller, env, ctx)).catch((error) => - console.error(JSON.stringify({ level: "error", event: "selfhost_cron_error", error: error instanceof Error ? error.message : "unknown error" })), + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_cron_error", + error: error instanceof Error ? error.message : "unknown error", + }), + ), ); }, intervalMs); @@ -402,8 +615,21 @@ async function main(): Promise { // inside exportOrbBatch: a no-op until the GitHub App is configured, or when ORB_AIR_GAP=true. const runOrbExport = () => exportOrbBatch(backend.db) - .then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); }) - .catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" }))); + .then((n) => { + if (n > 0) + console.log( + JSON.stringify({ event: "selfhost_orb_export", exported: n }), + ); + }) + .catch((error) => + console.error( + JSON.stringify({ + level: "error", + event: "selfhost_orb_export_error", + error: error instanceof Error ? error.message : "unknown error", + }), + ), + ); void runOrbExport(); // flush any pending events at startup setInterval(runOrbExport, 3_600_000); // then hourly @@ -414,7 +640,12 @@ async function main(): Promise { ORB_BROKER_URL: process.env.ORB_BROKER_URL, PUBLIC_API_ORIGIN: process.env.PUBLIC_API_ORIGIN, }) - .then((r) => { if (r !== "skipped") console.log(JSON.stringify({ event: "selfhost_orb_relay_register", result: r })); }) + .then((r) => { + if (r !== "skipped") + console.log( + JSON.stringify({ event: "selfhost_orb_relay_register", result: r }), + ); + }) .catch(() => {}); // Graceful shutdown: stop accepting HTTP, let the queue finish, close the backend. @@ -426,6 +657,7 @@ async function main(): Promise { clearInterval(cron); server.close(); await backend.shutdown(); + await flushSentry(); process.exit(0); }; process.on("SIGTERM", () => void shutdown("SIGTERM")); @@ -433,6 +665,7 @@ async function main(): Promise { } main().catch((error) => { + captureError(error, { kind: "boot" }); console.error(error); - process.exit(1); + void flushSentry().finally(() => process.exit(1)); }); diff --git a/test/unit/selfhost-sentry.test.ts b/test/unit/selfhost-sentry.test.ts new file mode 100644 index 0000000000..8c5646f221 --- /dev/null +++ b/test/unit/selfhost-sentry.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock @sentry/node so the dynamic import inside initSentry() resolves to spies. Hoisted so vi.mock can see it. +const mocks = vi.hoisted(() => { + const scope = { setContext: vi.fn(), setLevel: vi.fn(), setTag: vi.fn() }; + return { + scope, + init: vi.fn(), + withScope: vi.fn((cb: (s: typeof scope) => void) => cb(scope)), + captureException: vi.fn(), + flush: vi.fn().mockResolvedValue(true), + }; +}); +vi.mock("@sentry/node", () => ({ + init: mocks.init, + withScope: mocks.withScope, + captureException: mocks.captureException, + flush: mocks.flush, +})); + +import { + initSentry, + captureError, + captureReviewFailure, + flushSentry, + scrubEvent, + resetSentryForTest, +} from "../../src/selfhost/sentry"; + +beforeEach(() => { + resetSentryForTest(); + vi.clearAllMocks(); +}); + +describe("scrubEvent — redact secrets before an event leaves the box", () => { + it("redacts secret-keyed fields in headers/contexts/extra, recurses, and leaves safe fields", () => { + const ev = scrubEvent({ + request: { headers: { authorization: "Bearer abc", "x-trace": "ok" } }, + contexts: { + gittensory: { + jobId: "j1", + apiKey: "shh", + nested: { secretToken: "deep" }, + }, + }, + extra: { note: "fine" }, + }) as any; + expect(ev.request.headers.authorization).toBe("[redacted]"); + expect(ev.request.headers["x-trace"]).toBe("ok"); + expect(ev.contexts.gittensory.apiKey).toBe("[redacted]"); + expect(ev.contexts.gittensory.jobId).toBe("j1"); + expect(ev.contexts.gittensory.nested.secretToken).toBe("[redacted]"); + expect(ev.extra.note).toBe("fine"); + }); + + it("is safe when headers/contexts/extra are absent (the !obj branch)", () => { + expect(() => scrubEvent({})).not.toThrow(); + }); + + it("stops at the depth guard without infinite recursion, still redacting shallow secrets", () => { + let deep: any = { secretToken: "x" }; + for (let i = 0; i < 8; i++) deep = { a: deep }; + const ev = scrubEvent({ extra: { token: "shallow", deep } }) as any; + expect(ev.extra.token).toBe("[redacted]"); + }); +}); + +describe("disabled when SENTRY_DSN is unset (modular opt-out → complete no-op)", () => { + it("initSentry returns false; capture/flush are safe no-ops and never touch the SDK", async () => { + expect(await initSentry({} as unknown as NodeJS.ProcessEnv)).toBe(false); + captureError(new Error("x"), { a: 1 }); + captureReviewFailure(new Error("y"), { repo: "o/r" }); + await flushSentry(); + expect(mocks.init).not.toHaveBeenCalled(); + expect(mocks.captureException).not.toHaveBeenCalled(); + expect(mocks.flush).not.toHaveBeenCalled(); + }); +}); + +describe("enabled when SENTRY_DSN is set", () => { + it("returns true and wires init with defaults (?? right-hand branches) + the scrubber as beforeSend", async () => { + expect( + await initSentry({ + SENTRY_DSN: "https://k@o.ingest/1", + } as unknown as NodeJS.ProcessEnv), + ).toBe(true); + expect(mocks.init).toHaveBeenCalledTimes(1); + const opts = mocks.init.mock.calls[0]![0]; + expect(opts.environment).toBe("production"); + expect(opts.tracesSampleRate).toBe(0); + expect( + opts.beforeSend({ extra: { sessionToken: "s" } }).extra.sessionToken, + ).toBe("[redacted]"); + }); + + it("honors explicit env (?? left-hand branches)", async () => { + await initSentry({ + SENTRY_DSN: "d", + SENTRY_ENVIRONMENT: "staging", + SENTRY_RELEASE: "v9", + SENTRY_TRACES_SAMPLE_RATE: "0.5", + PUBLIC_API_ORIGIN: "https://self.host", + } as unknown as NodeJS.ProcessEnv); + const opts = mocks.init.mock.calls[0]![0]; + expect(opts.environment).toBe("staging"); + expect(opts.release).toBe("v9"); + expect(opts.tracesSampleRate).toBe(0.5); + expect(opts.serverName).toBe("https://self.host"); + }); + + it("captureError sends with context, and without context skips setContext", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + captureError(new Error("boom"), { kind: "job_dead" }); + expect(mocks.scope.setContext).toHaveBeenCalledWith("gittensory", { + kind: "job_dead", + }); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + mocks.scope.setContext.mockClear(); + captureError("plain string with no context"); + expect(mocks.scope.setContext).not.toHaveBeenCalled(); + expect(mocks.captureException).toHaveBeenCalledTimes(2); + }); + + it("captureReviewFailure sets warning level + repo/PR/SHA tags, skipping null/undefined, and works without context", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + captureReviewFailure(new Error("rev"), { + repo: "o/r", + pr: 7, + head_sha: "abc", + owner: null, + }); + expect(mocks.scope.setLevel).toHaveBeenCalledWith("warning"); + expect(mocks.scope.setTag).toHaveBeenCalledWith("repo", "o/r"); + expect(mocks.scope.setTag).toHaveBeenCalledWith("pr", "7"); + expect(mocks.scope.setTag).toHaveBeenCalledWith("head_sha", "abc"); + expect(mocks.scope.setTag).not.toHaveBeenCalledWith( + "owner", + expect.anything(), + ); + captureReviewFailure("string failure, no context"); + expect(mocks.captureException).toHaveBeenCalledTimes(2); + }); + + it("flushSentry delegates to Sentry.flush with the timeout", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + await flushSentry(123); + expect(mocks.flush).toHaveBeenCalledWith(123); + }); + + it("flushSentry swallows a flush rejection (never breaks shutdown)", async () => { + await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv); + mocks.flush.mockRejectedValueOnce(new Error("network")); + await expect(flushSentry()).resolves.toBeUndefined(); + }); +});