diff --git a/.gitignore b/.gitignore index c35ec56..e84aa11 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ node_modules/ dist/ out/ build/ +.next/ +next-env.d.ts *.tsbuildinfo # Environment files diff --git a/sentry-javascript/19367/README.md b/sentry-javascript/19367/README.md new file mode 100644 index 0000000..e8bf095 --- /dev/null +++ b/sentry-javascript/19367/README.md @@ -0,0 +1,227 @@ +# Reproduction for sentry-javascript#19367 + +**Issue:** https://github.com/getsentry/sentry-javascript/issues/19367 + +## Description + +Next.js 16 Turbopack bundles `@opentelemetry/api` into **separate chunks** for the +instrumentation hook (Sentry SDK init) and each route handler. This creates multiple +module instances that share the global context manager via `Symbol.for("opentelemetry.js.api.1")`, +but have separate `ContextAPI` singletons and module closures. + +In `@sentry/nextjs` 10.38.0, `_startSpan()` nests 3 `context.with()` calls: +``` +context.with(suppressedCtx) <- copy A's ContextAPI + -> tracer.startActiveSpan() + -> api.context.with(ctxWithSpan) <- copy B's ContextAPI + -> context.with(activeCtx) <- copy A's ContextAPI + -> callback +``` + +With duplicated module instances, the `SentryContextManager`'s scope cloning and +re-entry through the async context strategy can spiral into infinite recursion, +causing `RangeError: Maximum call stack size exceeded`. + +This did not happen with `@sentry/nextjs` 10.8.0 because `startSpan()` only had +**1** `context.with()` call (inside `tracer.startActiveSpan()`), keeping the +re-entry depth bounded. + +## Steps to Reproduce + +1. Install dependencies: + ```bash + npm install + ``` + +2. Build with Turbopack (the default for Next.js 16): + ```bash + npm run build + ``` + +3. **Verify the duplication in build output:** + ```bash + npm run check-otel-dedup + ``` + This analyzes which chunks each route and the instrumentation hook load, + confirming they get **different** copies of `@opentelemetry/api`. + +4. Start the production server: + ```bash + npm start + ``` + +5. Test the diagnostic endpoint: + ```bash + curl http://localhost:3000/api/otel-check | python3 -m json.tool + ``` + +6. Test the realistic route with nested Sentry spans: + ```bash + curl http://localhost:3000/api/test | python3 -m json.tool + ``` + +7. Load test to attempt triggering the crash (intermittent): + ```bash + for i in $(seq 1 500); do curl -s http://localhost:3000/api/test > /dev/null & done; wait + ``` + +## What the check script shows + +`npm run check-otel-dedup` maps the exact chunk loading for each entry point: + +``` +=== @opentelemetry/api duplication analysis === + +Instrumentation OTel chunks: + [root-of-the-server]__963c8309._.js (8KB) -- Symbol.for: true, ContextAPI: false + _0066dbbb._.js (4684KB) -- Symbol.for: false, ContextAPI: true + +/api/test OTel chunks: + [root-of-the-server]__3f199e61._.js (9KB) -- Symbol.for: true, ContextAPI: false + [root-of-the-server]__8f25289d._.js (161KB) -- Symbol.for: false, ContextAPI: true + + *** DUPLICATION DETECTED for /api/test *** + Instrumentation-only OTel chunks: __963c8309, _0066dbbb + Route-only OTel chunks: __3f199e61, __8f25289d + -> Different module instances with separate ContextAPI singletons +``` + +## Routes + +| Route | Purpose | +|---|---| +| `/api/otel-check` | Diagnostic: tests `context.with()` (single + 3-deep nested), reports global OTel registry state | +| `/api/test` | Realistic: nested `Sentry.startSpan()` calls simulating DB queries + external API calls | + +## Investigation Findings + +### Structural Evidence (Confirmed) + +After `npm run build`, the `check-otel-dedup` script confirms that the instrumentation +hook and each route handler load **different chunks** containing `@opentelemetry/api`. +Every route gets its own copy. The instrumentation hook and route handlers never share +OTel chunks. + +**Two ContextAPI class definitions in the same chunk:** The route handler chunk contains +two separate `ContextAPI` class definitions -- one Turbopack ESM-style (used by route +handler code) and one CJS-style (bundled from `@prisma/instrumentation`'s nested +`@opentelemetry/instrumentation` dependency). Both call `_getContextManager()` which +reads from `globalThis[Symbol.for("opentelemetry.js.api.1")]`, so they share the same +context manager but are separate JavaScript objects with separate closures, singletons, +and prototype chains. + +**Version-independent:** The duplication occurs identically with both `@sentry/nextjs` +10.8.0 and 10.38.0 (8 chunks with OTel `Symbol.for`, 2 with `ContextAPI` in both). + +**`prismaIntegration()` makes no difference:** Removing `Sentry.prismaIntegration()` +produces identical chunk structure. The `@prisma/instrumentation` package is bundled +because it is a direct dependency of `@sentry/node`, not because of the integration call. + +### Runtime Behavior (Crash Not Reproduced) + +| Test | Result | +|---|---| +| Single `context.with()` call | Passes | +| 3-deep nested `context.with()` (mimicking `_startSpan`) | Passes | +| Alternating Copy A / Copy B `context.with()` | Passes | +| 1000 concurrent mixed A/B `context.with()` calls | Passes | +| `Sentry.startSpan()` with nested spans + Copy B `context.with()` | Passes | +| 500 concurrent HTTP requests to `/api/test` | Server survives | +| `--stack-size=128` with concurrent load | Server survives | +| Monkey-patching instrumentation's `ContextAPI.with()` | Never triggered by routes (confirming separate singletons) | + +**Why the crash doesn't reproduce locally:** The `SentryContextManager.with()` calls +`super.with()` which ends at `asyncLocalStorage.run(ctx, fn)` -- it does **not** call +back to any `ContextAPI.with()`. So there is no direct recursive loop between the two +ContextAPI singletons. + +**Remaining hypotheses for the crash:** +- Node.js v24 runtime differences (different ALS behavior or stack limits) +- Actual Prisma DB queries creating deeper instrumentation chains +- pnpm workspace resolution causing different module nesting +- `next-intl` middleware adding wrapping to the Next.js config +- Sustained production traffic ("minutes to hours" under load) +- Duplicate copies of `require-in-the-middle`/`import-in-the-middle` both patching + the same modules, creating re-entrant wrapping chains + +### Turbopack's Behavior Is By Design + +Per-route self-contained bundles are intentional. A Turbopack contributor (lukesandberg) +confirmed on [vercel/next.js#89192](https://github.com/vercel/next.js/issues/89192): + +> "Bundling the module into multiple different output chunks is expected. The different +> routes may end up in different lambdas in production and so need to be self-contained." + +However, the runtime deduplication layer that is supposed to compensate is not working +correctly for `@opentelemetry/api`. The Turbopack runtime uses a shared `moduleCache` +but the build output shows separate module definitions with different module IDs across +chunks. + +**Known gaps:** +- [vercel/next.js#89192](https://github.com/vercel/next.js/issues/89192) -- Duplicate + class definitions across route chunks, breaking `instanceof` (same root cause) +- [vercel/next.js#89252](https://github.com/vercel/next.js/issues/89252) -- Related + chunking issue where unused CSS is included across routes +- Turbopack status page lists "Production Optimized JS Chunking" as still in progress +- Webpack does not have this problem (SplitChunksPlugin deduplicates shared deps) + +### The `serverExternalPackages` Workaround + +Adding `@opentelemetry/api` to `serverExternalPackages` in `next.config.js` forces +Node.js native `require()` resolution at runtime, bypassing Turbopack's bundling: + +```js +const nextConfig = { + serverExternalPackages: ["@opentelemetry/api"], +}; +``` + +This guarantees a single module instance because Node.js `require()` caches modules. +`@opentelemetry/api` is **not** on Next.js's built-in auto-externalized packages list, +though `@sentry/profiling-node` and `import-in-the-middle` are. Adding it upstream +would be a reasonable fix. + +### OTel's Singleton Design vs Bundler Reality + +`@opentelemetry/api` uses `Symbol.for("opentelemetry.js.api.1")` to store a global +registry on `globalThis`. This design was intended for the npm/yarn flat `node_modules` +world where multiple _versions_ might coexist. It was not designed for bundler-created +duplicate instances of the _same version_ in the same process. + +**What the `Symbol.for` pattern protects:** global context manager registration, +global tracer provider registration, version compatibility checks. + +**What it does NOT protect:** class identity (`instanceof` fails across copies), +module closures (each copy has its own), singleton instances (`ContextAPI.getInstance()` +returns a different object per copy), instrumentation registration (two copies may +both try to hook into the same Node.js modules). + +## Conclusions + +**Confirmed:** +1. Turbopack creates separate `@opentelemetry/api` module instances -- reproducible and deterministic +2. This is a known Turbopack limitation -- runtime deduplication does not work correctly here +3. Webpack does not have this problem +4. The duplication is SDK-version-independent (10.8.0 and 10.38.0 identical) +5. `prismaIntegration()` is irrelevant -- duplication comes from `@sentry/node`'s bundled `@prisma/instrumentation` + +**Not confirmed:** +1. The actual infinite recursion mechanism -- could not trigger `RangeError` in any test +2. Why 10.38.0 crashes but 10.8.0 doesn't -- structural duplication is identical +3. The exact conditions -- likely requires Node.js v24, real Prisma queries, pnpm, or sustained production traffic + +**Recommended actions:** + +| Action | Owner | Priority | +|---|---|---| +| Add `@opentelemetry/api` to `serverExternalPackages` | Users (workaround) | Immediate | +| Add `@opentelemetry/api` to Next.js built-in externals list | Next.js team | Short-term | +| Fix Turbopack runtime module deduplication | Turbopack team | Medium-term | +| Track [vercel/next.js#89192](https://github.com/vercel/next.js/issues/89192) | All | Ongoing | + +## Environment + +- `@sentry/nextjs`: 10.38.0 +- `next`: 16.1.6 (Turbopack) +- `@prisma/instrumentation`: ^7.4.0 +- Node.js: v20+ (reporter uses v24) diff --git a/sentry-javascript/19367/app/api/otel-check/route.ts b/sentry-javascript/19367/app/api/otel-check/route.ts new file mode 100644 index 0000000..9c42f8f --- /dev/null +++ b/sentry-javascript/19367/app/api/otel-check/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { context, trace } from "@opentelemetry/api"; + +/** + * Diagnostic route that proves @opentelemetry/api is duplicated at runtime. + * + * When Turbopack bundles this route, it gets its OWN copy of @opentelemetry/api + * (separate module instance from the one Sentry loaded in the instrumentation hook). + * Both copies share the same globalThis[Symbol.for("opentelemetry.js.api.1")] + * registry, but they are different JavaScript objects with different closures. + * + * This is the root cause of sentry-javascript#19367: the duplicated ContextAPI + * creates a re-entrant loop through the shared global context manager. + */ +export async function GET() { + const diagnostics: Record = {}; + + // 1. Check the global OTel registry (shared across all copies via Symbol.for) + const globalKey = Symbol.for("opentelemetry.js.api.1"); + const globalRegistry = (globalThis as any)[globalKey]; + diagnostics.globalOtelRegistry = { + exists: !!globalRegistry, + version: globalRegistry?.version, + hasContextManager: !!globalRegistry?.context, + hasTracerProvider: !!globalRegistry?.trace, + hasDiag: !!globalRegistry?.diag, + }; + + // 2. Check the context manager registered by Sentry + const ctxManager = globalRegistry?.context; + if (ctxManager) { + diagnostics.contextManager = { + constructor: ctxManager.constructor.name, + hasWithMethod: typeof ctxManager.with === "function", + }; + } + + // 3. Test context.with() — the method that causes the infinite recursion + let contextWithResult: string; + try { + const result = context.with(context.active(), () => { + return "context.with() succeeded (single call)"; + }); + contextWithResult = result; + } catch (err: any) { + contextWithResult = `CRASH: ${err.message}`; + } + diagnostics.singleContextWith = contextWithResult; + + // 4. Test nested context.with() — mimics what _startSpan() does in SDK 10.38.0 + // + // In 10.38.0, _startSpan() wraps the call like: + // context.with(suppressedCtx, () => // call 1 + // tracer.startActiveSpan(name, ctx, span => // call 2 (inside startActiveSpan) + // context.with(activeCtx, () => // call 3 + // callback(span) + // ) + // ) + // ) + // + // With duplicated modules, each context.with() may go through a different + // ContextAPI singleton, and the Sentry context manager's scope cloning + // triggers re-entry through the async context strategy. + let nestedResult: string; + try { + context.with(context.active(), () => { + return context.with(context.active(), () => { + const tracer = trace.getTracer("repro-test"); + return tracer.startActiveSpan("nested-test", (span) => { + span.end(); + return "nested context.with() succeeded (3-deep, same as _startSpan in 10.38.0)"; + }); + }); + }); + nestedResult = "3-deep nested context.with() succeeded"; + } catch (err: any) { + nestedResult = `CRASH: ${err.message}`; + } + diagnostics.nestedContextWith = nestedResult; + + // 5. The build-time evidence + diagnostics.buildTimeEvidence = + "Run `npm run check-otel-dedup` on the build output to confirm that " + + "the instrumentation hook and route handlers load DIFFERENT chunks " + + "containing @opentelemetry/api. Turbopack creates separate module " + + "instances instead of deduplicating into a single shared chunk."; + + return NextResponse.json(diagnostics, { + status: contextWithResult.startsWith("CRASH") ? 500 : 200, + }); +} diff --git a/sentry-javascript/19367/app/api/test/route.ts b/sentry-javascript/19367/app/api/test/route.ts new file mode 100644 index 0000000..30349e2 --- /dev/null +++ b/sentry-javascript/19367/app/api/test/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import * as Sentry from "@sentry/nextjs"; + +/** + * Simulates a realistic API route that creates nested Sentry spans. + * + * In production, auto-instrumentation (HTTP, Prisma, etc.) creates spans + * around every request and DB query. Each Sentry.startSpan() call in 10.38.0 + * internally calls context.with() THREE times (suppressed ctx → startActiveSpan + * → active ctx). With Turbopack's duplicate @opentelemetry/api modules, this + * 3-deep nesting × N nested spans can spiral into infinite recursion. + * + * The crash is intermittent because it depends on Node.js event loop timing + * and which module copy's ContextAPI handles each context.with() call. + */ +export async function GET() { + // Outer span — simulates the HTTP instrumentation auto-span + return Sentry.startSpan({ name: "GET /api/test" }, async () => { + // Inner span — simulates a DB query (e.g., Prisma) + const dbResult = await Sentry.startSpan( + { name: "prisma:query SELECT" }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 2)); + return { users: 42 }; + } + ); + + // Another inner span — simulates a second DB query + const cacheResult = await Sentry.startSpan( + { name: "redis:get session" }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return { cached: true }; + } + ); + + // Deeply nested span — simulates calling an external API + const apiResult = await Sentry.startSpan( + { name: "http:POST /external-api" }, + async () => { + // Nested span inside the external call + return Sentry.startSpan( + { name: "serialize:response" }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return { ok: true }; + } + ); + } + ); + + return NextResponse.json({ + status: "ok", + dbResult, + cacheResult, + apiResult, + timestamp: Date.now(), + }); + }); +} diff --git a/sentry-javascript/19367/app/layout.tsx b/sentry-javascript/19367/app/layout.tsx new file mode 100644 index 0000000..225b603 --- /dev/null +++ b/sentry-javascript/19367/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/sentry-javascript/19367/app/page.tsx b/sentry-javascript/19367/app/page.tsx new file mode 100644 index 0000000..126528b --- /dev/null +++ b/sentry-javascript/19367/app/page.tsx @@ -0,0 +1,35 @@ +export default function Home() { + return ( +
+

Repro: sentry-javascript#19367

+

+ Next.js 16 Turbopack bundles @opentelemetry/api into + separate chunks for the instrumentation hook and each route handler. + This creates multiple module instances that share the global context + manager via Symbol.for(), but have separate{" "} + ContextAPI singletons. Under sustained traffic, the + re-entrant .with() calls spiral into infinite recursion. +

+ +

Routes

+ + +

Build-time evidence

+

+ Run npm run check-otel-dedup after building to see which + chunks each route loads and confirm the duplication. +

+
+ ); +} diff --git a/sentry-javascript/19367/instrumentation.ts b/sentry-javascript/19367/instrumentation.ts new file mode 100644 index 0000000..f8a929b --- /dev/null +++ b/sentry-javascript/19367/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} diff --git a/sentry-javascript/19367/next.config.js b/sentry-javascript/19367/next.config.js new file mode 100644 index 0000000..cd850cd --- /dev/null +++ b/sentry-javascript/19367/next.config.js @@ -0,0 +1,17 @@ +const { withSentryConfig } = require("@sentry/nextjs"); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Turbopack is the default in Next.js 16, no extra config needed +}; + +module.exports = withSentryConfig(nextConfig, { + org: "your-org", + project: "your-project", + // Suppress build output noise for the repro + silent: true, + // Disable source map upload since we have no real DSN + sourcemaps: { + disable: true, + }, +}); diff --git a/sentry-javascript/19367/package.json b/sentry-javascript/19367/package.json new file mode 100644 index 0000000..cd1cbaf --- /dev/null +++ b/sentry-javascript/19367/package.json @@ -0,0 +1,24 @@ +{ + "name": "repro-sentry-javascript-19367", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "check-otel-dedup": "node scripts/check-otel-dedup.js" + }, + "dependencies": { + "@prisma/instrumentation": "^7.4.0", + "@sentry/nextjs": "10.38.0", + "next": "16.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } +} diff --git a/sentry-javascript/19367/scripts/check-otel-dedup.js b/sentry-javascript/19367/scripts/check-otel-dedup.js new file mode 100644 index 0000000..29bbc7f --- /dev/null +++ b/sentry-javascript/19367/scripts/check-otel-dedup.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/** + * Analyzes the Turbopack build output to detect duplicate @opentelemetry/api + * module instances — the root cause of sentry-javascript#19367. + * + * Turbopack creates per-route entry points. Each route loads different chunks. + * This script maps which chunks each route loads and checks whether the + * instrumentation hook and route handlers get different copies of + * @opentelemetry/api's ContextAPI. + * + * Run after `npm run build`: + * node scripts/check-otel-dedup.js + */ + +const fs = require("fs"); +const path = require("path"); + +const dotNextDir = path.join(__dirname, "../.next"); +const serverDir = path.join(dotNextDir, "server"); +const chunksDir = path.join(serverDir, "chunks"); + +if (!fs.existsSync(chunksDir)) { + console.error("ERROR: .next/server/chunks not found. Run `npm run build` first."); + process.exit(1); +} + +/** + * Resolve a chunk path from R.c() or e.l() to an absolute filesystem path. + * R.c() paths are like "server/chunks/foo.js" — relative to .next/ + */ +function resolveChunkPath(chunkRef) { + return path.join(dotNextDir, chunkRef); +} + +// 1. Parse the instrumentation entry point +const instrPath = path.join(serverDir, "instrumentation.js"); +const instrContent = fs.readFileSync(instrPath, "utf8"); + +// Static chunks loaded via R.c() +const instrStaticRefs = (instrContent.match(/R\.c\("([^"]+)"\)/g) || []) + .map(m => m.match(/"([^"]+)"/)[1]); + +// Find dynamic chunks inside the static chunk files (loaded via e.l()) +let instrDynRefs = []; +for (const ref of instrStaticRefs) { + const fullPath = resolveChunkPath(ref); + if (!fs.existsSync(fullPath)) continue; + const content = fs.readFileSync(fullPath, "utf8"); + const dynMatches = [...content.matchAll(/"(server\/chunks\/[^"]+\.js)"/g)]; + instrDynRefs.push(...dynMatches.map(m => m[1])); +} + +const instrAllRefs = [...instrStaticRefs, ...instrDynRefs]; +console.log("=== Instrumentation entry point ==="); +console.log("Static chunks (R.c):", instrStaticRefs); +console.log("Dynamic chunks (e.l):", instrDynRefs); + +// 2. Parse route entry points +const routeFiles = []; +function findRouteFiles(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) findRouteFiles(full); + else if (entry.name === "route.js" || entry.name === "page.js") routeFiles.push(full); + } +} +findRouteFiles(path.join(serverDir, "app")); + +const routes = {}; +for (const rf of routeFiles) { + const content = fs.readFileSync(rf, "utf8"); + const chunks = (content.match(/R\.c\("([^"]+)"\)/g) || []).map(m => m.match(/"([^"]+)"/)[1]); + const routeName = rf.replace(serverDir + "/app", "").replace(/\/(route|page)\.js$/, "") || "/"; + routes[routeName] = chunks; +} + +console.log("\n=== Route entry points ==="); +for (const [route, chunks] of Object.entries(routes)) { + console.log(`${route}: ${chunks.length} chunks`); +} + +// 3. Analyze chunks for @opentelemetry/api content +function analyzeChunk(chunkRef) { + const fullPath = resolveChunkPath(chunkRef); + if (!fs.existsSync(fullPath)) return null; + const content = fs.readFileSync(fullPath, "utf8"); + return { + ref: chunkRef, + size: content.length, + hasOtelSymbol: content.includes('Symbol.for("opentelemetry.js.api'), + hasContextAPI: content.includes("ContextAPI"), + }; +} + +console.log("\n=== @opentelemetry/api duplication analysis ===\n"); + +// Analyze instrumentation chunks +const instrOtelChunks = []; +for (const ref of instrAllRefs) { + const analysis = analyzeChunk(ref); + if (analysis && (analysis.hasOtelSymbol || analysis.hasContextAPI)) { + instrOtelChunks.push(analysis); + } +} + +if (instrOtelChunks.length > 0) { + console.log("Instrumentation OTel chunks:"); + for (const c of instrOtelChunks) { + const shortName = path.basename(c.ref); + console.log(` ${shortName} (${(c.size / 1024).toFixed(0)}KB) — Symbol.for: ${c.hasOtelSymbol}, ContextAPI: ${c.hasContextAPI}`); + } +} else { + console.log("Instrumentation: no OTel chunks found (may be using externalized modules)"); +} + +// Analyze route chunks and compare with instrumentation +let hasDuplication = false; +for (const [route, chunks] of Object.entries(routes)) { + const routeOtelChunks = []; + for (const ref of chunks) { + const analysis = analyzeChunk(ref); + if (analysis && (analysis.hasOtelSymbol || analysis.hasContextAPI)) { + routeOtelChunks.push(analysis); + } + } + + if (routeOtelChunks.length === 0) continue; + + console.log(`\n${route} OTel chunks:`); + for (const c of routeOtelChunks) { + const shortName = path.basename(c.ref); + console.log(` ${shortName} (${(c.size / 1024).toFixed(0)}KB) — Symbol.for: ${c.hasOtelSymbol}, ContextAPI: ${c.hasContextAPI}`); + } + + // Check for duplication: does this route load DIFFERENT otel chunks than instrumentation? + const routeChunkRefs = new Set(routeOtelChunks.map(c => c.ref)); + const instrChunkRefs = new Set(instrOtelChunks.map(c => c.ref)); + const routeOnly = [...routeChunkRefs].filter(r => !instrChunkRefs.has(r)); + const instrOnly = [...instrChunkRefs].filter(r => !routeChunkRefs.has(r)); + + if (routeOnly.length > 0 && instrOtelChunks.length > 0) { + hasDuplication = true; + console.log(`\n *** DUPLICATION DETECTED for ${route} ***`); + console.log(` Instrumentation-only OTel chunks: ${instrOnly.map(r => path.basename(r)).join(", ")}`); + console.log(` Route-only OTel chunks: ${routeOnly.map(r => path.basename(r)).join(", ")}`); + console.log(` -> The instrumentation hook and this route load DIFFERENT copies`); + console.log(` of @opentelemetry/api. Both register/read via the same global`); + console.log(` Symbol.for("opentelemetry.js.api.1"), but they are separate`); + console.log(` module instances with separate ContextAPI singletons.`); + } +} + +console.log("\n=== Verdict ===\n"); +if (hasDuplication) { + console.log( + "BUG CONFIRMED: Turbopack bundled @opentelemetry/api into separate chunks\n" + + "for the instrumentation hook and route handlers.\n\n" + + "Root cause chain:\n" + + " 1. Instrumentation hook loads Sentry SDK → registers SentryContextManager\n" + + " via copy A of @opentelemetry/api's registerGlobal()\n" + + " 2. Route handler loads copy B of @opentelemetry/api with its own ContextAPI\n" + + " 3. Both copies share the same global Symbol registry, but have separate\n" + + " module closures and ContextAPI singletons\n" + + " 4. In SDK 10.38.0, _startSpan() nests 3 context.with() calls:\n" + + " context.with(suppressed) → tracer.startActiveSpan() → context.with(active)\n" + + " Each call goes through the global SentryContextManager, which clones\n" + + " scopes and re-enters through the async context strategy. With two\n" + + " module copies, the re-entry spirals into infinite recursion.\n" + + " 5. RangeError: Maximum call stack size exceeded\n\n" + + "This is sentry-javascript#19367.\n\n" + + "Workaround: add @opentelemetry/api to serverExternalPackages in next.config.js\n" + + "to force Node.js require() resolution (single instance)." + ); + process.exit(1); +} else { + console.log("No cross-entry-point duplication detected."); +} diff --git a/sentry-javascript/19367/scripts/force-crash.js b/sentry-javascript/19367/scripts/force-crash.js new file mode 100644 index 0000000..137b58c --- /dev/null +++ b/sentry-javascript/19367/scripts/force-crash.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * Force-reproduces the infinite .with() recursion from sentry-javascript#19367. + * + * In the real Turbopack build, there are two separate ContextAPI singleton instances + * (one from the Turbopack ESM bundle, one from the CJS bundle within the same chunk). + * Both share the same global context manager via Symbol.for("opentelemetry.js.api.1"). + * + * This script simulates that duplication by loading @opentelemetry/api twice + * (clearing the require cache to get separate singletons) and exercising the + * context.with() path the way @sentry/opentelemetry's _startSpan() does. + */ + +const Module = require("module"); +const path = require("path"); + +// --- Load copy A of @opentelemetry/api --- +const apiA = require("@opentelemetry/api"); +const contextA = apiA.context; + +console.log("Copy A loaded. ContextAPI instance:", contextA.constructor.name); + +// --- Force a second load by clearing require cache for all @opentelemetry/api modules --- +const otelApiDir = path.dirname(require.resolve("@opentelemetry/api")); +for (const key of Object.keys(require.cache)) { + if (key.includes("@opentelemetry/api")) { + delete require.cache[key]; + } +} + +// Also clear the ContextAPI singleton (it's stored as a static property) +// In the real Turbopack scenario, each chunk creates its own singleton +const apiB = require("@opentelemetry/api"); +const contextB = apiB.context; + +console.log("Copy B loaded. ContextAPI instance:", contextB.constructor.name); +console.log("Same instance?", contextA === contextB); // Should be false + +// --- Set up the Sentry-like context manager (shared via global) --- +// Import the real SentryContextManager from @sentry/node-core if available +let SentryContextManager; +try { + const { AsyncLocalStorageContextManager } = require("@opentelemetry/context-async-hooks"); + const { wrapContextManagerClass } = require("@sentry/opentelemetry"); + SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); +} catch (e) { + console.log("Could not load SentryContextManager, using basic setup:", e.message); + process.exit(1); +} + +// Register via copy A (like the instrumentation hook does) +const mgr = new SentryContextManager(); +mgr.enable(); +contextA.setGlobalContextManager(mgr); +console.log("Registered SentryContextManager via copy A"); + +// --- Now simulate what _startSpan() does --- +// This calls context.with() from BOTH copies in a nested fashion, +// just like the Turbopack bundle does when the instrumentation hook's +// @sentry/opentelemetry calls context.with() and the route handler's +// @opentelemetry/api also calls context.with(). + +console.log("\n--- Test 1: Single context.with() via copy A ---"); +try { + contextA.with(contextA.active(), () => "ok from A"); + console.log("✓ Passed"); +} catch (e) { + console.log("✗ CRASH:", e.message); +} + +console.log("\n--- Test 2: Single context.with() via copy B ---"); +try { + contextB.with(contextB.active(), () => "ok from B"); + console.log("✓ Passed"); +} catch (e) { + console.log("✗ CRASH:", e.message); +} + +console.log("\n--- Test 3: Nested A → B (simulates _startSpan with two module copies) ---"); +try { + contextA.with(contextA.active(), () => { + return contextB.with(contextB.active(), () => { + return "ok nested A→B"; + }); + }); + console.log("✓ Passed"); +} catch (e) { + console.log("✗ CRASH:", e.message); +} + +console.log("\n--- Test 4: Deep nesting A→B→A→B (simulates startSpan + withScope chains) ---"); +try { + contextA.with(contextA.active(), () => { + return contextB.with(contextB.active(), () => { + return contextA.with(contextA.active(), () => { + return contextB.with(contextB.active(), () => { + return "ok deep nesting"; + }); + }); + }); + }); + console.log("✓ Passed"); +} catch (e) { + console.log("✗ CRASH:", e.message); +} + +console.log("\n--- Test 5: Using @sentry/opentelemetry's actual startSpan from both copies ---"); +try { + const Sentry = require("@sentry/node"); + Sentry.startSpan({ name: "outer" }, () => { + return Sentry.startSpan({ name: "inner" }, () => { + // This nesting is what happens in the real scenario + return contextB.with(contextB.active(), () => { + return Sentry.startSpan({ name: "deepest" }, () => { + return "ok from nested Sentry spans with copy B"; + }); + }); + }); + }); + console.log("✓ Passed"); +} catch (e) { + console.log("✗ CRASH:", e.message); +} + +console.log("\n--- Test 6: Rapid concurrent context.with() from both copies ---"); +let crashes = 0; +let successes = 0; +const promises = []; +for (let i = 0; i < 1000; i++) { + promises.push( + new Promise((resolve) => { + try { + const ctx = i % 2 === 0 ? contextA : contextB; + const otherCtx = i % 2 === 0 ? contextB : contextA; + ctx.with(ctx.active(), () => { + return otherCtx.with(otherCtx.active(), () => { + successes++; + resolve(); + }); + }); + } catch (e) { + crashes++; + resolve(); + } + }) + ); +} +Promise.all(promises).then(() => { + console.log(`Results: ${successes} successes, ${crashes} crashes`); + if (crashes > 0) { + console.log("✗ Concurrent context.with() caused crashes!"); + } else { + console.log("✓ No crashes in concurrent test"); + } + + console.log("\nDone. If no crash occurred, the recursion may require"); + console.log("additional factors: specific instrumentation chains,"); + console.log("Node.js v24 behavior, or sustained production traffic patterns."); +}); diff --git a/sentry-javascript/19367/sentry.client.config.ts b/sentry-javascript/19367/sentry.client.config.ts new file mode 100644 index 0000000..3e36b9b --- /dev/null +++ b/sentry-javascript/19367/sentry.client.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + tracesSampleRate: 1, +}); diff --git a/sentry-javascript/19367/sentry.edge.config.ts b/sentry-javascript/19367/sentry.edge.config.ts new file mode 100644 index 0000000..3e36b9b --- /dev/null +++ b/sentry-javascript/19367/sentry.edge.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + tracesSampleRate: 1, +}); diff --git a/sentry-javascript/19367/sentry.server.config.ts b/sentry-javascript/19367/sentry.server.config.ts new file mode 100644 index 0000000..edb8818 --- /dev/null +++ b/sentry-javascript/19367/sentry.server.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN || "", + tracesSampleRate: 1, + integrations: [ + Sentry.prismaIntegration(), + ], +}); diff --git a/sentry-javascript/19367/tsconfig.json b/sentry-javascript/19367/tsconfig.json new file mode 100644 index 0000000..e7ff3a2 --- /dev/null +++ b/sentry-javascript/19367/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}