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
46 changes: 46 additions & 0 deletions dev-packages/e2e-tests/test-applications/nextjs-otlp/.gitignore
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-otlp').startActiveSpan('telemetry-handler', span => {
const { traceId, spanId } = span.spanContext();

metrics.getMeter('nextjs-otlp').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,
});
110 changes: 110 additions & 0 deletions dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts
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,46 @@
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 { 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-otlp' });
const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`;

// The user owns tracing: this registers the global tracer provider, context manager and
// propagator. Sentry is initialized afterwards with `enableOpenTelemetrySetup: false` so it does
// not contend for any of them.
new NodeTracerProvider({
resource,
spanProcessors: [
new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces` }), {
scheduledDelayMillis: 100,
}),
],
}).register();

metrics.setGlobalMeterProvider(
new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }),
exportIntervalMillis: 500,
exportTimeoutMillis: 500,
}),
],
}),
);
}
59 changes: 59 additions & 0 deletions dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: The test should cover both Turbopack and webpack (as a variant) - you can check out the other nextjs 16 tests for the setup

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b602c33

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "nextjs-otlp",
"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",
"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-otlp (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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/nextjs';

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

// Errors only: no `tracesSampleRate`, so Sentry starts no spans and sends no transactions.

// The app brings its own OpenTelemetry SDK, which already owns the global tracer provider,
// context manager and propagator.
enableOpenTelemetrySetup: false,

// Puts the active OpenTelemetry span's trace on everything Sentry sends.
integrations: [Sentry.otlpIntegration()],
});
Loading
Loading