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);
+});