diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/route-handler-error/[id]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/route-handler-error/[id]/route.ts new file mode 100644 index 000000000000..fde13119c1f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/route-handler-error/[id]/route.ts @@ -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}`); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/telemetry/[id]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/telemetry/[id]/route.ts index 5c1823c8805b..1ff73d89a2d7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/telemetry/[id]/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/telemetry/[id]/route.ts @@ -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'; @@ -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(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/page.tsx index 753b8859885c..82349ca99b69 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/page.tsx @@ -1,3 +1,3 @@ export default function Page() { - return

Next.js app with user-owned OpenTelemetry tracing and metrics

; + return

Next.js app with app-owned OpenTelemetry tracing

; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/server-component-error/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/server-component-error/[id]/page.tsx new file mode 100644 index 000000000000..5f893890c76e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/server-component-error/[id]/page.tsx @@ -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}`); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts index 029f85ac7903..4ec7c89096dd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts @@ -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; + 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 { - const flattened: Record = {}; - - 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, + }); } } } @@ -74,7 +38,7 @@ async function readJsonBody(stream: AsyncIterable): Promise { /** * 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 @@ -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; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel.server.config.ts index b3207cce10f4..0ee1bc170f31 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel.server.config.ts @@ -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 @@ -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, - }), - ], - }), - ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json index 96764928cfa5..740e6becc409 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json @@ -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", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otel-telemetry.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otel-telemetry.test.ts index ce87c41ea2ef..221295867055 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otel-telemetry.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otel-telemetry.test.ts @@ -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; -} - -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(select: (collected: Collected) => T | undefined, description: string): Promise { - 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 => - waitForCollected( - ({ metrics }) => metrics.find(metric => metric.name === 'otlp.test.count' && metric.attributes.id === id), - `the metric for id ${id}`, - ); - -const waitForExportedSpan = (spanId: string): Promise => - 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 }) => { @@ -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); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts new file mode 100644 index 000000000000..e79db4f8175f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts @@ -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(select: (collected: Collected) => T | undefined, description: string): Promise { + 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 => 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 }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/trace-linking.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/trace-linking.test.ts new file mode 100644 index 000000000000..8ef2f5e73515 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/trace-linking.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForError, waitForMetric, waitForRequest } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; +import { triggerTelemetry, waitForExportedSpan } from './otlp'; + +test('stamps captured exceptions 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('stamps logs with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('nextjs-otlp', envelope => { + return ( + envelope[0].type === 'log' && + (envelope[1] as SerializedLogContainer).items.some(item => item.body === 'This is a log with id 124') + ); + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '124'); + const logEnvelope = await logEnvelopePromise; + + const log = (logEnvelope[1] as SerializedLogContainer).items.find(item => item.body === 'This is a log with id 124'); + expect(log?.trace_id).toBe(traceId); +}); + +test('stamps metrics with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const metricPromise = waitForMetric('nextjs-otlp', metric => { + return metric.name === 'sentry.test.count' && metric.attributes?.id?.value === '125'; + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '125'); + const metric = await metricPromise; + + expect(metric.trace_id).toBe(traceId); +}); + +test('sends no envelope trace header while riding along on an OpenTelemetry span', async ({ baseURL }) => { + const envelopePromise = waitForRequest('nextjs-otlp', ({ envelope }) => { + const [, items] = envelope; + return items.some( + item => + (item[1] as { exception?: { values?: { value?: string }[] } })?.exception?.values?.[0]?.value === + 'This is an exception with id 126', + ); + }); + + await triggerTelemetry(baseURL as string, '126'); + const { envelope } = await envelopePromise; + const [envelopeHeaders] = envelope; + + // The Sentry scope's sampling context describes a different trace than the OpenTelemetry one the + // event is stamped with, so no `trace` header is sent rather than one naming the wrong trace. + expect((envelopeHeaders as { trace?: unknown }).trace).toBeUndefined(); +}); + +test('links captured exceptions to the request span the app exports over OTLP', async ({ baseURL }) => { + const errorEventPromise = waitForError('nextjs-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 135'; + }); + + const { traceId } = await triggerTelemetry(baseURL as string, '135'); + const errorEvent = await errorEventPromise; + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /api/telemetry/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(requestSpan.traceId); + expect(requestSpan.parentSpanId).toBeUndefined(); +}); + +test('captures errors thrown in route handlers on the request trace', async ({ baseURL }) => { + const errorEventPromise = waitForError('nextjs-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is a route handler error with id 246'; + }); + + const response = await fetch(`${baseURL}/api/route-handler-error/246`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + const traceId = errorEvent.contexts?.trace?.trace_id as string; + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /api/route-handler-error/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(requestSpan.parentSpanId).toBeUndefined(); + expect(errorEvent.transaction).toBe('GET /api/route-handler-error/[id]'); + // Webpack builds wrap the handler at build time, Turbopack builds rely on `onRequestError`. + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: expect.stringMatching(/^auto\.function\.nextjs\.(route_handler|on_request_error)$/), + }); +}); + +test('captures errors thrown in server components on the request trace', async ({ baseURL }) => { + const errorEventPromise = waitForError('nextjs-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is a server component error with id 357'; + }); + + const response = await fetch(`${baseURL}/server-component-error/357`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + const traceId = errorEvent.contexts?.trace?.trace_id as string; + expect(traceId).toMatch(/^[a-f0-9]{32}$/); + + const requestSpan = await waitForExportedSpan( + span => span.name === 'GET /server-component-error/[id]' && span.traceId === traceId, + `the request span for trace ${traceId}`, + ); + + expect(requestSpan.parentSpanId).toBeUndefined(); + // Webpack builds wrap the component at build time, Turbopack builds rely on `onRequestError`. + expect(errorEvent.transaction).toContain('/server-component-error/[id]'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: expect.stringMatching(/^auto\.function\.nextjs\.(server_component|on_request_error)$/), + }); +}); + +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); +});