diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 7b2eb337..6dd48353 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -1,6 +1,7 @@ import { type SpanProcessor } from '@opentelemetry/sdk-trace-node' import type { Instrumentation } from '@opentelemetry/instrumentation' -import { GET_TRACER, SHUTDOWN_TRACERS } from '../constants.js' +import { GET_TRACE_CONTEXT_FORWARDER, GET_TRACER, SHUTDOWN_TRACERS } from '../constants.js' +import { Context, context, W3CTraceContextPropagator } from '../opentelemetry.ts' export interface TracerProviderOptions { serviceName: string @@ -11,6 +12,7 @@ export interface TracerProviderOptions { siteName: string instrumentations?: (Instrumentation | Promise)[] spanProcessors?: (SpanProcessor | Promise)[] + propagationHeaders?: Headers } export const createTracerProvider = async (options: TracerProviderOptions) => { @@ -46,6 +48,27 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { propagator: new W3CTraceContextPropagator(), }) + let traceContextForwarder: (propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context + + if (options.propagationHeaders) { + traceContextForwarder = (propagator: W3CTraceContextPropagator, requestHeaders: Headers): Context => { + const getter = { + keys: (carrier: Headers) => [...carrier.keys()], + get: (carrier: Headers, key: string) => carrier.get(key) ?? undefined, + } + const extractedContext = propagator.extract(context.active(), options.propagationHeaders, getter) + + propagator.inject(context.active(), requestHeaders, { + set: (carrier, key, value) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + carrier.set(key, value) + }, + }) + + return extractedContext + } + } + const instrumentations = await Promise.all(options.instrumentations ?? []) registerInstrumentations({ @@ -70,6 +93,15 @@ export const createTracerProvider = async (options: TracerProviderOptions) => { }, }) + Object.defineProperty(globalThis, GET_TRACE_CONTEXT_FORWARDER, { + enumerable: false, + configurable: true, + writable: false, + value: function () { + return traceContextForwarder + }, + }) + Object.defineProperty(globalThis, SHUTDOWN_TRACERS, { enumerable: false, configurable: true, diff --git a/packages/otel/src/constants.ts b/packages/otel/src/constants.ts index 3262a667..aaabf161 100644 --- a/packages/otel/src/constants.ts +++ b/packages/otel/src/constants.ts @@ -1,3 +1,4 @@ export const GET_TRACER = '__netlify__getTracer' export const SHUTDOWN_TRACERS = '__netlify__shutdownTracers' +export const GET_TRACE_CONTEXT_FORWARDER = '__netlify__getTraceContextForwarder' export const TRACE_PREFIX = '__nfOTLPTrace' diff --git a/packages/otel/src/instrumentations/fetch.test.ts b/packages/otel/src/instrumentations/fetch.test.ts index e492d3b6..61d5be02 100644 --- a/packages/otel/src/instrumentations/fetch.test.ts +++ b/packages/otel/src/instrumentations/fetch.test.ts @@ -85,10 +85,15 @@ describe('header exclusion', () => { }) }) +let requestHeaders = new Headers() + describe('patched fetch', () => { const server = setupServer( // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - http.get('http://localhost:3000/ok', () => HttpResponse.json({ message: 'ok' })), + http.get('http://localhost:3000/ok', ({ request }) => { + requestHeaders = request.headers + return HttpResponse.json({ message: 'ok' }) + }), // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return http.post('http://localhost:3000/ok', () => HttpResponse.json({ message: 'ok' })), ) @@ -98,6 +103,7 @@ describe('patched fetch', () => { }) beforeEach(async () => { + requestHeaders = new Headers() await createTracerProvider({ headers: new Headers({ 'x-nf-enable-tracing': 'true' }), serviceName: 'test-service', @@ -162,4 +168,29 @@ describe('patched fetch', () => { }) await expect(fetch(req).then((r) => r.json())).resolves.toEqual({ message: 'ok' }) }) + + it('uses propagation headers to forward trace context', async () => { + const traceParent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' + await createTracerProvider({ + propagationHeaders: new Headers({ traceparent: traceParent }), + serviceName: 'test-service', + serviceVersion: '1.0.0', + deploymentEnvironment: 'test', + siteUrl: 'https://example.com', + siteId: '12345', + siteName: 'example', + instrumentations: [new FetchInstrumentation()], + }) + + await expect( + fetch('http://localhost:3000/ok', { headers: new Headers({ 'some-header': 'value' }) }).then((r) => r.json()), + ).resolves.toEqual({ message: 'ok' }) + + const forwardedTraceParent = requestHeaders.get('traceparent') + expect(forwardedTraceParent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/) + expect(forwardedTraceParent).not.toBe(traceParent) + + // ensure we do not strip existing headers + expect(requestHeaders.get('some-header')).toBe('value') + }) }) diff --git a/packages/otel/src/instrumentations/fetch.ts b/packages/otel/src/instrumentations/fetch.ts index df3f51a7..db246517 100644 --- a/packages/otel/src/instrumentations/fetch.ts +++ b/packages/otel/src/instrumentations/fetch.ts @@ -1,7 +1,8 @@ import * as api from '@opentelemetry/api' import { SugaredTracer } from '@opentelemetry/api/experimental' -import { _globalThis } from '@opentelemetry/core' +import { _globalThis, W3CTraceContextPropagator } from '@opentelemetry/core' import { InstrumentationConfig, type Instrumentation } from '@opentelemetry/instrumentation' +import { getTraceContextForwarder } from '../main.ts' export interface FetchInstrumentationConfig extends InstrumentationConfig { getRequestAttributes?(headers: Request): api.Attributes @@ -120,6 +121,23 @@ export class FetchInstrumentation implements Instrumentation { return await originalFetch(resource, options) } + const traceContextForwarder = getTraceContextForwarder() + if (traceContextForwarder) { + const headers = options?.headers ? new Headers(options.headers) : new Headers() + const extractedContext = traceContextForwarder(new W3CTraceContextPropagator(), headers) + + // Replace headers in options with the mutated version + const nextOptions: RequestInit = { ...options, headers } + + return tracer.startActiveSpan('fetch', {}, extractedContext, async (span) => { + const request = new Request(resource, nextOptions) + this.annotateFromRequest(span, request) + const response = await originalFetch(request, nextOptions) + this.annotateFromResponse(span, response) + return response + }) + } + return tracer.withActiveSpan('fetch', async (span) => { const request = new Request(resource, options) this.annotateFromRequest(span, request) diff --git a/packages/otel/src/main.ts b/packages/otel/src/main.ts index 67befeca..24d638e8 100644 --- a/packages/otel/src/main.ts +++ b/packages/otel/src/main.ts @@ -1,16 +1,26 @@ import { type SugaredSpanOptions, type SugaredTracer } from '@opentelemetry/api/experimental' -import { GET_TRACER, SHUTDOWN_TRACERS } from './constants.js' +import { GET_TRACER, SHUTDOWN_TRACERS, GET_TRACE_CONTEXT_FORWARDER } from './constants.js' import type { Context, Span } from '@opentelemetry/api' +import { W3CTraceContextPropagator } from '@opentelemetry/core' type GlobalThisExtended = typeof globalThis & { [GET_TRACER]?: (name?: string, version?: string) => SugaredTracer | undefined [SHUTDOWN_TRACERS]?: () => void + [GET_TRACE_CONTEXT_FORWARDER]?: () => + | ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) + | undefined } export const getTracer = (name?: string, version?: string): SugaredTracer | undefined => { return (globalThis as GlobalThisExtended)[GET_TRACER]?.(name, version) } +export const getTraceContextForwarder = (): + | ((propagator: W3CTraceContextPropagator, requestHeaders: Headers) => Context) + | undefined => { + return (globalThis as GlobalThisExtended)[GET_TRACE_CONTEXT_FORWARDER]?.() +} + export const shutdownTracers = async (): Promise => { return (globalThis as GlobalThisExtended)[SHUTDOWN_TRACERS]?.() }