Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

event-dumps

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# Sentry Config File
.env.sentry-build-plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { metrics, trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/nextjs';

export const dynamic = 'force-dynamic';

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;

return trace.getTracer('nextjs-custom-otel').startActiveSpan('telemetry-handler', span => {
const { traceId, spanId } = span.spanContext();

metrics.getMeter('nextjs-custom-otel').createCounter('otlp.test.count').add(1, { id });

Sentry.captureException(new Error(`This is an exception with id ${id}`));

span.end();

return Response.json({ traceId, spanId });
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>Next.js app with user-owned OpenTelemetry tracing and metrics</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
baseDirectory: __dirname,
});

const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
},
];

export default eslintConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa',
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: 'http://localhost:3031/', // proxy server
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/nextjs';

export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Order matters: the app's OpenTelemetry SDK claims the global tracer provider first, and
// Sentry then attaches to it instead of setting up its own.
await import('./otel.server.config');
await import('./sentry.server.config');
}
}

export const onRequestError = Sentry.captureRequestError;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {};

export default withSentryConfig(nextConfig, {
silent: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createServer } from 'node:http';

export const OTLP_RECEIVER_PORT = 3033;

export interface CollectedSpan {
traceId: string;
spanId: string;
name: string;
}

export interface CollectedMetric {
name: string;
value: number;
attributes: Record<string, string>;
}

const collectedSpans: CollectedSpan[] = [];
const collectedMetrics: CollectedMetric[] = [];

interface OtlpAnyValue {
stringValue?: string;
intValue?: string | number;
doubleValue?: number;
boolValue?: boolean;
}

function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = []): Record<string, string> {
const flattened: Record<string, string> = {};

for (const { key, value } of attributes) {
const rawValue = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue;
if (rawValue !== undefined) {
flattened[key] = String(rawValue);
}
}

return flattened;
}

function collectSpans(body: any): void {
for (const resourceSpan of body?.resourceSpans ?? []) {
for (const scopeSpan of resourceSpan.scopeSpans ?? []) {
for (const span of scopeSpan.spans ?? []) {
collectedSpans.push({ traceId: span.traceId, spanId: span.spanId, name: span.name });
}
}
}
}

function collectMetrics(body: any): void {
for (const resourceMetric of body?.resourceMetrics ?? []) {
for (const scopeMetric of resourceMetric.scopeMetrics ?? []) {
for (const metric of scopeMetric.metrics ?? []) {
// Only counters are recorded by this app, so `sum` is the only shape that needs handling.
for (const dataPoint of metric.sum?.dataPoints ?? []) {
collectedMetrics.push({
name: metric.name,
value: Number(dataPoint.asInt ?? dataPoint.asDouble ?? 0),
attributes: flattenAttributes(dataPoint.attributes),
});
}
}
}
}
}

async function readJsonBody(stream: AsyncIterable<Buffer>): Promise<any> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

/**
* Stands in for the OTLP backend the app would export to in production, so the test can assert what
* the user's OpenTelemetry SDK actually put on the wire.
*
* It deliberately runs as a plain `node:http` server rather than a Next.js route: exporting into the
* Next.js server would make every export request produce spans of its own, which would then be
* exported again.
*/
export function startOtlpReceiver(): void {
const server = createServer((req, res) => {
void (async () => {
if (req.method === 'POST' && req.url === '/v1/traces') {
collectSpans(await readJsonBody(req));
res.writeHead(200, { 'content-type': 'application/json' }).end('{}');
return;
}

if (req.method === 'POST' && req.url === '/v1/metrics') {
collectMetrics(await readJsonBody(req));
res.writeHead(200, { 'content-type': 'application/json' }).end('{}');
return;
}

if (req.method === 'GET' && req.url === '/collected') {
res
.writeHead(200, { 'content-type': 'application/json' })
.end(JSON.stringify({ spans: collectedSpans, metrics: collectedMetrics }));
return;
}

res.writeHead(404).end();
})();
});

server.listen(OTLP_RECEIVER_PORT);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { metrics } from '@opentelemetry/api';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { SentryContextManager } from '@sentry/nextjs';
import { SentryPropagator } from '@sentry/opentelemetry';
import { OTLP_RECEIVER_PORT, startOtlpReceiver } from './otel-receiver';

// Next.js can run `register()` more than once in dev, which would leave a second receiver fighting
// for the port and a second set of providers losing the race to register globally.
const globalWithOtelFlag = globalThis as typeof globalThis & { __otelRegistered?: boolean };

if (!globalWithOtelFlag.__otelRegistered) {
globalWithOtelFlag.__otelRegistered = true;

startOtlpReceiver();

const resource = resourceFromAttributes({ 'service.name': 'nextjs-custom-otel' });
const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`;

// The user owns tracing. Sentry's context manager and propagator are handed to the user's
// provider (the documented `skipOpenTelemetrySetup` path) so Sentry's scopes still ride on the
// OpenTelemetry context. No `SentrySpanProcessor` or `SentrySampler`: Sentry sends no spans here.
new NodeTracerProvider({
resource,
spanProcessors: [
new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces` }), {
scheduledDelayMillis: 100,
}),
],
}).register({
contextManager: new SentryContextManager(),
propagator: new SentryPropagator(),
});

metrics.setGlobalMeterProvider(
new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }),
exportIntervalMillis: 500,
exportTimeoutMillis: 500,
}),
],
}),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"name": "nextjs-custom-otel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev:webpack": "next dev --webpack",
"build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
"clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs",
"start": "next start",
"lint": "eslint",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test",
"test:dev-webpack": "TEST_ENV=development-webpack playwright test",
"test:build": "pnpm install && pnpm build",
"test:build-webpack": "pnpm install && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-metrics": "^2.9.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@opentelemetry/sdk-trace-node": "^2.9.0",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/opentelemetry": "file:../../packed/sentry-opentelemetry-packed.tgz",
"import-in-the-middle": "^2",
"next": "16.3.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^8"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "^16",
"typescript": "^5"
},
"volta": {
"extends": "../../package.json"
},
"sentryTest": {
"variants": [
{
"build-command": "pnpm test:build-webpack",
"label": "nextjs-custom-otel (webpack)",
"assert-command": "pnpm test:assert-webpack"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const testEnv = process.env.TEST_ENV;

if (!testEnv) {
throw new Error('No test env defined');
}

const getStartCommand = () => {
if (testEnv === 'development') {
return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs';
}

if (testEnv === 'development-webpack') {
return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs';
}

if (testEnv === 'production') {
return 'pnpm next start -p 3030';
}

throw new Error(`Unknown test env: ${testEnv}`);
};

const config = getPlaywrightConfig({
startCommand: getStartCommand(),
port: 3030,
});

export default config;
Loading
Loading