diff --git a/packages/otel/package.json b/packages/otel/package.json index 8bdeb7ec4..8bce39b24 100644 --- a/packages/otel/package.json +++ b/packages/otel/package.json @@ -66,6 +66,20 @@ "default": "./dist/instrumentations/fetch.js" } }, + "./instrumentation-http": { + "require": { + "types": "./dist/instrumentations/http.d.cts", + "default": "./dist/instrumentations/http.cjs" + }, + "import": { + "types": "./dist/instrumentations/http.d.ts", + "default": "./dist/instrumentations/http.js" + }, + "default": { + "types": "./dist/instrumentations/http.d.ts", + "default": "./dist/instrumentations/http.js" + } + }, "./opentelemetry": { "require": { "types": "./dist/opentelemetry.d.cts", diff --git a/packages/otel/src/instrumentations/http.test.ts b/packages/otel/src/instrumentations/http.test.ts new file mode 100644 index 000000000..5a498d9a8 --- /dev/null +++ b/packages/otel/src/instrumentations/http.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest' +import { HttpInstrumentation } from './http.ts' + +describe('header exclusion', () => { + test('skips configured headers', () => { + const instrumentation = new HttpInstrumentation({ + skipHeaders: ['authorization'], + }) + + // eslint-disable-next-line @typescript-eslint/dot-notation + const attributes = instrumentation['prepareHeaders']('request', { + a: 'a', + b: 'b', + authorization: 'secret', + }) + expect(attributes).toEqual({ + 'http.request.header.a': 'a', + 'http.request.header.b': 'b', + }) + }) + + test('it skips all headers if so configured', () => { + const everything = new HttpInstrumentation({ + skipHeaders: true, + }) + // eslint-disable-next-line @typescript-eslint/dot-notation + const empty = everything['prepareHeaders']('request', { + a: 'a', + b: 'b', + authorization: 'secret', + }) + expect(empty).toEqual({}) + }) + + test('redacts configured headers', () => { + const instrumentation = new HttpInstrumentation({ + redactHeaders: ['authorization'], + }) + + // eslint-disable-next-line @typescript-eslint/dot-notation + const attributes = instrumentation['prepareHeaders']('request', { + a: 'a', + b: 'b', + authorization: 'secret', + }) + expect(attributes['http.request.header.authorization']).not.toBe('secret') + expect(attributes['http.request.header.authorization']).toBeTypeOf('string') + expect(attributes['http.request.header.a']).toBe('a') + expect(attributes['http.request.header.b']).toBe('b') + }) + + test('redacts everything if so requested', () => { + const instrumentation = new HttpInstrumentation({ + redactHeaders: true, + }) + + // eslint-disable-next-line @typescript-eslint/dot-notation + const attributes = instrumentation['prepareHeaders']('request', { + a: 'a', + b: 'b', + authorization: 'secret', + }) + expect(attributes['http.request.header.authorization']).not.toBe('secret') + expect(attributes['http.request.header.a']).not.toBe('a') + expect(attributes['http.request.header.b']).not.toBe('b') + expect(attributes['http.request.header.authorization']).toBeTypeOf('string') + expect(attributes['http.request.header.a']).toBeTypeOf('string') + expect(attributes['http.request.header.b']).toBeTypeOf('string') + }) +}) diff --git a/packages/otel/src/instrumentations/http.ts b/packages/otel/src/instrumentations/http.ts new file mode 100644 index 000000000..508388cd0 --- /dev/null +++ b/packages/otel/src/instrumentations/http.ts @@ -0,0 +1,204 @@ +import * as diagnosticsChannel from 'diagnostics_channel' +import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders } from 'http' + +import * as api from '@opentelemetry/api' +import { SugaredTracer } from '@opentelemetry/api/experimental' +import { _globalThis } from '@opentelemetry/core' +import { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation' + +export interface HttpInstrumentationConfig extends InstrumentationConfig { + getRequestAttributes?(request: ClientRequest): api.Attributes + getResponseAttributes?(response: IncomingMessage): api.Attributes + skipURLs?: (string | RegExp)[] + skipHeaders?: (string | RegExp)[] | true + redactHeaders?: (string | RegExp)[] | true +} + +export class HttpInstrumentation implements Instrumentation { + instrumentationName = '@netlify/otel/instrumentation-http' + instrumentationVersion = '1.0.0' + private config: HttpInstrumentationConfig + private provider?: api.TracerProvider + + declare private _channelSubs: ListenerRecord[] + private _recordFromReq = new WeakMap() + + constructor(config = {}) { + this.config = config + this._channelSubs = [] + } + + getConfig() { + return this.config + } + + setConfig() {} + + setMeterProvider() {} + setTracerProvider(provider: api.TracerProvider): void { + this.provider = provider + } + getTracerProvider(): api.TracerProvider | undefined { + return this.provider + } + + private annotateFromRequest(span: api.Span, request: ClientRequest): void { + const extras = this.config.getRequestAttributes?.(request) ?? {} + const url = new URL(request.path, `${request.protocol}//${request.host}`) + + // these are based on @opentelemetry/semantic-convention 1.36 + span.setAttributes({ + ...extras, + 'http.request.method': request.method, + 'url.full': url.href, + 'url.host': url.host, + 'url.scheme': url.protocol.slice(0, -1), + 'server.address': url.hostname, + ...this.prepareHeaders('request', request.getHeaders()), + }) + } + + private annotateFromResponse(span: api.Span, response: IncomingMessage): void { + const extras = this.config.getResponseAttributes?.(response) ?? {} + + // these are based on @opentelemetry/semantic-convention 1.36 + span.setAttributes({ + ...extras, + 'http.response.status_code': response.statusCode, + ...this.prepareHeaders('response', response.headers), + }) + + span.setStatus({ + code: response.statusCode && response.statusCode >= 400 ? api.SpanStatusCode.ERROR : api.SpanStatusCode.UNSET, + }) + } + + private prepareHeaders( + type: 'request' | 'response', + headers: IncomingHttpHeaders | OutgoingHttpHeaders, + ): api.Attributes { + if (this.config.skipHeaders === true) { + return {} + } + const everything = ['*', '/.*/'] + const skips = this.config.skipHeaders ?? [] + const redacts = this.config.redactHeaders ?? [] + const everythingSkipped = skips.some((skip) => everything.includes(skip.toString())) + const attributes: api.Attributes = {} + if (everythingSkipped) return attributes + const entries = Object.entries(headers) + for (const [key, value] of entries) { + if (skips.some((skip) => (typeof skip == 'string' ? skip == key : skip.test(key)))) { + continue + } + const attributeKey = `http.${type}.header.${key}` + if ( + redacts === true || + redacts.some((redact) => (typeof redact == 'string' ? redact == key : redact.test(key))) + ) { + attributes[attributeKey] = 'REDACTED' + } else { + attributes[attributeKey] = value + } + } + return attributes + } + + private getRequestMethod(original: string): string { + const acceptedMethods = ['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + + if (acceptedMethods.includes(original.toUpperCase())) { + return original.toUpperCase() + } + + return '_OTHER' + } + + getTracer() { + if (!this.provider) { + return undefined + } + + const tracer = this.provider.getTracer(this.instrumentationName, this.instrumentationVersion) + + if (tracer instanceof SugaredTracer) { + return tracer + } + + return new SugaredTracer(tracer) + } + + enable() { + // Avoid to duplicate subscriptions + if (this._channelSubs.length > 0) return + + // https://nodejs.org/docs/latest-v20.x/api/diagnostics_channel.html#http + this.subscribe('http.client.request.start', this.onRequest.bind(this)) + this.subscribe('http.client.response.finish', this.onResponse.bind(this)) + this.subscribe('http.client.request.error', this.onError.bind(this)) + } + + disable() { + this._channelSubs.forEach((sub) => { + sub.unsubscribe() + }) + this._channelSubs.length = 0 + } + + private onRequest({ request }: { request: ClientRequest }): void { + const tracer = this.getTracer() + if (!tracer) return + + const span = tracer.startSpan( + this.getRequestMethod(request.method), + { + kind: api.SpanKind.CLIENT, + }, + api.context.active(), + ) + + this.annotateFromRequest(span, request) + + this._recordFromReq.set(request, span) + } + + private onResponse({ request, response }: { request: ClientRequest; response: IncomingMessage }): void { + const span = this._recordFromReq.get(request) + + if (!span) return + + this.annotateFromResponse(span, response) + + span.end() + + this._recordFromReq.delete(request) + } + + private onError({ request, error }: { request: ClientRequest; error: Error }): void { + const span = this._recordFromReq.get(request) + + if (!span) return + + span.recordException(error) + span.setStatus({ + code: api.SpanStatusCode.ERROR, + message: error.name, + }) + + span.end() + + this._recordFromReq.delete(request) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private subscribe(channelName: string, onMessage: (message: any, name: string | symbol) => void) { + diagnosticsChannel.subscribe(channelName, onMessage) + const unsubscribe = () => diagnosticsChannel.unsubscribe(channelName, onMessage) + this._channelSubs.push({ name: channelName, unsubscribe }) + } +} + +interface ListenerRecord { + name: string + unsubscribe: () => void +} diff --git a/packages/otel/tsup.config.ts b/packages/otel/tsup.config.ts index 578adedeb..f1bf5ab52 100644 --- a/packages/otel/tsup.config.ts +++ b/packages/otel/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig([ 'src/main.ts', 'src/exporters/netlify.ts', 'src/instrumentations/fetch.ts', + 'src/instrumentations/http.ts', 'src/opentelemetry.ts', ], tsconfig: 'tsconfig.json',