From 31156408fec8e295fd17cb3a332313930c8130ea Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 14:54:55 +0200 Subject: [PATCH 1/2] test(e2e): Cover error, log and metric trace linking in the nextjs-otlp app Adds coverage for thrown route handler and server component errors, Sentry logs and metrics carrying the active OpenTelemetry trace, the envelope trace header, and the DSN-derived OTLP auth header, mirroring the node-express-otlp app. Co-Authored-By: Claude Fable 5.1 --- .../app/api/route-handler-error/[id]/route.ts | 7 + .../app/api/telemetry/[id]/route.ts | 2 + .../app/server-component-error/[id]/page.tsx | 7 + .../nextjs-otlp/otel-receiver.ts | 15 +- .../nextjs-otlp/otel.server.config.ts | 15 +- .../nextjs-otlp/tests/otel-telemetry.test.ts | 99 ++---------- .../nextjs-otlp/tests/otlp.ts | 42 +++++ .../nextjs-otlp/tests/trace-linking.test.ts | 148 ++++++++++++++++++ 8 files changed, 244 insertions(+), 91 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/route-handler-error/[id]/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-otlp/app/server-component-error/[id]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/trace-linking.test.ts 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..6c8578ea458f 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 @@ -11,6 +11,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: 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/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..c8db2702201d 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,7 +5,9 @@ export const OTLP_RECEIVER_PORT = 3033; export interface CollectedSpan { traceId: string; spanId: string; + parentSpanId?: string; name: string; + sentryAuthHeader?: string; } export interface CollectedMetric { @@ -37,11 +39,17 @@ function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = 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 }); + collectedSpans.push({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentSpanId, + name: span.name, + sentryAuthHeader, + }); } } } @@ -84,7 +92,8 @@ export function startOtlpReceiver(): void { const server = createServer((req, res) => { void (async () => { if (req.method === 'POST' && req.url === '/v1/traces') { - collectSpans(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; } 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..a63610614997 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 @@ -5,6 +5,7 @@ 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,15 +20,23 @@ if (!globalWithOtelFlag.__otelRegistered) { const resource = resourceFromAttributes({ 'service.name': 'nextjs-otlp' }); const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`; + // 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 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, - }), + new BatchSpanProcessor( + new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces`, headers: otlpTracesEndpoint.headers }), + { scheduledDelayMillis: 100 }, + ), ], }).register(); 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..c8bb32cefd13 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,30 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; - -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 }); -}); +import { triggerTelemetry, waitForExportedMetric, waitForExportedSpan } from './otlp'; test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => { await triggerTelemetry(baseURL as string, '234'); - const metric = await waitForExportedMetric('234'); + const metric = await waitForExportedMetric( + metric => metric.name === 'otlp.test.count' && metric.attributes.id === '234', + 'the metric for id 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 +46,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..72e02951a22e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts @@ -0,0 +1,42 @@ +import type { CollectedMetric, CollectedSpan } from '../otel-receiver'; +import { OTLP_RECEIVER_PORT } from '../otel-receiver'; + +const OTLP_RECEIVER_URL = `http://localhost:${OTLP_RECEIVER_PORT}`; + +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`); +} + +export const waitForExportedSpan = ( + matches: (span: CollectedSpan) => boolean, + description: string, +): Promise => waitForCollected(({ spans }) => spans.find(matches), description); + +export const waitForExportedMetric = ( + matches: (metric: CollectedMetric) => boolean, + description: string, +): Promise => waitForCollected(({ metrics }) => metrics.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); +}); From a6817440effafe829d4c91b9082e0d86651db472 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 17:39:46 +0200 Subject: [PATCH 2/2] test(e2e): Emit metrics through Sentry only in the nextjs-otlp app The app no longer registers an OpenTelemetry meter provider or exports metrics over OTLP. Metrics, like logs, go through Sentry and are asserted to carry the active OpenTelemetry trace. Co-Authored-By: Claude Fable 5.1 --- .../app/api/telemetry/[id]/route.ts | 4 +- .../nextjs-otlp/app/page.tsx | 2 +- .../nextjs-otlp/otel-receiver.ts | 56 +------------------ .../nextjs-otlp/otel.server.config.ts | 18 +----- .../nextjs-otlp/package.json | 2 - .../nextjs-otlp/tests/otel-telemetry.test.ts | 13 +---- .../nextjs-otlp/tests/otlp.ts | 16 +++--- 7 files changed, 14 insertions(+), 97 deletions(-) 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 6c8578ea458f..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,6 @@ 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}`)); 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/otel-receiver.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts index c8db2702201d..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 @@ -10,34 +10,7 @@ export interface CollectedSpan { sentryAuthHeader?: string; } -export interface CollectedMetric { - name: string; - value: number; - attributes: Record; -} - 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, sentryAuthHeader: string | undefined): void { for (const resourceSpan of body?.resourceSpans ?? []) { @@ -55,23 +28,6 @@ function collectSpans(body: any, sentryAuthHeader: string | undefined): void { } } -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): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { @@ -82,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 @@ -98,16 +54,8 @@ export function startOtlpReceiver(): void { 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 })); + 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 a63610614997..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,8 +1,5 @@ -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'; @@ -27,7 +24,7 @@ if (!globalWithOtelFlag.__otelRegistered) { throw new Error('Could not derive an OTLP traces endpoint from NEXT_PUBLIC_E2E_TEST_DSN'); } - // The user owns tracing: this registers the global tracer provider, context manager and + // 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({ @@ -39,17 +36,4 @@ if (!globalWithOtelFlag.__otelRegistered) { ), ], }).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 c8bb32cefd13..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,17 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -import { triggerTelemetry, waitForExportedMetric, waitForExportedSpan } from './otlp'; - -test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => { - await triggerTelemetry(baseURL as string, '234'); - - const metric = await waitForExportedMetric( - metric => metric.name === 'otlp.test.count' && metric.attributes.id === '234', - 'the metric for id 234', - ); - - expect(metric).toEqual({ name: 'otlp.test.count', value: 1, attributes: { id: '234' } }); -}); +import { triggerTelemetry, waitForExportedSpan } from './otlp'; 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'); 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 index 72e02951a22e..e79db4f8175f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otlp.ts @@ -1,11 +1,10 @@ -import type { CollectedMetric, CollectedSpan } from '../otel-receiver'; +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[]; - metrics: CollectedMetric[]; } async function waitForCollected(select: (collected: Collected) => T | undefined, description: string): Promise { @@ -23,7 +22,13 @@ async function waitForCollected(select: (collected: Collected) => T | undefin await new Promise(resolve => setTimeout(resolve, 200)); } - throw new Error(`Timed out waiting for ${description} to be exported over OTLP`); + 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 = ( @@ -31,11 +36,6 @@ export const waitForExportedSpan = ( description: string, ): Promise => waitForCollected(({ spans }) => spans.find(matches), description); -export const waitForExportedMetric = ( - matches: (metric: CollectedMetric) => boolean, - description: string, -): Promise => waitForCollected(({ metrics }) => metrics.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 };