Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions sentry-javascript/19367/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Reproduction for sentry-javascript#19367

**Issue:** https://github.com/getsentry/sentry-javascript/issues/19367

## Description

Next.js 16 with Turbopack (the default bundler) splits `@opentelemetry/api` across
multiple server-side chunks instead of deduplicating it into a single module instance.
When two chunks each contain their own copy of the OTel `ContextAPI`, the `.with()`
method of each copy delegates to the _other_ copy's `.with()`, creating infinite mutual
recursion that fatally crashes the Node.js process with:

```
RangeError: Maximum call stack size exceeded
```

This reproduces with `@sentry/nextjs` 10.38.0 + Next.js 16.1.6 Turbopack and does **not**
reproduce on `@sentry/nextjs` 10.8.0.

## Steps to Reproduce

1. Install dependencies:
```bash
npm install
```

2. (Optional) Export your Sentry DSN – the app works without one, but events won't be sent:
```bash
export SENTRY_DSN=https://your-key@oXXXXXX.ingest.sentry.io/XXXXXX
```

3. Build with Turbopack (the default for Next.js 16):
```bash
npm run build
```

4. **Detect the duplicate OTel chunks immediately after the build:**
```bash
npm run check-otel-dedup
```
Expected output shows `@opentelemetry/api` duplicated across 7 server-side chunks.

5. Start the production server:
```bash
npm start
```

6. Send requests to trigger OTel context propagation:
```bash
# Single request
curl http://localhost:3000/api/test

# Load test – the crash is intermittent; sustained traffic triggers it
for i in $(seq 1 500); do curl -s http://localhost:3000/api/test > /dev/null; done
```

The server may crash with `RangeError: Maximum call stack size exceeded` during or after
the load test. The crash is non-deterministic – it can happen within minutes or after
several hours of traffic (matching the original report).

## Expected Behavior

`@opentelemetry/api` is loaded as a single module instance. The `.with()` context method
works without recursion and the server remains stable.

## Actual Behavior

`npm run check-otel-dedup` reports:

```
✗ BUG DETECTED: @opentelemetry/api module definition found in 7 chunks:
- [root-of-the-server]__14b38a08._.js
- [root-of-the-server]__1a01c8dc._.js
- [root-of-the-server]__6126aa9f._.js
- [root-of-the-server]__ab5f2c12._.js
- [root-of-the-server]__da904e4a._.js
- [root-of-the-server]__f934a92d._.js
- node_modules_@opentelemetry_a01cbabd._.js
```

Under sustained traffic the server crashes:

```
RangeError: Maximum call stack size exceeded
at ContextAPI.with (.next/server/chunks/[root-of-the-server]__14b38a08._.js:...)
at ContextAPI.with (.next/server/chunks/node_modules_@opentelemetry_a01cbabd._.js:...)
at ContextAPI.with (.next/server/chunks/[root-of-the-server]__14b38a08._.js:...)
...
```

## Environment

- Node.js: v24.12.0 (also reproduces on v22)
- `@sentry/nextjs`: 10.38.0
- `next`: 16.1.6 (Turbopack)
- `@prisma/instrumentation`: ^7.4.0
- OS: Linux (Debian 12) / macOS (development)
18 changes: 18 additions & 0 deletions sentry-javascript/19367/app/api/test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";

