diff --git a/sentry-javascript/17742/README.md b/sentry-javascript/17742/README.md new file mode 100644 index 0000000..d120a41 --- /dev/null +++ b/sentry-javascript/17742/README.md @@ -0,0 +1,68 @@ +# Reproduction for sentry-javascript#17742 + +**Issue:** https://github.com/getsentry/sentry-javascript/issues/17742 + +## Description + +Breadcrumbs from background jobs leak into HTTP request error events in NestJS. Background jobs run outside the HTTP request context, so they add breadcrumbs to the default isolation scope. When a new HTTP request arrives, `httpServerIntegration` clones the default scope — inheriting all those stale breadcrumbs. + +This reproduction covers **all four** common NestJS background job patterns: + +| Framework | Decorator | External Dep | Env Var | +|-----------|-----------|-------------|---------| +| `@nestjs/schedule` | `@Interval` / `@Cron` | None | Always active | +| `@nestjs/event-emitter` | `@OnEvent` | None | Always active | +| `@nestjs/bullmq` | `@Processor` | Redis | `REDIS_URL` | +| `nestjs-graphile-worker` | `@Task` | PostgreSQL | `DATABASE_URL` | + +## Steps to Reproduce + +1. Add a `.env` file with your Sentry DSN (and optionally Redis/PostgreSQL): + ```bash + SENTRY_DSN= + # Optional: + # REDIS_URL=redis://localhost:6379 + # DATABASE_URL=postgres://user:pass@localhost:5432/dbname + ``` + +2. Install dependencies and run: + ```bash + npm install + npm run test:repro + ``` + +3. Check the output for leaked breadcrumbs. + +## Expected Behavior + +The error event from `GET /trigger-error` should only contain its own breadcrumb: +``` +=== Sentry Event Breadcrumbs (1 total) === + [0] category=http-request, message=About to trigger an error in HTTP handler +``` + +## Actual Behavior + +``` +*** BUG CONFIRMED: Breadcrumbs leaked from background jobs! *** + Leaked: schedule-job: 3, event-job: 2 +``` + +## Root Cause + +In `packages/node-core/src/integrations/http/httpServerIntegration.ts:185`: +```ts +const isolationScope = getIsolationScope().clone(); +``` + +Background jobs execute on the default isolation scope (no HTTP request forked a new one). Their breadcrumbs accumulate on the default scope. When `httpServerIntegration` handles a new request, it clones the default scope — including all stale breadcrumbs from background jobs. + +## Environment + +- Node.js: v18+ +- @sentry/nestjs: ^10.2.0 +- @nestjs/core: ^10.0.0 +- @nestjs/schedule: ^6.1.1 +- @nestjs/event-emitter: latest +- @nestjs/bullmq: latest (optional) +- nestjs-graphile-worker: latest (optional) diff --git a/sentry-javascript/17742/nest-cli.json b/sentry-javascript/17742/nest-cli.json new file mode 100644 index 0000000..2566481 --- /dev/null +++ b/sentry-javascript/17742/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/sentry-javascript/17742/package.json b/sentry-javascript/17742/package.json new file mode 100644 index 0000000..89f427c --- /dev/null +++ b/sentry-javascript/17742/package.json @@ -0,0 +1,30 @@ +{ + "name": "repro-sentry-javascript-17742", + "version": "1.0.0", + "description": "Reproduction for sentry-javascript#17742 - NestJS leaking breadcrumbs", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "test:repro": "npm run build && bash test-repro.sh" + }, + "dependencies": { + "@nestjs/bullmq": "^11.0.4", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^6.1.1", + "@sentry/nestjs": "^10.2.0", + "bullmq": "^5.69.3", + "dotenv": "^17.3.1", + "graphile-worker": "^0.16.6", + "nestjs-graphile-worker": "^0.9.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "typescript": "^5.0.0" + } +} diff --git a/sentry-javascript/17742/src/app.controller.ts b/sentry-javascript/17742/src/app.controller.ts new file mode 100644 index 0000000..a7817bd --- /dev/null +++ b/sentry-javascript/17742/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service"; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get("trigger-error") + triggerError(): string { + return this.appService.triggerError(); + } +} diff --git a/sentry-javascript/17742/src/app.module.ts b/sentry-javascript/17742/src/app.module.ts new file mode 100644 index 0000000..5c956d1 --- /dev/null +++ b/sentry-javascript/17742/src/app.module.ts @@ -0,0 +1,95 @@ +import { DynamicModule, Module } from "@nestjs/common"; +import { APP_FILTER } from "@nestjs/core"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { ScheduleModule } from "@nestjs/schedule"; +import { SentryGlobalFilter, SentryModule } from "@sentry/nestjs/setup"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; +import { ScheduleJobService } from "./schedule-job.service"; +import { EventJobService } from "./event-job.service"; + +/** + * Build imports and providers dynamically based on available services. + * - Schedule + EventEmitter: always enabled (no external deps) + * - BullMQ: enabled when REDIS_URL is set + * - Graphile Worker: enabled when DATABASE_URL is set + */ +function getOptionalImports(): DynamicModule[] { + const imports: DynamicModule[] = []; + + if (process.env.REDIS_URL) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { BullModule } = require("@nestjs/bullmq"); + imports.push( + BullModule.forRoot({ connection: { url: process.env.REDIS_URL } }) + ); + imports.push(BullModule.registerQueue({ name: "background-queue" })); + console.log("[Config] BullMQ enabled (REDIS_URL set)"); + } catch (e) { + console.log("[Config] BullMQ not available"); + } + } else { + console.log("[Config] BullMQ disabled (set REDIS_URL to enable)"); + } + + if (process.env.DATABASE_URL) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { GraphileWorkerModule } = require("nestjs-graphile-worker"); + imports.push( + GraphileWorkerModule.forRoot({ + connectionString: process.env.DATABASE_URL, + }) + ); + console.log("[Config] Graphile Worker enabled (DATABASE_URL set)"); + } catch (e) { + console.log("[Config] Graphile Worker not available"); + } + } else { + console.log("[Config] Graphile Worker disabled (set DATABASE_URL to enable)"); + } + + return imports; +} + +function getOptionalProviders(): any[] { + const providers: any[] = []; + + if (process.env.REDIS_URL) { + try { + const { BullmqJobProcessor, BullmqJobProducer } = require("./bullmq-job.processor"); + providers.push(BullmqJobProcessor, BullmqJobProducer); + } catch (e) {} + } + + if (process.env.DATABASE_URL) { + try { + const { GraphileJobHandler, GraphileJobProducer } = require("./graphile-job.service"); + providers.push(GraphileJobHandler, GraphileJobProducer); + } catch (e) {} + } + + return providers; +} + +@Module({ + imports: [ + SentryModule.forRoot(), + ScheduleModule.forRoot(), + EventEmitterModule.forRoot(), + ...getOptionalImports(), + ], + controllers: [AppController], + providers: [ + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + AppService, + ScheduleJobService, + EventJobService, + ...getOptionalProviders(), + ], +}) +export class AppModule {} diff --git a/sentry-javascript/17742/src/app.service.ts b/sentry-javascript/17742/src/app.service.ts new file mode 100644 index 0000000..a110ea7 --- /dev/null +++ b/sentry-javascript/17742/src/app.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import * as Sentry from "@sentry/nestjs"; + +@Injectable() +export class AppService { + triggerError(): string { + // Add a breadcrumb specific to this HTTP request + Sentry.addBreadcrumb({ + category: "http-request", + message: "About to trigger an error in HTTP handler", + level: "error", + }); + + // Throw an exception — the SentryGlobalFilter captures it automatically. + // EXPECTED: Only the "http-request" breadcrumb should appear on the event + // ACTUAL (BUG): Breadcrumbs from background jobs leak into this event + // because the HTTP request's isolation scope was cloned from the default + // scope, which was polluted by background job breadcrumbs + throw new Error("Test error to check breadcrumb isolation"); + } +} diff --git a/sentry-javascript/17742/src/bullmq-job.processor.ts b/sentry-javascript/17742/src/bullmq-job.processor.ts new file mode 100644 index 0000000..554126b --- /dev/null +++ b/sentry-javascript/17742/src/bullmq-job.processor.ts @@ -0,0 +1,49 @@ +import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { InjectQueue } from "@nestjs/bullmq"; +import { Queue, Job } from "bullmq"; +import * as Sentry from "@sentry/nestjs"; + +/** + * Case 3: @nestjs/bullmq + * BullMQ processors run outside HTTP request context on the default isolation scope. + */ +@Processor("background-queue") +export class BullmqJobProcessor extends WorkerHost { + private jobCount = 0; + + async process(job: Job): Promise { + this.jobCount++; + console.log( + `[@nestjs/bullmq] Processing job #${this.jobCount}: ${job.name}` + ); + + Sentry.addBreadcrumb({ + category: "bullmq-job", + message: `BullMQ job #${this.jobCount} processed`, + level: "info", + }); + + console.log(`[@nestjs/bullmq] Job #${this.jobCount} done`); + } +} + +/** + * Service that periodically adds jobs to the BullMQ queue. + */ +@Injectable() +export class BullmqJobProducer implements OnModuleInit { + private jobCount = 0; + + constructor(@InjectQueue("background-queue") private queue: Queue) {} + + onModuleInit() { + setInterval(async () => { + this.jobCount++; + await this.queue.add("background-task", { + id: this.jobCount, + }); + console.log(`[@nestjs/bullmq] Added job #${this.jobCount} to queue`); + }, 5000); + } +} diff --git a/sentry-javascript/17742/src/event-job.service.ts b/sentry-javascript/17742/src/event-job.service.ts new file mode 100644 index 0000000..8145126 --- /dev/null +++ b/sentry-javascript/17742/src/event-job.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { OnEvent } from "@nestjs/event-emitter"; +import * as Sentry from "@sentry/nestjs"; + +/** + * Case 2: @nestjs/event-emitter + * Event handlers run outside HTTP request context on the default isolation scope. + */ +@Injectable() +export class EventJobService { + private eventCount = 0; + + constructor(private eventEmitter: EventEmitter2) { + // Emit events periodically to simulate background event processing + setInterval(() => { + this.eventEmitter.emit("background.task", { + id: Date.now(), + }); + }, 4000); + } + + @OnEvent("background.task") + handleBackgroundEvent(payload: { id: number }) { + this.eventCount++; + console.log(`[@nestjs/event-emitter] Event #${this.eventCount} received`); + + Sentry.addBreadcrumb({ + category: "event-job", + message: `Event handler #${this.eventCount} executed`, + level: "info", + }); + + console.log(`[@nestjs/event-emitter] Event #${this.eventCount} done`); + } +} diff --git a/sentry-javascript/17742/src/graphile-job.service.ts b/sentry-javascript/17742/src/graphile-job.service.ts new file mode 100644 index 0000000..815a226 --- /dev/null +++ b/sentry-javascript/17742/src/graphile-job.service.ts @@ -0,0 +1,63 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { Task, TaskHandler } from "nestjs-graphile-worker"; +import { WorkerService } from "nestjs-graphile-worker"; +import * as Sentry from "@sentry/nestjs"; + +/** + * Case 4: nestjs-graphile-worker + * Graphile worker tasks run outside HTTP request context on the default isolation scope. + */ +@Injectable() +@Task("background-graphile-task") +export class GraphileJobHandler { + private taskCount = 0; + + @TaskHandler() + async handler(payload: { id: number }) { + this.taskCount++; + console.log( + `[nestjs-graphile-worker] Task #${this.taskCount} running` + ); + + Sentry.addBreadcrumb({ + category: "graphile-job", + message: `Graphile task #${this.taskCount} executed`, + level: "info", + }); + + console.log( + `[nestjs-graphile-worker] Task #${this.taskCount} done` + ); + } +} + +/** + * Service that periodically adds tasks to the graphile-worker queue. + */ +@Injectable() +export class GraphileJobProducer implements OnModuleInit { + private taskCount = 0; + + constructor(private readonly workerService: WorkerService) {} + + async onModuleInit() { + // Start the graphile-worker runner so it actually processes tasks + this.workerService.run().catch((err) => { + console.error("[nestjs-graphile-worker] Runner error:", err); + }); + + setInterval(async () => { + this.taskCount++; + try { + await this.workerService.addJob("background-graphile-task", { + id: this.taskCount, + }); + console.log( + `[nestjs-graphile-worker] Added task #${this.taskCount} to queue` + ); + } catch (e) { + // Silently ignore if worker service isn't ready + } + }, 6000); + } +} diff --git a/sentry-javascript/17742/src/instrument.ts b/sentry-javascript/17742/src/instrument.ts new file mode 100644 index 0000000..1f891fb --- /dev/null +++ b/sentry-javascript/17742/src/instrument.ts @@ -0,0 +1,45 @@ +import "dotenv/config"; +import * as Sentry from "@sentry/nestjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + debug: false, + tracesSampleRate: 1.0, + beforeSend(event) { + const breadcrumbs = event.breadcrumbs || []; + console.log( + `\n=== Sentry Event Breadcrumbs (${breadcrumbs.length} total) ===` + ); + + // Group breadcrumbs by category + const categories = new Map(); + breadcrumbs.forEach((bc, i) => { + const cat = bc.category || "unknown"; + categories.set(cat, (categories.get(cat) || 0) + 1); + console.log(` [${i}] category=${cat}, message=${bc.message}`); + }); + + // Check for leaked breadcrumbs from each background job type + const leakCategories = [ + "schedule-job", + "event-job", + "bullmq-job", + "graphile-job", + ]; + + const leaks = leakCategories + .filter((cat) => categories.has(cat)) + .map((cat) => `${cat}: ${categories.get(cat)}`); + + if (leaks.length > 0) { + console.log( + `\n*** BUG CONFIRMED: Breadcrumbs leaked from background jobs! ***` + ); + console.log(` Leaked: ${leaks.join(", ")}`); + } else { + console.log("\n No leaked breadcrumbs detected (isolation working)."); + } + console.log("=== End Breadcrumbs ===\n"); + return event; + }, +}); diff --git a/sentry-javascript/17742/src/main.ts b/sentry-javascript/17742/src/main.ts new file mode 100644 index 0000000..c1f0591 --- /dev/null +++ b/sentry-javascript/17742/src/main.ts @@ -0,0 +1,23 @@ +import "./instrument"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + console.log("Server running on http://localhost:3000"); + console.log(""); + console.log("To reproduce the breadcrumb leaking issue:"); + console.log( + "1. Wait ~10 seconds for background jobs to pollute the default scope" + ); + console.log("2. curl http://localhost:3000/trigger-error"); + console.log(""); + console.log("Background job sources:"); + console.log(" - @nestjs/schedule @Interval (always active)"); + console.log(" - @nestjs/event-emitter @OnEvent (always active)"); + console.log(" - @nestjs/bullmq @Processor (requires REDIS_URL)"); + console.log(" - nestjs-graphile-worker @Task (requires DATABASE_URL)"); +} +bootstrap(); diff --git a/sentry-javascript/17742/src/schedule-job.service.ts b/sentry-javascript/17742/src/schedule-job.service.ts new file mode 100644 index 0000000..0479604 --- /dev/null +++ b/sentry-javascript/17742/src/schedule-job.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { Interval } from "@nestjs/schedule"; +import * as Sentry from "@sentry/nestjs"; + +/** + * Case 1: @nestjs/schedule + * Scheduled jobs run outside HTTP request context on the default isolation scope. + */ +@Injectable() +export class ScheduleJobService { + private jobCount = 0; + + @Interval(3000) + handleScheduledJob() { + this.jobCount++; + console.log(`[@nestjs/schedule] Job #${this.jobCount} running`); + + Sentry.addBreadcrumb({ + category: "schedule-job", + message: `Scheduled job #${this.jobCount} executed`, + level: "info", + }); + + console.log(`[@nestjs/schedule] Job #${this.jobCount} done`); + } +} diff --git a/sentry-javascript/17742/test-repro.sh b/sentry-javascript/17742/test-repro.sh new file mode 100755 index 0000000..64e65b9 --- /dev/null +++ b/sentry-javascript/17742/test-repro.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Reproduction script for sentry-javascript#17742 +# Tests breadcrumb leaking from all background job frameworks: +# - @nestjs/schedule (always active) +# - @nestjs/event-emitter (always active) +# - @nestjs/bullmq (requires REDIS_URL) +# - nestjs-graphile-worker (requires DATABASE_URL) + +echo "=== Starting NestJS server ===" +node dist/main.js & +APP_PID=$! + +echo "Waiting for server to start and background jobs to run..." +sleep 18 + +echo "" +echo "=== Triggering error to check for leaked breadcrumbs ===" +curl -s http://localhost:3000/trigger-error +echo "" + +sleep 3 +kill $APP_PID 2>/dev/null +wait $APP_PID 2>/dev/null diff --git a/sentry-javascript/17742/tsconfig.json b/sentry-javascript/17742/tsconfig.json new file mode 100644 index 0000000..95f5641 --- /dev/null +++ b/sentry-javascript/17742/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +}