opentelemetry-instrumentation-fetch-node
Version:
OpenTelemetry Node 18+ native fetch automatic instrumentation package
257 lines (219 loc) • 7.82 kB
text/typescript
/*
* 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}`;
}