From c8d90bd8fcb15b8f9a50f44d3038b99b1628901c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Feb 2026 12:58:39 +0000 Subject: [PATCH] feat(sentry): add error monitoring for router, worker, and dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate @sentry/node v10 across all three container types (router, worker, dashboard) using Node's --import flag for early SDK initialization. Core: - Add src/instrument.ts (module preload) and src/sentry.ts (no-op wrappers) - Wire Sentry captures into all Hono error handlers, webhook processing, worker lifecycle, watchdog timeouts, queue errors, and retry exhaustion - Forward SENTRY_* env vars from router to spawned worker containers Bug fixes found during review: - Fix tracesSampleRate=0 silently becoming 0.1 (|| vs nullish check) - Add Sentry flush before watchdog process.exit(1) to drain queued events - Remove redundant try/catch in router webhook handlers that swallowed queue failures (Redis down → 200 instead of 500), restoring correct HTTP semantics for webhook provider retries - Add Sentry capture to server mode's handleProcessingError (was log-only) Code quality: - Extract dispatchJob() from worker-entry main() to fix cognitive complexity lint warning (17 > 15) - Fix all import ordering and formatting issues Tests: - Add tests/unit/sentry.test.ts — no-op behavior, context propagation - Add tests/unit/instrument.test.ts — conditional init, tracesSampleRate=0 - Add Sentry flush assertion to lifecycle watchdog tests - Add handleProcessingError Sentry capture test to webhookHandlers Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 + Dockerfile.dashboard | 2 +- Dockerfile.router | 2 +- Dockerfile.worker | 2 +- package-lock.json | 841 +++++++++++++++++++++- package.json | 1 + src/agents/shared/lifecycle.ts | 18 +- src/config/env.ts | 3 + src/config/retryConfig.ts | 11 + src/dashboard.ts | 7 + src/instrument.ts | 14 + src/router/index.ts | 27 +- src/router/queue.ts | 2 + src/router/worker-manager.ts | 45 ++ src/sentry.ts | 46 ++ src/server.ts | 5 + src/server/webhookHandlers.ts | 4 + src/utils/lifecycle.ts | 2 + src/worker-entry.ts | 122 ++-- tests/unit/instrument.test.ts | 95 +++ tests/unit/sentry.test.ts | 129 ++++ tests/unit/server/webhookHandlers.test.ts | 31 + tests/unit/utils/lifecycle.test.ts | 26 +- 23 files changed, 1385 insertions(+), 54 deletions(-) create mode 100644 src/instrument.ts create mode 100644 src/sentry.ts create mode 100644 tests/unit/instrument.test.ts create mode 100644 tests/unit/sentry.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3e8e8ed0..1c0939c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,10 @@ Optional (infrastructure): - `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled) - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code backend (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. +- `SENTRY_DSN` - Sentry DSN for error monitoring (router + worker) +- `SENTRY_ENVIRONMENT` - Sentry environment tag (default: NODE_ENV or 'production') +- `SENTRY_RELEASE` - Release identifier for source maps (e.g., git SHA) +- `SENTRY_TRACES_SAMPLE_RATE` - Trace sampling rate 0.0-1.0 (default: 0.1) **Project credentials** (`GITHUB_TOKEN_IMPLEMENTER`, `GITHUB_TOKEN_REVIEWER`, `TRELLO_API_KEY`, `TRELLO_TOKEN`, LLM API keys) are stored in the `credentials` table (org-scoped, encrypted at rest when `CREDENTIAL_MASTER_KEY` is set). Integration-specific credentials (GitHub tokens, Trello keys, JIRA tokens) are linked to integrations via the `integration_credentials` join table with provider-defined roles. Non-integration credentials (LLM API keys) remain org-scoped defaults. There is no env var fallback — the database is the sole source of truth for project-scoped secrets. diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index 5a5a2556..0641d5cd 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -30,4 +30,4 @@ COPY --from=builder /app/src/agents/prompts/templates ./dist/agents/prompts/temp ENV PORT=3001 EXPOSE 3001 -CMD ["node", "dist/dashboard.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/dashboard.js"] diff --git a/Dockerfile.router b/Dockerfile.router index dba72b5b..1fe39de1 100644 --- a/Dockerfile.router +++ b/Dockerfile.router @@ -30,4 +30,4 @@ COPY config ./config ENV PORT=3000 EXPOSE 3000 -CMD ["node", "dist/router/index.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/router/index.js"] diff --git a/Dockerfile.worker b/Dockerfile.worker index 01e588ca..0fb43bba 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -102,4 +102,4 @@ COPY --chown=node:node src/agents/prompts/templates ./dist/agents/prompts/templa COPY --chown=node:node config ./config # Worker entry point - processes a single job and exits -CMD ["node", "dist/worker-entry.js"] +CMD ["node", "--import", "./dist/instrument.js", "dist/worker-entry.js"] diff --git a/package-lock.json b/package-lock.json index e7c9b950..14d6b994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@sentry/node": "^10.39.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", @@ -127,6 +128,23 @@ } } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", + "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", + "license": "Apache-2.0" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", + "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.8.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", @@ -2662,6 +2680,530 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", + "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "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.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.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/instrumentation-amqplib": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz", + "integrity": "sha512-fjpQtH18J6GxzUZ+cwNhWUpb71u+DzT7rFkg5pLssDGaEber91Y2WNGdpVpwGivfEluMlNMZumzjEqfg8DeKXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.54.0.tgz", + "integrity": "sha512-43RmbhUhqt3uuPnc16cX6NsxEASEtn8z/cYV8Zpt6EP4p2h9s4FNuJ4Q9BbEQ2C0YlCCB/2crO1ruVz/hWt8fA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.28.0.tgz", + "integrity": "sha512-ExXGBp0sUj8yhm6Znhf9jmuOaGDsYfDES3gswZnKr4MCqoBWQdEFn6EoDdt5u+RdbxQER+t43FoUihEfTSqsjA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.59.0.tgz", + "integrity": "sha512-pMKV/qnHiW/Q6pmbKkxt0eIhuNEtvJ7sUAyee192HErlr+a1Jx+FZ3WjfmzhQL1geewyGEiPGkmjjAgNY8TgDA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.30.0.tgz", + "integrity": "sha512-n3Cf8YhG7reaj5dncGlRIU7iT40bxPOjsBEA5Bc1a1g6e9Qvb+JFJ7SEiMlPbUw4PBmxE3h40ltE8LZ3zVt6OA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.54.0.tgz", + "integrity": "sha512-8dXMBzzmEdXfH/wjuRvcJnUFeWzZHUnExkmFJ2uPfa31wmpyBCMxO59yr8f/OXXgSogNgi/uPo9KW9H7LMIZ+g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.58.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.58.0.tgz", + "integrity": "sha512-+yWVVY7fxOs3j2RixCbvue8vUuJ1inHxN2q1sduqDB0Wnkr4vOzVKRYl/Zy7B31/dcPS72D9lo/kltdOTBM3bQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.57.0.tgz", + "integrity": "sha512-Os4THbvls8cTQTVA8ApLfZZztuuqGEeqog0XUnyRW7QVF0d/vOVBEcBCk1pazPFmllXGEdNbbat8e2fYIWdFbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.211.0.tgz", + "integrity": "sha512-n0IaQ6oVll9PP84SjbOCwDjaJasWRHi6BLsbMLiT6tNj7QbVOkuA5sk/EfZczwI0j5uTKl1awQPivO/ldVtsqA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.0", + "@opentelemetry/instrumentation": "0.211.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "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-ioredis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", + "integrity": "sha512-875UxzBHWkW+P4Y45SoFM2AR8f8TzBMD8eO7QXGCyFSCUMP5s9vtt/BS8b/r2kqLyaRPK6mLbdnZznK3XzQWvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.20.0.tgz", + "integrity": "sha512-yJXOuWZROzj7WmYCUiyT27tIfqBrVtl1/TwVbQyWPz7rL0r1Lu7kWjD0PiVeTCIL6CrIZ7M2s8eBxsTAOxbNvw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.55.0.tgz", + "integrity": "sha512-FtTL5DUx5Ka/8VK6P1VwnlUXPa3nrb7REvm5ddLUIeXXq4tb9pKd+/ThB1xM/IjefkRSN3z8a5t7epYw1JLBJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.59.0.tgz", + "integrity": "sha512-K9o2skADV20Skdu5tG2bogPKiSpXh4KxfLjz6FuqIVvDJNibwSdu5UvyyBzRVp1rQMV6UmoIk6d3PyPtJbaGSg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.55.0.tgz", + "integrity": "sha512-FDBfT7yDGcspN0Cxbu/k8A0Pp1Jhv/m7BMTzXGpcb8ENl3tDj/51U65R5lWzUH15GaZA15HQ5A5wtafklxYj7g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.64.0.tgz", + "integrity": "sha512-pFlCJjweTqVp7B220mCvCld1c1eYKZfQt1p3bxSbcReypKLJTwat+wbL2YZoX9jPi5X2O8tTKFEOahO5ehQGsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.57.0.tgz", + "integrity": "sha512-MthiekrU/BAJc5JZoZeJmo0OTX6ycJMiP6sMOSRTkvz5BrPMYDqaJos0OgsLPL/HpcgHP7eo5pduETuLguOqcg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz", + "integrity": "sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz", + "integrity": "sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.63.0.tgz", + "integrity": "sha512-dKm/ODNN3GgIQVlbD6ZPxwRc3kleLf95hrRWXM+l8wYo+vSeXtEpQPT53afEf6VFWDVzJK55VGn8KMLtSve/cg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.7" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.59.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.59.0.tgz", + "integrity": "sha512-JKv1KDDYA2chJ1PC3pLP+Q9ISMQk6h5ey+99mB57/ARk0vQPGZTTEb4h4/JlcEpy7AYT8HIGv7X6l+br03Neeg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.30.0.tgz", + "integrity": "sha512-bZy9Q8jFdycKQ2pAsyuHYUHNmCxCOGdG6eg1Mn75RvQDccq832sU5OWOBnc12EFUELI6icJkhR7+EQKMBam2GA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.21.0.tgz", + "integrity": "sha512-gok0LPUOTz2FQ1YJMZzaHcOzDFyT64XJ8M9rNkugk923/p6lDGms/cRW1cqgqp6N6qcd6K6YdVHwPEhnx9BWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@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.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", + "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.5.1", + "@opentelemetry/resources": "2.5.1", + "@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.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -2670,6 +3212,47 @@ "node": ">=14" } }, + "node_modules/@prisma/instrumentation": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.2.0.tgz", + "integrity": "sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", + "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "import-in-the-middle": "^2.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/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -3020,6 +3603,163 @@ "win32" ] }, + "node_modules/@sentry/core": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz", + "integrity": "sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.39.0.tgz", + "integrity": "sha512-dx66DtU/xkCTPEDsjU+mYSIEbzu06pzKNQcDA2wvx7wvwsUciZ5yA32Ce/o6p2uHHgy0/joJX9rP5J/BIijaOA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.5.0", + "@opentelemetry/core": "^2.5.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/instrumentation-amqplib": "0.58.0", + "@opentelemetry/instrumentation-connect": "0.54.0", + "@opentelemetry/instrumentation-dataloader": "0.28.0", + "@opentelemetry/instrumentation-express": "0.59.0", + "@opentelemetry/instrumentation-fs": "0.30.0", + "@opentelemetry/instrumentation-generic-pool": "0.54.0", + "@opentelemetry/instrumentation-graphql": "0.58.0", + "@opentelemetry/instrumentation-hapi": "0.57.0", + "@opentelemetry/instrumentation-http": "0.211.0", + "@opentelemetry/instrumentation-ioredis": "0.59.0", + "@opentelemetry/instrumentation-kafkajs": "0.20.0", + "@opentelemetry/instrumentation-knex": "0.55.0", + "@opentelemetry/instrumentation-koa": "0.59.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.55.0", + "@opentelemetry/instrumentation-mongodb": "0.64.0", + "@opentelemetry/instrumentation-mongoose": "0.57.0", + "@opentelemetry/instrumentation-mysql": "0.57.0", + "@opentelemetry/instrumentation-mysql2": "0.57.0", + "@opentelemetry/instrumentation-pg": "0.63.0", + "@opentelemetry/instrumentation-redis": "0.59.0", + "@opentelemetry/instrumentation-tedious": "0.30.0", + "@opentelemetry/instrumentation-undici": "0.21.0", + "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/semantic-conventions": "^1.39.0", + "@prisma/instrumentation": "7.2.0", + "@sentry/core": "10.39.0", + "@sentry/node-core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", + "import-in-the-middle": "^2.0.6", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.39.0.tgz", + "integrity": "sha512-xdeBG00TmtAcGvXnZNbqOCvnZ5kY3s5aT/L8wUQ0w0TT2KmrC9XL/7UHUfJ45TLbjl10kZOtaMQXgUjpwSJW+g==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.3.1", + "@sentry/core": "10.39.0", + "@sentry/opentelemetry": "10.39.0", + "import-in-the-middle": "^2.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/context-async-hooks": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/node/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/node/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/node/node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.39.0.tgz", + "integrity": "sha512-eU8t/pyxjy7xYt6PNCVxT+8SJw5E3pnupdcUNN4ClqG4O5lX4QCDLtId48ki7i30VqrLtR7vmCHMSvqXXdvXPA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.39.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "license": "MIT", @@ -3135,6 +3875,15 @@ "@types/node": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.2", "dev": true, @@ -3174,6 +3923,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.3", "license": "MIT", @@ -3185,7 +3943,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3193,6 +3950,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/pg-pool": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3241,6 +4007,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@unblessed/core": { "version": "1.0.0-alpha.23", "license": "MIT", @@ -3402,6 +4177,27 @@ "node": ">=6.5" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "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/adm-zip": { "version": "0.5.16", "license": "MIT", @@ -4026,6 +4822,12 @@ "version": "1.1.4", "license": "ISC" }, + "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/clean-stack": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", @@ -5703,6 +6505,12 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "license": "MIT" @@ -6215,6 +7023,18 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "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" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "dev": true, @@ -7160,6 +7980,12 @@ "npm": ">=6" } }, + "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", "license": "MIT" @@ -7868,6 +8694,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-from": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index 8de0e504..266a76dd 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@sentry/node": "^10.39.0", "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", diff --git a/src/agents/shared/lifecycle.ts b/src/agents/shared/lifecycle.ts index 447ca582..6ef06947 100644 --- a/src/agents/shared/lifecycle.ts +++ b/src/agents/shared/lifecycle.ts @@ -8,6 +8,7 @@ import { storeLlmCallsBulk, storeRunLogs, } from '../../db/repositories/runsRepository.js'; +import { addBreadcrumb, captureException } from '../../sentry.js'; import type { AgentResult } from '../../types/index.js'; import { loadCascadeEnv, unloadCascadeEnv } from '../../utils/cascadeEnv.js'; import { createFileLogger } from '../../utils/fileLogger.js'; @@ -238,6 +239,11 @@ export async function executeAgentLifecycle( const log = createAgentLogger(fileLogger); setWatchdogCleanup(async () => { + const durationMs = Date.now() - startTime; + captureException(new Error('Agent watchdog timeout'), { + tags: { source: 'watchdog_timeout', agent: options.loggerIdentifier }, + extra: { runId, durationMs }, + }); fileLogger.close(); await finalizeRun( runId, @@ -245,7 +251,7 @@ export async function executeAgentLifecycle( llmCallAccumulator, { status: 'timed_out', - durationMs: Date.now() - startTime, + durationMs, success: false, error: 'Watchdog timeout', }, @@ -279,6 +285,12 @@ export async function executeAgentLifecycle( runId, }); + addBreadcrumb({ + category: 'agent', + message: `Starting ${options.loggerIdentifier}`, + data: { model: ctx.model, maxIterations: ctx.maxIterations, runId }, + }); + try { process.env.LLMIST_LOG_FILE = fileLogger.llmistLogPath; process.env.LLMIST_LOG_TEE = 'true'; @@ -370,6 +382,10 @@ export async function executeAgentLifecycle( identifier: options.loggerIdentifier, error: String(err), }); + captureException(err, { + tags: { source: 'agent_lifecycle', agent: options.loggerIdentifier }, + extra: { runId, durationMs: Date.now() - startTime }, + }); let logBuffer: Buffer | undefined; try { diff --git a/src/config/env.ts b/src/config/env.ts index 41dffffd..a39d7a9a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -2,6 +2,7 @@ export interface EnvConfig { port: number; logLevel: string; databaseUrl: string; + sentryDsn?: string; } function getEnvOrThrow(key: string): string { @@ -21,6 +22,7 @@ export function loadEnvConfig(): EnvConfig { port: Number.parseInt(getEnvOrDefault('PORT', '3000'), 10), logLevel: getEnvOrDefault('LOG_LEVEL', 'info'), databaseUrl: getEnvOrThrow('DATABASE_URL'), + sentryDsn: process.env.SENTRY_DSN, }; } @@ -29,5 +31,6 @@ export function loadEnvConfigSafe(): Omit & { database port: Number.parseInt(getEnvOrDefault('PORT', '3000'), 10), logLevel: getEnvOrDefault('LOG_LEVEL', 'info'), databaseUrl: process.env.DATABASE_URL, + sentryDsn: process.env.SENTRY_DSN, }; } diff --git a/src/config/retryConfig.ts b/src/config/retryConfig.ts index 9afc8c4f..418ef331 100644 --- a/src/config/retryConfig.ts +++ b/src/config/retryConfig.ts @@ -1,5 +1,6 @@ import { type RetryConfig, isRetryableError } from 'llmist'; import type { ILogObj, Logger } from 'llmist'; +import { addBreadcrumb, captureException } from '../sentry.js'; /** * Check if an error is a transient stream/connection error from undici/fetch. @@ -59,6 +60,12 @@ export function getRetryConfig(logger: Logger): RetryConfig { isStreamError, nextRetryDelayMs: baseDelay, }); + addBreadcrumb({ + category: 'llm', + message: `LLM retry attempt ${attempt}/5`, + level: 'warning', + data: { attempt, error: error.message, isStreamError, nextRetryDelayMs: baseDelay }, + }); }, onRetriesExhausted: (error: Error, attempts: number) => { @@ -67,6 +74,10 @@ export function getRetryConfig(logger: Logger): RetryConfig { error: error.message, totalWaitTimeMs: `~${1000 + 2000 + 4000 + 8000 + 16000}`, // Approximate total }); + captureException(error, { + tags: { source: 'llm_retries_exhausted' }, + extra: { attempts }, + }); }, }; } diff --git a/src/dashboard.ts b/src/dashboard.ts index 144f62e9..464ebd48 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -23,6 +23,9 @@ import { logoutHandler } from './api/auth/logout.js'; import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; +import { captureException, setTag } from './sentry.js'; + +setTag('role', 'dashboard'); const app = new Hono(); @@ -65,6 +68,10 @@ app.notFound((c) => c.json({ error: 'Not Found' }, 404)); // Error handler app.onError((err, c) => { console.error('Unhandled error', { error: String(err), path: c.req.path }); + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); return c.json({ error: 'Internal Server Error' }, 500); }); diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 00000000..aeddaadf --- /dev/null +++ b/src/instrument.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; + +if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production', + release: process.env.SENTRY_RELEASE || undefined, + tracesSampleRate: + process.env.SENTRY_TRACES_SAMPLE_RATE != null + ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) + : 0.1, + sendDefaultPii: false, + }); +} diff --git a/src/router/index.ts b/src/router/index.ts index 8232c82b..d24edd06 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,5 +1,6 @@ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; +import { captureException, flush, setTag } from '../sentry.js'; import { createWebhookHandler, parseGitHubPayload, @@ -19,12 +20,22 @@ import { stopWorkerProcessor, } from './worker-manager.js'; +setTag('role', 'router'); + // Create trigger registry once at router startup for matchTrigger() calls const triggerRegistry = createTriggerRegistry(); registerBuiltInTriggers(triggerRegistry); const app = new Hono(); +app.onError((err, c) => { + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); + return c.text('Internal Server Error', 500); +}); + // Health check with queue stats app.get('/health', async (c) => { const queueStats = await getQueueStats(); @@ -114,12 +125,24 @@ app.post( async function shutdown(signal: string): Promise { console.log(`[Router] Received ${signal}, shutting down...`); await stopWorkerProcessor(); + await flush(3000); process.exit(0); } process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('uncaughtException', (err) => { + captureException(err, { tags: { source: 'uncaughtException' }, level: 'fatal' }); +}); + +process.on('unhandledRejection', (reason) => { + captureException(reason instanceof Error ? reason : new Error(String(reason)), { + tags: { source: 'unhandledRejection' }, + level: 'error', + }); +}); + // Start server and worker processor async function startRouter(): Promise { const port = Number(process.env.PORT) || 3000; @@ -128,7 +151,9 @@ async function startRouter(): Promise { serve({ fetch: app.fetch, port }); } -startRouter().catch((err) => { +startRouter().catch(async (err) => { console.error('[Router] Failed to start:', err); + captureException(err, { tags: { source: 'router_startup' }, level: 'fatal' }); + await flush(3000); process.exit(1); }); diff --git a/src/router/queue.ts b/src/router/queue.ts index 80714d32..cb298542 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -1,4 +1,5 @@ import { type ConnectionOptions, Queue } from 'bullmq'; +import { captureException } from '../sentry.js'; import { routerConfig } from './config.js'; // Parse Redis URL to connection options @@ -67,6 +68,7 @@ export const jobQueue = new Queue('cascade-jobs', { // Queue event logging jobQueue.on('error', (err) => { console.error('[Queue] Error:', err); + captureException(err, { tags: { source: 'job_queue' } }); }); console.log('[Queue] Initialized with Redis at', routerConfig.redisUrl); diff --git a/src/router/worker-manager.ts b/src/router/worker-manager.ts index 4db95646..558e3638 100644 --- a/src/router/worker-manager.ts +++ b/src/router/worker-manager.ts @@ -1,6 +1,7 @@ import { type Job, Worker } from 'bullmq'; import Docker from 'dockerode'; import { findProjectByRepo, getAllProjectCredentials } from '../config/provider.js'; +import { captureException } from '../sentry.js'; import { routerConfig } from './config.js'; import { notifyTimeout } from './notifications.js'; import type { CascadeJob } from './queue.js'; @@ -78,6 +79,11 @@ async function buildWorkerEnv(job: Job): Promise { projectId, error: String(err), }); + captureException(err, { + tags: { source: 'credential_resolution' }, + extra: { projectId }, + level: 'warning', + }); } } @@ -85,6 +91,12 @@ async function buildWorkerEnv(job: Job): Promise { if (process.env.CLAUDE_CODE_OAUTH_TOKEN) env.push(`CLAUDE_CODE_OAUTH_TOKEN=${process.env.CLAUDE_CODE_OAUTH_TOKEN}`); + // Forward Sentry env vars so worker containers report to the same project. + if (process.env.SENTRY_DSN) env.push(`SENTRY_DSN=${process.env.SENTRY_DSN}`); + if (process.env.SENTRY_ENVIRONMENT) + env.push(`SENTRY_ENVIRONMENT=${process.env.SENTRY_ENVIRONMENT}`); + if (process.env.SENTRY_RELEASE) env.push(`SENTRY_RELEASE=${process.env.SENTRY_RELEASE}`); + return env; } @@ -131,6 +143,11 @@ async function spawnWorker(job: Job): Promise { jobId, durationMs, }); + captureException(new Error(`Worker timeout after ${durationMs}ms`), { + tags: { source: 'worker_timeout', jobType: job.data.type }, + extra: { jobId, durationMs }, + level: 'warning', + }); killWorker(jobId).catch((err) => { console.error('[WorkerManager] Failed to kill timed-out worker:', err); }); @@ -173,6 +190,12 @@ async function spawnWorker(job: Job): Promise { // Container may already be removed — expected with AutoRemove } + if (result.StatusCode !== 0) { + captureException(new Error(`Worker exited with status ${result.StatusCode}`), { + tags: { source: 'worker_exit', jobType: job.data.type }, + extra: { jobId, statusCode: result.StatusCode }, + }); + } console.log('[WorkerManager] Worker exited:', { jobId, statusCode: result.StatusCode, @@ -181,6 +204,10 @@ async function spawnWorker(job: Job): Promise { }) .catch((err) => { console.error('[WorkerManager] Error waiting for container:', err); + captureException(err, { + tags: { source: 'worker_wait', jobType: job.data.type }, + extra: { jobId }, + }); cleanupWorker(jobId); }); } catch (err) { @@ -188,6 +215,10 @@ async function spawnWorker(job: Job): Promise { jobId, error: String(err), }); + captureException(err, { + tags: { source: 'worker_spawn', jobType: job.data.type }, + extra: { jobId }, + }); throw err; } } @@ -299,10 +330,17 @@ export function startWorkerProcessor(): void { jobId: job?.id, error: String(err), }); + captureException(err, { + tags: { source: 'bullmq_dispatch', queue: 'cascade-jobs' }, + extra: { jobId: job?.id }, + }); }); bullWorker.on('error', (err) => { console.error('[WorkerManager] Worker error:', err); + captureException(err, { + tags: { source: 'bullmq_error', queue: 'cascade-jobs' }, + }); }); // Dashboard jobs queue — manual runs, retries, debug analyses submitted @@ -333,10 +371,17 @@ export function startWorkerProcessor(): void { jobId: job?.id, error: String(err), }); + captureException(err, { + tags: { source: 'bullmq_dispatch', queue: 'cascade-dashboard-jobs' }, + extra: { jobId: job?.id }, + }); }); dashboardWorker.on('error', (err) => { console.error('[WorkerManager] Dashboard worker error:', err); + captureException(err, { + tags: { source: 'bullmq_error', queue: 'cascade-dashboard-jobs' }, + }); }); console.log('[WorkerManager] Started with max', routerConfig.maxWorkers, 'concurrent workers'); diff --git a/src/sentry.ts b/src/sentry.ts new file mode 100644 index 00000000..48b14850 --- /dev/null +++ b/src/sentry.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/node'; + +export const sentryEnabled = !!process.env.SENTRY_DSN; + +export function captureException( + error: unknown, + context?: { + tags?: Record; + extra?: Record; + level?: Sentry.SeverityLevel; + }, +): void { + if (!sentryEnabled) return; + + Sentry.withScope((scope) => { + if (context?.tags) { + for (const [key, value] of Object.entries(context.tags)) { + scope.setTag(key, value); + } + } + if (context?.extra) { + for (const [key, value] of Object.entries(context.extra)) { + scope.setExtra(key, value); + } + } + if (context?.level) { + scope.setLevel(context.level); + } + Sentry.captureException(error); + }); +} + +export function addBreadcrumb(breadcrumb: Sentry.Breadcrumb): void { + if (!sentryEnabled) return; + Sentry.addBreadcrumb(breadcrumb); +} + +export function setTag(key: string, value: string): void { + if (!sentryEnabled) return; + Sentry.setTag(key, value); +} + +export async function flush(timeoutMs = 2000): Promise { + if (!sentryEnabled) return; + await Sentry.flush(timeoutMs); +} diff --git a/src/server.ts b/src/server.ts index 4d586382..ef743bb6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import { logoutHandler } from './api/auth/logout.js'; import { resolveUserFromSession } from './api/auth/session.js'; import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; +import { captureException } from './sentry.js'; import { buildGitHubReactionSender, buildJiraReactionSender, @@ -153,6 +154,10 @@ export function createServer(deps: ServerDependencies): Hono { // Error handler app.onError((err, c) => { logger.error('Unhandled error', { error: String(err), path: c.req.path }); + captureException(err, { + tags: { source: 'hono_error' }, + extra: { path: c.req.path, method: c.req.method }, + }); return c.json({ error: 'Internal Server Error' }, 500); }); diff --git a/src/server/webhookHandlers.ts b/src/server/webhookHandlers.ts index 90ed93f6..d855f623 100644 --- a/src/server/webhookHandlers.ts +++ b/src/server/webhookHandlers.ts @@ -23,6 +23,7 @@ import { findProjectByRepo } from '../config/provider.js'; import { resolvePersonaIdentities } from '../github/personas.js'; import { sendAcknowledgeReaction } from '../router/reactions.js'; import { extractRawHeaders, parseGitHubWebhookPayload } from '../router/webhookParsing.js'; +import { captureException } from '../sentry.js'; import type { CascadeConfig } from '../types/index.js'; import { canAcceptWebhook, isCurrentlyProcessing, logger } from '../utils/index.js'; import { logWebhookCall } from '../utils/webhookLogger.js'; @@ -142,6 +143,9 @@ function handleProcessingError(source: WebhookHandlerConfig['source'], err: unkn error: String(err), stack: err instanceof Error ? err.stack : undefined, }); + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: `${source}_webhook` }, + }); } /** diff --git a/src/utils/lifecycle.ts b/src/utils/lifecycle.ts index 256e6b39..cfbc6a25 100644 --- a/src/utils/lifecycle.ts +++ b/src/utils/lifecycle.ts @@ -1,3 +1,4 @@ +import { flush } from '../sentry.js'; import { logger } from './logging.js'; let watchdogTimer: ReturnType | null = null; @@ -43,6 +44,7 @@ export function startWatchdog(timeoutMs: number): void { } } + await flush(3000); logger.error('Force exiting'); process.exit(1); }, timeoutMs); diff --git a/src/worker-entry.ts b/src/worker-entry.ts index ce709d07..17da73ff 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -16,7 +16,9 @@ import { loadEnvConfigSafe } from './config/env.js'; import { loadConfig } from './config/provider.js'; import { getDb } from './db/client.js'; +import { captureException, flush, setTag } from './sentry.js'; import { + type TriggerRegistry, createTriggerRegistry, processGitHubWebhook, processJiraWebhook, @@ -133,13 +135,76 @@ async function processDashboardJob(jobId: string, jobData: DashboardJobData): Pr } } +async function dispatchJob( + jobId: string, + jobData: JobData, + triggerRegistry: TriggerRegistry, +): Promise { + switch (jobData.type) { + case 'trello': + logger.info('[Worker] Processing Trello job', { + jobId, + cardId: jobData.cardId, + actionType: jobData.actionType, + ackCommentId: jobData.ackCommentId, + }); + await processTrelloWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + break; + case 'github': + logger.info('[Worker] Processing GitHub job', { + jobId, + eventType: jobData.eventType, + repoFullName: jobData.repoFullName, + ackCommentId: jobData.ackCommentId, + }); + await processGitHubWebhook( + jobData.payload, + jobData.eventType, + triggerRegistry, + jobData.ackCommentId, + jobData.ackMessage, + ); + break; + case 'jira': + logger.info('[Worker] Processing JIRA job', { + jobId, + issueKey: jobData.issueKey, + webhookEvent: jobData.webhookEvent, + ackCommentId: jobData.ackCommentId, + }); + await processJiraWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); + break; + case 'manual-run': + case 'retry-run': + case 'debug-analysis': + await processDashboardJob(jobId, jobData); + break; + default: { + const unknownType = (jobData as { type: string }).type; + logger.error('[Worker] Unknown job type', { jobType: unknownType }); + captureException(new Error(`Unknown job type: ${unknownType}`), { + tags: { source: 'worker_unknown_job' }, + }); + await flush(); + process.exit(1); + } + } +} + async function main(): Promise { const jobId = process.env.JOB_ID; const jobType = process.env.JOB_TYPE; const jobDataRaw = process.env.JOB_DATA; + setTag('role', 'worker'); + if (jobId) setTag('jobId', jobId); + if (jobType) setTag('jobType', jobType); + if (!jobId || !jobType || !jobDataRaw) { - console.error('[Worker] Missing required environment variables: JOB_ID, JOB_TYPE, JOB_DATA'); + const err = new Error('Missing required environment variables: JOB_ID, JOB_TYPE, JOB_DATA'); + console.error(`[Worker] ${err.message}`); + captureException(err, { tags: { source: 'worker_env' } }); + await flush(); process.exit(1); } @@ -148,9 +213,15 @@ async function main(): Promise { jobData = JSON.parse(jobDataRaw); } catch (err) { console.error('[Worker] Failed to parse JOB_DATA:', err); + captureException(err, { tags: { source: 'worker_job_parse' } }); + await flush(); process.exit(1); } + // Set Sentry tags from parsed job data + if ('projectId' in jobData && jobData.projectId) setTag('projectId', jobData.projectId); + if ('agentType' in jobData && jobData.agentType) setTag('agentType', jobData.agentType); + // Load environment config const envConfig = loadEnvConfigSafe(); setLogLevel(envConfig.logLevel); @@ -182,56 +253,21 @@ async function main(): Promise { registerBuiltInTriggers(triggerRegistry); try { - if (jobData.type === 'trello') { - logger.info('[Worker] Processing Trello job', { - jobId, - cardId: jobData.cardId, - actionType: jobData.actionType, - ackCommentId: jobData.ackCommentId, - }); - await processTrelloWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); - } else if (jobData.type === 'github') { - logger.info('[Worker] Processing GitHub job', { - jobId, - eventType: jobData.eventType, - repoFullName: jobData.repoFullName, - ackCommentId: jobData.ackCommentId, - }); - await processGitHubWebhook( - jobData.payload, - jobData.eventType, - triggerRegistry, - jobData.ackCommentId, - jobData.ackMessage, - ); - } else if (jobData.type === 'jira') { - logger.info('[Worker] Processing JIRA job', { - jobId, - issueKey: jobData.issueKey, - webhookEvent: jobData.webhookEvent, - ackCommentId: jobData.ackCommentId, - }); - await processJiraWebhook(jobData.payload, triggerRegistry, jobData.ackCommentId); - } else if ( - jobData.type === 'manual-run' || - jobData.type === 'retry-run' || - jobData.type === 'debug-analysis' - ) { - await processDashboardJob(jobId, jobData); - } else { - logger.error('[Worker] Unknown job type', { jobType: (jobData as { type: string }).type }); - process.exit(1); - } - + await dispatchJob(jobId, jobData, triggerRegistry); logger.info('[Worker] Job completed successfully', { jobId }); + await flush(); process.exit(0); } catch (err) { logger.error('[Worker] Job failed', { jobId, error: String(err) }); + captureException(err, { tags: { source: 'worker_job_failure' } }); + await flush(); process.exit(1); } } -main().catch((err) => { +main().catch(async (err) => { console.error('[Worker] Unhandled error:', err); + captureException(err, { tags: { source: 'worker_unhandled' }, level: 'fatal' }); + await flush(); process.exit(1); }); diff --git a/tests/unit/instrument.test.ts b/tests/unit/instrument.test.ts new file mode 100644 index 00000000..039b39ad --- /dev/null +++ b/tests/unit/instrument.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn(), +})); + +import * as Sentry from '@sentry/node'; + +const mockInit = vi.mocked(Sentry.init); + +describe('instrument (Sentry init)', () => { + beforeEach(() => { + vi.resetModules(); + mockInit.mockClear(); + }); + + afterEach(() => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_ENVIRONMENT; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_RELEASE; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_TRACES_SAMPLE_RATE; + }); + + it('does NOT call Sentry.init when SENTRY_DSN is unset', async () => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + await import('../../src/instrument.js'); + expect(mockInit).not.toHaveBeenCalled(); + }); + + it('calls Sentry.init with DSN when SENTRY_DSN is set', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith( + expect.objectContaining({ dsn: 'https://fake@sentry.io/123' }), + ); + }); + + it('passes environment from SENTRY_ENVIRONMENT', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_ENVIRONMENT = 'staging'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ environment: 'staging' })); + }); + + it('falls back to NODE_ENV for environment', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_ENVIRONMENT; + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ environment: 'test' })); + process.env.NODE_ENV = originalNodeEnv; + }); + + it('passes release from SENTRY_RELEASE', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_RELEASE = 'abc123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ release: 'abc123' })); + }); + + it('defaults tracesSampleRate to 0.1', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_TRACES_SAMPLE_RATE; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0.1 })); + }); + + it('respects SENTRY_TRACES_SAMPLE_RATE=0 (does not fall back to 0.1)', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_TRACES_SAMPLE_RATE = '0'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 0 })); + }); + + it('parses SENTRY_TRACES_SAMPLE_RATE=1 correctly', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + process.env.SENTRY_TRACES_SAMPLE_RATE = '1'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ tracesSampleRate: 1 })); + }); + + it('disables PII collection', async () => { + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + await import('../../src/instrument.js'); + expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({ sendDefaultPii: false })); + }); +}); diff --git a/tests/unit/sentry.test.ts b/tests/unit/sentry.test.ts new file mode 100644 index 00000000..57e8a250 --- /dev/null +++ b/tests/unit/sentry.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock @sentry/node before any import (ESM exports are frozen, vi.spyOn won't work) +vi.mock('@sentry/node', () => ({ + init: vi.fn(), + withScope: vi.fn((cb: (scope: unknown) => void) => cb(mockScope)), + captureException: vi.fn(), + addBreadcrumb: vi.fn(), + setTag: vi.fn(), + flush: vi.fn().mockResolvedValue(true), +})); + +const mockScope = { + setTag: vi.fn(), + setExtra: vi.fn(), + setLevel: vi.fn(), +}; + +import * as Sentry from '@sentry/node'; + +describe('sentry wrappers', () => { + describe('when SENTRY_DSN is NOT set (disabled)', () => { + let sentry: typeof import('../../src/sentry.js'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + sentry = await import('../../src/sentry.js'); + }); + + it('sentryEnabled is false', () => { + expect(sentry.sentryEnabled).toBe(false); + }); + + it('captureException does not call Sentry', () => { + sentry.captureException(new Error('test')); + expect(Sentry.withScope).not.toHaveBeenCalled(); + }); + + it('addBreadcrumb does not call Sentry', () => { + sentry.addBreadcrumb({ message: 'test', category: 'test' }); + expect(Sentry.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('setTag does not call Sentry', () => { + sentry.setTag('key', 'value'); + expect(Sentry.setTag).not.toHaveBeenCalled(); + }); + + it('flush resolves without calling Sentry', async () => { + await sentry.flush(); + expect(Sentry.flush).not.toHaveBeenCalled(); + }); + }); + + describe('when SENTRY_DSN IS set (enabled)', () => { + let sentry: typeof import('../../src/sentry.js'); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + for (const k of Object.keys(mockScope)) mockScope[k as keyof typeof mockScope].mockClear(); + process.env.SENTRY_DSN = 'https://fake@sentry.io/123'; + sentry = await import('../../src/sentry.js'); + }); + + afterEach(() => { + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset + delete process.env.SENTRY_DSN; + }); + + it('sentryEnabled is true', () => { + expect(sentry.sentryEnabled).toBe(true); + }); + + it('captureException delegates to Sentry.withScope', () => { + sentry.captureException(new Error('test')); + expect(Sentry.withScope).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + + it('captureException sets tags, extra, and level on scope', () => { + const error = new Error('test'); + sentry.captureException(error, { + tags: { source: 'test_source', role: 'worker' }, + extra: { jobId: '123' }, + level: 'fatal', + }); + + expect(mockScope.setTag).toHaveBeenCalledWith('source', 'test_source'); + expect(mockScope.setTag).toHaveBeenCalledWith('role', 'worker'); + expect(mockScope.setExtra).toHaveBeenCalledWith('jobId', '123'); + expect(mockScope.setLevel).toHaveBeenCalledWith('fatal'); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + + it('captureException works with no context', () => { + const error = new Error('bare error'); + sentry.captureException(error); + expect(Sentry.captureException).toHaveBeenCalledWith(error); + expect(mockScope.setTag).not.toHaveBeenCalled(); + expect(mockScope.setExtra).not.toHaveBeenCalled(); + expect(mockScope.setLevel).not.toHaveBeenCalled(); + }); + + it('addBreadcrumb delegates to Sentry', () => { + const breadcrumb = { message: 'test', category: 'http' }; + sentry.addBreadcrumb(breadcrumb); + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb); + }); + + it('setTag delegates to Sentry', () => { + sentry.setTag('role', 'router'); + expect(Sentry.setTag).toHaveBeenCalledWith('role', 'router'); + }); + + it('flush delegates to Sentry.flush with timeout', async () => { + await sentry.flush(5000); + expect(Sentry.flush).toHaveBeenCalledWith(5000); + }); + + it('flush uses default 2000ms timeout', async () => { + await sentry.flush(); + expect(Sentry.flush).toHaveBeenCalledWith(2000); + }); + }); +}); diff --git a/tests/unit/server/webhookHandlers.test.ts b/tests/unit/server/webhookHandlers.test.ts index 54138fb0..7a117fb3 100644 --- a/tests/unit/server/webhookHandlers.test.ts +++ b/tests/unit/server/webhookHandlers.test.ts @@ -29,9 +29,14 @@ vi.mock('../../../src/utils/webhookLogger.js', () => ({ logWebhookCall: vi.fn(), })); +vi.mock('../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + import { findProjectByRepo } from '../../../src/config/provider.js'; import { resolvePersonaIdentities } from '../../../src/github/personas.js'; import { sendAcknowledgeReaction } from '../../../src/router/reactions.js'; +import { captureException } from '../../../src/sentry.js'; import { buildGitHubReactionSender, buildJiraReactionSender, @@ -44,6 +49,7 @@ import { import { canAcceptWebhook, isCurrentlyProcessing } from '../../../src/utils/index.js'; import { logWebhookCall } from '../../../src/utils/webhookLogger.js'; +const mockCaptureException = vi.mocked(captureException); const mockLogWebhookCall = vi.mocked(logWebhookCall); const mockIsCurrentlyProcessing = vi.mocked(isCurrentlyProcessing); const mockCanAcceptWebhook = vi.mocked(canAcceptWebhook); @@ -311,6 +317,31 @@ describe('createWebhookHandler', () => { ); }); + it('captures processWebhook errors to Sentry in fire-and-forget mode', async () => { + vi.useFakeTimers(); + const processError = new Error('redis connection failed'); + const handler = createWebhookHandler({ + source: 'trello', + fireAndForget: true, + parsePayload: async () => ({ ok: true, payload: {}, eventType: 'commentCard' }), + processWebhook: vi.fn().mockRejectedValue(processError), + }); + + const app = buildApp(handler); + const res = await postJson(app, {}); + // Fire-and-forget always returns 200 + expect(res.status).toBe(200); + + // Let setImmediate fire and the rejection be caught + await vi.runAllTimersAsync(); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.objectContaining({ message: 'redis connection failed' }), + expect.objectContaining({ tags: { source: 'trello_webhook' } }), + ); + vi.useRealTimers(); + }); + it('lets processWebhook errors propagate when fireAndForget=false', async () => { const handler = createWebhookHandler({ source: 'jira', diff --git a/tests/unit/utils/lifecycle.test.ts b/tests/unit/utils/lifecycle.test.ts index da3f7d7c..37e50675 100644 --- a/tests/unit/utils/lifecycle.test.ts +++ b/tests/unit/utils/lifecycle.test.ts @@ -8,6 +8,11 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); +vi.mock('../../../src/sentry.js', () => ({ + flush: vi.fn().mockResolvedValue(undefined), +})); + +import { flush } from '../../../src/sentry.js'; import { clearWatchdog, clearWatchdogCleanup, @@ -17,6 +22,8 @@ import { startWatchdog, } from '../../../src/utils/lifecycle.js'; +const mockFlush = vi.mocked(flush); + describe('lifecycle', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,11 +58,20 @@ describe('lifecycle', () => { }); describe('watchdog', () => { - it('force exits after timeout', () => { + it('force exits after timeout', async () => { + startWatchdog(30000); + + await vi.advanceTimersByTimeAsync(30000); + + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('flushes Sentry before force exit', async () => { startWatchdog(30000); - vi.advanceTimersByTime(30000); + await vi.advanceTimersByTimeAsync(30000); + expect(mockFlush).toHaveBeenCalledWith(3000); expect(process.exit).toHaveBeenCalledWith(1); }); @@ -68,14 +84,14 @@ describe('lifecycle', () => { expect(process.exit).not.toHaveBeenCalled(); }); - it('clears previous watchdog when starting new one', () => { + it('clears previous watchdog when starting new one', async () => { startWatchdog(5000); startWatchdog(10000); - vi.advanceTimersByTime(5000); + await vi.advanceTimersByTimeAsync(5000); expect(process.exit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(5000); + await vi.advanceTimersByTimeAsync(5000); expect(process.exit).toHaveBeenCalledWith(1); }); });