UNPKG

opentelemetry-instrumentation-fetch-node

Version:
257 lines (219 loc) 7.82 kB
/* * Portions from https://github.com/elastic/apm-agent-nodejs * Copyright Elasticsearch B.V. and other contributors where applicable. * Licensed under the BSD 2-Clause License; you may not use this file except in * compliance with the BSD 2-Clause License. * */ import diagch from 'node:diagnostics_channel'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation'; import { Attributes, context, Meter, MeterProvider, metrics, propagation, Span, SpanKind, SpanStatusCode, trace, Tracer, TracerProvider, } from '@opentelemetry/api'; interface ListenerRecord { name: string; channel: diagch.Channel; onMessage: diagch.ChannelListener; } interface FetchRequest { method: string; origin: string; path: string; headers: string | string[]; } interface FetchResponse { headers: Buffer[]; statusCode: number; } export interface FetchInstrumentationConfig extends InstrumentationConfig { ignoreRequestHook?: (request: FetchRequest) => boolean; onRequest?: (args: { request: FetchRequest; span: Span; additionalHeaders: Record<string, string | string[]>; }) => void; } function getMessage(error: Error) { if (error instanceof AggregateError) { return error.errors.map((e) => e.message).join(', '); } return error.message; } // Get the content-length from undici response headers. // `headers` is an Array of buffers: [k, v, k, v, ...]. // If the header is not present, or has an invalid value, this returns null. function contentLengthFromResponseHeaders(headers: Buffer[]) { const name = 'content-length'; for (let i = 0; i < headers.length; i += 2) { const k = headers[i]; if (k.length === name.length && k.toString().toLowerCase() === name) { const v = Number(headers[i + 1]); if (!Number.isNaN(Number(v))) { return v; } return undefined; } } return undefined; } async function loadFetch() { try { await fetch(''); } catch (_) { // } } // A combination of https://github.com/elastic/apm-agent-nodejs and // https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts export class FetchInstrumentation implements Instrumentation { // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for // unsubscribing. private channelSubs: Array<ListenerRecord> | undefined; private spanFromReq = new WeakMap<FetchRequest, Span>(); private tracer: Tracer; private config: FetchInstrumentationConfig; private meter: Meter; public readonly instrumentationName = 'opentelemetry-instrumentation-node-18-fetch'; public readonly instrumentationVersion = '1.0.0'; public readonly instrumentationDescription = 'Instrumentation for Node 18 fetch via diagnostics_channel'; private subscribeToChannel(diagnosticChannel: string, onMessage: diagch.ChannelListener) { const channel = diagch.channel(diagnosticChannel); channel.subscribe(onMessage); this.channelSubs!.push({ name: diagnosticChannel, channel, onMessage, }); } constructor(config: FetchInstrumentationConfig) { // Force load fetch API (since it's lazy loaded in Node 18) loadFetch(); this.channelSubs = []; this.meter = metrics.getMeter(this.instrumentationName, this.instrumentationVersion); this.tracer = trace.getTracer(this.instrumentationName, this.instrumentationVersion); this.config = { ...config }; } disable(): void { this.channelSubs?.forEach((sub) => sub.channel.unsubscribe(sub.onMessage)); } enable(): void { this.subscribeToChannel('undici:request:create', (args) => this.onRequest(args as { request: FetchRequest }), ); this.subscribeToChannel('undici:request:headers', (args) => this.onHeaders(args as { request: FetchRequest; response: FetchResponse }), ); this.subscribeToChannel('undici:request:trailers', (args) => this.onDone(args as { request: FetchRequest }), ); this.subscribeToChannel('undici:request:error', (args) => this.onError(args as { request: FetchRequest; error: Error }), ); } setTracerProvider(tracerProvider: TracerProvider): void { this.tracer = tracerProvider.getTracer(this.instrumentationName, this.instrumentationVersion); } public setMeterProvider(meterProvider: MeterProvider): void { this.meter = meterProvider.getMeter(this.instrumentationName, this.instrumentationVersion); } setConfig(config: InstrumentationConfig): void { this.config = { ...config }; } getConfig(): InstrumentationConfig { return this.config; } onRequest({ request }: { request: FetchRequest }): void { // Don't instrument CONNECT - see comments at: // https://github.com/elastic/apm-agent-nodejs/blob/c55b1d8c32b2574362fc24d81b8e173ce2f75257/lib/instrumentation/modules/undici.js#L24 if (request.method === 'CONNECT') { return; } if (this.config.ignoreRequestHook && this.config.ignoreRequestHook(request) === true) { return; } const span = this.tracer.startSpan(`HTTP ${request.method}`, { kind: SpanKind.CLIENT, attributes: { [SemanticAttributes.HTTP_URL]: getAbsoluteUrl(request.origin, request.path), [SemanticAttributes.HTTP_METHOD]: request.method, [SemanticAttributes.HTTP_TARGET]: request.path, 'http.client': 'fetch', }, }); const requestContext = trace.setSpan(context.active(), span); const addedHeaders: Record<string, string> = {}; propagation.inject(requestContext, addedHeaders); if (this.config.onRequest) { this.config.onRequest({ request, span, additionalHeaders: addedHeaders }); } if (Array.isArray(request.headers)) { request.headers.push(...Object.entries(addedHeaders).flat()); } else { request.headers += Object.entries(addedHeaders) .map(([k, v]) => `${k}: ${v}\r\n`) .join(''); } this.spanFromReq.set(request, span); } onHeaders({ request, response }: { request: FetchRequest; response: FetchResponse }): void { const span = this.spanFromReq.get(request); if (span !== undefined) { // We are currently *not* capturing response headers, even though the // intake API does allow it, because none of the other `setHttpContext` // uses currently do. const cLen = contentLengthFromResponseHeaders(response.headers); const attrs: Attributes = { [SemanticAttributes.HTTP_STATUS_CODE]: response.statusCode, }; if (cLen) { attrs[SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH] = cLen; } span.setAttributes(attrs); span.setStatus({ code: response.statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.OK, message: String(response.statusCode), }); } } onDone({ request }: { request: FetchRequest }): void { const span = this.spanFromReq.get(request); if (span !== undefined) { span.end(); this.spanFromReq.delete(request); } } onError({ request, error }: { request: FetchRequest; error: Error }): void { const span = this.spanFromReq.get(request); if (span !== undefined) { span.recordException(error); span.setStatus({ code: SpanStatusCode.ERROR, message: getMessage(error), }); span.end(); } } } function getAbsoluteUrl(origin: string, path: string = '/'): string { const url = `${origin}`; if (origin.endsWith('/') && path.startsWith('/')) { return `${url}${path.slice(1)}`; } if (!origin.endsWith('/') && !path.startsWith('/')) { return `${url}/${path.slice(1)}`; } return `${url}${path}`; }