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,7 @@
export const dynamic = 'force-dynamic';

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

throw new Error(`This is a route handler error with id ${id}`);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { metrics, trace } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/nextjs';

export const dynamic = 'force-dynamic';
Expand All @@ -9,8 +9,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
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.logger.info(`This is a log with id ${id}`);
Sentry.metrics.count('sentry.test.count', 1, { attributes: { id } });
Sentry.captureException(new Error(`This is an exception with id ${id}`));

span.end();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default function Page() {
return <p>Next.js app with user-owned OpenTelemetry tracing and metrics</p>;
return <p>Next.js app with app-owned OpenTelemetry tracing</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const dynamic = 'force-dynamic';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;

throw new Error(`This is a server component error with id ${id}`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,24 @@ export const OTLP_RECEIVER_PORT = 3033;
export interface CollectedSpan {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
}

export interface CollectedMetric {
name: string;
value: number;
attributes: Record<string, string>;
sentryAuthHeader?: 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 {
function collectSpans(body: any, sentryAuthHeader: string | undefined): 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),
});
}
collectedSpans.push({
traceId: span.traceId,
spanId: span.spanId,
parentSpanId: span.parentSpanId,
name: span.name,
sentryAuthHeader,
});
}
}
}
Expand All @@ -74,7 +38,7 @@ async function readJsonBody(stream: AsyncIterable<Buffer>): Promise<any> {

/**
* 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.
* the app'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
Expand All @@ -84,21 +48,14 @@ 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));
const sentryAuthHeader = req.headers['x-sentry-auth'];
collectSpans(await readJsonBody(req), Array.isArray(sentryAuthHeader) ? sentryAuthHeader[0] : sentryAuthHeader);
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 }));
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ spans: collectedSpans }));
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
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 { getOtlpTracesEndpoint } from '@sentry/nextjs';
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
Expand All @@ -19,28 +17,23 @@ if (!globalWithOtelFlag.__otelRegistered) {
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
// In production the exporter would point at `otlpTracesEndpoint.url`; here it points at the local
// receiver so the test can assert what was exported. The auth headers are the real DSN-derived ones.
const otlpTracesEndpoint = getOtlpTracesEndpoint(process.env.NEXT_PUBLIC_E2E_TEST_DSN as string);
if (!otlpTracesEndpoint) {
throw new Error('Could not derive an OTLP traces endpoint from NEXT_PUBLIC_E2E_TEST_DSN');
}

// The app 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,
}),
new BatchSpanProcessor(
new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces`, headers: otlpTracesEndpoint.headers }),
{ scheduledDelayMillis: 100 },
),
],
}).register();

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
Expand Up @@ -20,10 +20,8 @@
},
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,82 +1,19 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { triggerTelemetry, waitForExportedSpan } from './otlp';

const OTLP_RECEIVER_URL = 'http://localhost:3033';

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

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

async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> {
const response = await fetch(`${baseURL}/api/telemetry/${id}`);
return (await response.json()) as { traceId: string; spanId: string };
}

interface Collected {
spans: CollectedSpan[];
metrics: CollectedMetric[];
}

async function waitForCollected<T>(select: (collected: Collected) => T | undefined, description: string): Promise<T> {
const deadline = Date.now() + 15_000;

while (Date.now() < deadline) {
const response = await fetch(`${OTLP_RECEIVER_URL}/collected`);
const collected = (await response.json()) as Collected;

const match = select(collected);
if (match !== undefined) {
return match;
}

await new Promise(resolve => setTimeout(resolve, 200));
}

throw new Error(`Timed out waiting for ${description} to be exported over OTLP`);
}

const waitForExportedMetric = (id: string): Promise<CollectedMetric> =>
waitForCollected(
({ metrics }) => metrics.find(metric => metric.name === 'otlp.test.count' && metric.attributes.id === id),
`the metric for id ${id}`,
);

const waitForExportedSpan = (spanId: string): Promise<CollectedSpan> =>
waitForCollected(({ spans }) => spans.find(span => span.spanId === spanId), `the span ${spanId}`);

test('stamps errors with the trace of the active OpenTelemetry span', async ({ baseURL }) => {
const errorEventPromise = waitForError('nextjs-otlp', event => {
return event.exception?.values?.[0]?.value === 'This is an exception with id 123';
});

const { traceId, spanId } = await triggerTelemetry(baseURL as string, '123');
const errorEvent = await errorEventPromise;

expect(errorEvent.contexts?.trace).toEqual({ trace_id: traceId, span_id: spanId });
});

test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => {
await triggerTelemetry(baseURL as string, '234');

const metric = await waitForExportedMetric('234');

expect(metric).toEqual({ name: 'otlp.test.count', value: 1, attributes: { id: '234' } });
});

test('keeps exporting the app-owned spans over OTLP', async ({ baseURL }) => {
test('keeps exporting the app-owned spans over OTLP with the DSN-derived auth header', async ({ baseURL }) => {
const { traceId, spanId } = await triggerTelemetry(baseURL as string, '345');

const span = await waitForExportedSpan(spanId);
const span = await waitForExportedSpan(span => span.spanId === spanId, `the span ${spanId}`);

expect(span).toEqual({ traceId, spanId, name: 'telemetry-handler' });
expect(span).toEqual({
sentryAuthHeader: expect.stringMatching(/^Sentry sentry_version=7, sentry_key=\w+$/),
traceId,
spanId,
parentSpanId: expect.stringMatching(/^[a-f0-9]{16}$/),
name: 'telemetry-handler',
});
});

test('sends no transactions to Sentry', async ({ baseURL }) => {
Expand All @@ -98,22 +35,3 @@ test('sends no transactions to Sentry', async ({ baseURL }) => {

expect(transaction).toBeUndefined();
});

test('keeps concurrent requests on separate traces', async ({ baseURL }) => {
const errorEventPromises = ['567', '678'].map(id =>
waitForError('nextjs-otlp', event => {
return event.exception?.values?.[0]?.value === `This is an exception with id ${id}`;
}),
);

const [first, second] = await Promise.all([
triggerTelemetry(baseURL as string, '567'),
triggerTelemetry(baseURL as string, '678'),
]);

const [firstError, secondError] = await Promise.all(errorEventPromises);

expect(first.traceId).not.toBe(second.traceId);
expect(firstError.contexts?.trace?.trace_id).toBe(first.traceId);
expect(secondError.contexts?.trace?.trace_id).toBe(second.traceId);
});
42 changes: 42 additions & 0 deletions dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { CollectedSpan } from '../otel-receiver';
import { OTLP_RECEIVER_PORT } from '../otel-receiver';

const OTLP_RECEIVER_URL = `http://localhost:${OTLP_RECEIVER_PORT}`;

interface Collected {
spans: CollectedSpan[];
}

async function waitForCollected<T>(select: (collected: Collected) => T | undefined, description: string): Promise<T> {
const deadline = Date.now() + 15_000;

while (Date.now() < deadline) {
const response = await fetch(`${OTLP_RECEIVER_URL}/collected`);
const collected = (await response.json()) as Collected;

const match = select(collected);
if (match !== undefined) {
return match;
}

await new Promise(resolve => setTimeout(resolve, 200));
}

const response = await fetch(`${OTLP_RECEIVER_URL}/collected`);
const { spans } = (await response.json()) as Collected;
const exportedSpanNames = [...new Set(spans.map(span => span.name))].join(', ');

throw new Error(
`Timed out waiting for ${description} to be exported over OTLP. Exported span names: ${exportedSpanNames}`,
);
}

export const waitForExportedSpan = (
matches: (span: CollectedSpan) => boolean,
description: string,
): Promise<CollectedSpan> => waitForCollected(({ spans }) => spans.find(matches), description);

export async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> {
const response = await fetch(`${baseURL}/api/telemetry/${id}`);
return (await response.json()) as { traceId: string; spanId: string };
}
Loading
Loading