// This route triggers OTel context propagation on every request.
// With @sentry/nextjs 10.38.0 + Next.js 16 Turbopack, @opentelemetry/api ends up
// bundled in two separate chunks. Each chunk's ContextAPI.with() delegates to the
// other copy's with(), creating infinite mutual recursion →
// RangeError: Maximum call stack size exceeded
export async function GET() {
// Simulate a minimal workload so Sentry/OTel creates spans
const start = Date.now();
await new Promise((resolve) => setTimeout(resolve, 1));

return NextResponse.json({
status: "ok",
timestamp: Date.now(),
duration: Date.now() - start,
});
}
11 changes: 11 additions & 0 deletions sentry-javascript/19367/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
20 changes: 20 additions & 0 deletions sentry-javascript/19367/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export default function Home() {
return (
<main style={{ fontFamily: "sans-serif", padding: "2rem" }}>
<h1>Repro: sentry-javascript#19367</h1>
<p>
Next.js 16 + Turbopack duplicates <code>@opentelemetry/api</code> across
chunks, causing infinite <code>.with()</code> recursion.
</p>
<p>
Hit the <a href="/api/test">/api/test</a> endpoint repeatedly (or under
load) to trigger OTel context propagation. The server may crash with{" "}
<code>RangeError: Maximum call stack size exceeded</code>.
</p>
<p>
Run <code>npm run check-otel-dedup</code> after building to detect
duplicate <code>@opentelemetry/api</code> chunks in the output.
</p>
</main>
);
}
9 changes: 9 additions & 0 deletions sentry-javascript/19367/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}
17 changes: 17 additions & 0 deletions sentry-javascript/19367/next.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
24 changes: 24 additions & 0 deletions sentry-javascript/19367/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
62 changes: 62 additions & 0 deletions sentry-javascript/19367/scripts/check-otel-dedup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Checks the Next.js build output (.next/server/chunks/) for duplicate
* @opentelemetry/api module definitions, which is the root cause of the
* infinite .with() recursion described in sentry-javascript#19367.
*
* Run after `npm run build`:
* node scripts/check-otel-dedup.js
*/

const fs = require("fs");
const path = require("path");

const chunksDir = path.join(__dirname, "../.next/server/chunks");

if (!fs.existsSync(chunksDir)) {
console.error(
"ERROR: .next/server/chunks not found. Run `npm run build` first."
);
process.exit(1);
}

const files = fs.readdirSync(chunksDir).filter((f) => f.endsWith(".js"));

const otelChunks = [];

for (const file of files) {
const content = fs.readFileSync(path.join(chunksDir, file), "utf8");
// @opentelemetry/api registers itself via a global symbol; look for the module definition
if (
content.includes("@opentelemetry/api") &&
(content.includes("ContextAPI") ||
content.includes("context._currentContext") ||
content.includes("Symbol.for(\"opentelemetry.js.api"))
) {
otelChunks.push(file);
}
}

console.log(`\nScanned ${files.length} server chunks in ${chunksDir}\n`);

if (otelChunks.length === 0) {
console.log("✓ No @opentelemetry/api module definitions found (may be externalized).");
} else if (otelChunks.length === 1) {
console.log(
`✓ @opentelemetry/api appears in exactly 1 chunk: ${otelChunks[0]}`
);
console.log(" This is the expected (non-duplicated) state.");
} else {
console.error(
`✗ BUG DETECTED: @opentelemetry/api module definition found in ${otelChunks.length} chunks:`
);
for (const f of otelChunks) {
console.error(` - ${f}`);
}
console.error(
"\n Two copies of @opentelemetry/api means their .with() methods will\n" +
" delegate to each other infinitely → RangeError: Maximum call stack\n" +
" size exceeded (sentry-javascript#19367)."
);
process.exit(1);
}
6 changes: 6 additions & 0 deletions sentry-javascript/19367/sentry.client.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Sentry from "@sentry/nextjs";

Sentry.init({
dsn: process.env.SENTRY_DSN || "",
tracesSampleRate: 1,
});
6 changes: 6 additions & 0 deletions sentry-javascript/19367/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as Sentry from "@sentry/nextjs";

Sentry.init({
dsn: process.env.SENTRY_DSN || "",
tracesSampleRate: 1,
});
12 changes: 12 additions & 0 deletions sentry-javascript/19367/sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from "@sentry/nextjs";

Sentry.init({
dsn: process.env.SENTRY_DSN || "",
tracesSampleRate: 1,
integrations: [
// prismaIntegration triggers @prisma/instrumentation which registers OTel instrumentations.
// Combined with Turbopack's chunk splitting, this leads to two copies of @opentelemetry/api
// whose .with() methods recursively call each other → RangeError: Maximum call stack size exceeded.
Sentry.prismaIntegration(),
],
});
41 changes: 41 additions & 0 deletions sentry-javascript/19367/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}