diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-otlp/.gitignore new file mode 100644 index 000000000000..ae044ec5ad53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/.gitignore @@ -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 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 new file mode 100644 index 000000000000..5c1823c8805b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/api/telemetry/[id]/route.ts @@ -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 }); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} 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 new file mode 100644 index 000000000000..753b8859885c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

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

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-otlp/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/eslint.config.mjs @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation-client.ts new file mode 100644 index 000000000000..115a6667cb4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation-client.ts @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation.ts new file mode 100644 index 000000000000..c3761ac260bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/instrumentation.ts @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/next.config.ts new file mode 100644 index 000000000000..6699b3dd2c33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/next.config.ts @@ -0,0 +1,8 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); 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 new file mode 100644 index 000000000000..029f85ac7903 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel-receiver.ts @@ -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; +} + +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 { + 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): Promise { + 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); +} 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 new file mode 100644 index 000000000000..b3207cce10f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/otel.server.config.ts @@ -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, + }), + ], + }), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json new file mode 100644 index 000000000000..96764928cfa5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/package.json @@ -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" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-otlp/playwright.config.mjs new file mode 100644 index 000000000000..f727698c0983 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/playwright.config.mjs @@ -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; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-otlp/sentry.server.config.ts new file mode 100644 index 000000000000..8aac811122de --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/sentry.server.config.ts @@ -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()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..40613495946b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-otlp', +}); 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 new file mode 100644 index 000000000000..ce87c41ea2ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tests/otel-telemetry.test.ts @@ -0,0 +1,119 @@ +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 }); +}); + +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 }) => { + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '345'); + + const span = await waitForExportedSpan(spanId); + + expect(span).toEqual({ traceId, spanId, name: 'telemetry-handler' }); +}); + +test('sends no transactions to Sentry', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('nextjs-otlp', () => true); + const errorPromise = waitForError('nextjs-otlp', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await triggerTelemetry(baseURL as string, '456'); + // Proves the request's telemetry reached the proxy, so the absence check below is not vacuous. + await errorPromise; + + // Absence can only be time bounded. This guards against Sentry's tracing defaults changing under + // the app, which would emit a transaction for every request, well inside this window. + const transaction = await Promise.race([ + transactionPromise, + new Promise(resolve => setTimeout(() => resolve(undefined), 3000)), + ]); + + 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/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-otlp/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 94ca87dc1ef1..369f1d95d8fe 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -43,6 +43,9 @@ export interface SentryCarrier { /** Strategy for assembling segment spans into transactions; set by SDKs that defer capture. */ segmentSpanCaptureStrategy?: SegmentSpanCaptureStrategy; + /** Supplies trace context from a non-Sentry source (e.g. OpenTelemetry); set by `otlpIntegration`. */ + externalPropagationContextProvider?: () => { traceId: string; spanId: string } | undefined; + /** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */ encodePolyfill?: (input: string) => Uint8Array; /** Overwrites TextDecoder used in `@sentry/core`, need for `react-native@0.73` and older */ diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index b66f3fac6e66..dd8e49c9c5d4 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -1,33 +1,34 @@ import { getAsyncContextStrategy } from './asyncContext'; -import { getGlobalSingleton, getMainCarrier } from './carrier'; +import { getGlobalSingleton, getMainCarrier, getSentryCarrier } from './carrier'; import type { Client } from './client'; import { Scope } from './scope'; import type { TraceContext } from './types/context'; import { generateSpanId } from './utils/propagationContext'; -let _externalPropagationContextProvider: (() => { traceId: string; spanId: string } | undefined) | undefined; - /** * Register an external propagation context provider function. * When registered, trace context will be read from the external source (e.g. OpenTelemetry) * instead of from the Sentry scope's propagation context. */ export function registerExternalPropagationContext(fn: () => { traceId: string; spanId: string } | undefined): void { - _externalPropagationContextProvider = fn; + // Kept on the carrier rather than in module state: bundlers routinely emit more than one copy of + // `@sentry/core` (e.g. one per Next.js server chunk), and the copy the integration registers on is + // usually not the copy that reads it back when an event is assembled. + getSentryCarrier(getMainCarrier()).externalPropagationContextProvider = fn; } /** * Get the external propagation context, if a provider has been registered. */ export function getExternalPropagationContext(): { traceId: string; spanId: string } | undefined { - return _externalPropagationContextProvider?.(); + return getSentryCarrier(getMainCarrier()).externalPropagationContextProvider?.(); } /** * Check if an external propagation context provider has been registered. */ export function hasExternalPropagationContext(): boolean { - return _externalPropagationContextProvider !== undefined; + return getSentryCarrier(getMainCarrier()).externalPropagationContextProvider !== undefined; } /**