Skip to content

Commit a666b89

Browse files
authored
feat: HttpInstrumentation (#555)
* feat: set up preliminary http instrumentation * fix: match interface to fetch instrumentation * fix: avoid multiple subscriptions * test: add tests for http instrumentation * test: fix typo in test case * feat: add http instrumentation entry point
1 parent b8083e3 commit a666b89

File tree

4 files changed

+289
-0
lines changed

4 files changed

+289
-0
lines changed

packages/otel/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@
6666
"default": "./dist/instrumentations/fetch.js"
6767
}
6868
},
69+
"./instrumentation-http": {
70+
"require": {
71+
"types": "./dist/instrumentations/http.d.cts",
72+
"default": "./dist/instrumentations/http.cjs"
73+
},
74+
"import": {
75+
"types": "./dist/instrumentations/http.d.ts",
76+
"default": "./dist/instrumentations/http.js"
77+
},
78+
"default": {
79+
"types": "./dist/instrumentations/http.d.ts",
80+
"default": "./dist/instrumentations/http.js"
81+
}
82+
},
6983
"./opentelemetry": {
7084
"require": {
7185
"types": "./dist/opentelemetry.d.cts",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { HttpInstrumentation } from './http.ts'
3+
4+
describe('header exclusion', () => {
5+
test('skips configured headers', () => {
6+
const instrumentation = new HttpInstrumentation({
7+
skipHeaders: ['authorization'],
8+
})
9+
10+
// eslint-disable-next-line @typescript-eslint/dot-notation
11+
const attributes = instrumentation['prepareHeaders']('request', {
12+
a: 'a',
13+
b: 'b',
14+
authorization: 'secret',
15+
})
16+
expect(attributes).toEqual({
17+
'http.request.header.a': 'a',
18+
'http.request.header.b': 'b',
19+
})
20+
})
21+
22+
test('it skips all headers if so configured', () => {
23+
const everything = new HttpInstrumentation({
24+
skipHeaders: true,
25+
})
26+
// eslint-disable-next-line @typescript-eslint/dot-notation
27+
const empty = everything['prepareHeaders']('request', {
28+
a: 'a',
29+
b: 'b',
30+
authorization: 'secret',
31+
})
32+
expect(empty).toEqual({})
33+
})
34+
35+
test('redacts configured headers', () => {
36+
const instrumentation = new HttpInstrumentation({
37+
redactHeaders: ['authorization'],
38+
})
39+
40+
// eslint-disable-next-line @typescript-eslint/dot-notation
41+
const attributes = instrumentation['prepareHeaders']('request', {
42+
a: 'a',
43+
b: 'b',
44+
authorization: 'secret',
45+
})
46+
expect(attributes['http.request.header.authorization']).not.toBe('secret')
47+
expect(attributes['http.request.header.authorization']).toBeTypeOf('string')
48+
expect(attributes['http.request.header.a']).toBe('a')
49+
expect(attributes['http.request.header.b']).toBe('b')
50+
})
51+
52+
test('redacts everything if so requested', () => {
53+
const instrumentation = new HttpInstrumentation({
54+
redactHeaders: true,
55+
})
56+
57+
// eslint-disable-next-line @typescript-eslint/dot-notation
58+
const attributes = instrumentation['prepareHeaders']('request', {
59+
a: 'a',
60+
b: 'b',
61+
authorization: 'secret',
62+
})
63+
expect(attributes['http.request.header.authorization']).not.toBe('secret')
64+
expect(attributes['http.request.header.a']).not.toBe('a')
65+
expect(attributes['http.request.header.b']).not.toBe('b')
66+
expect(attributes['http.request.header.authorization']).toBeTypeOf('string')
67+
expect(attributes['http.request.header.a']).toBeTypeOf('string')
68+
expect(attributes['http.request.header.b']).toBeTypeOf('string')
69+
})
70+
})
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import * as diagnosticsChannel from 'diagnostics_channel'
2+
import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders } from 'http'
3+
4+
import * as api from '@opentelemetry/api'
5+
import { SugaredTracer } from '@opentelemetry/api/experimental'
6+
import { _globalThis } from '@opentelemetry/core'
7+
import { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation'
8+
9+
export interface HttpInstrumentationConfig extends InstrumentationConfig {
10+
getRequestAttributes?(request: ClientRequest): api.Attributes
11+
getResponseAttributes?(response: IncomingMessage): api.Attributes
12+
skipURLs?: (string | RegExp)[]
13+
skipHeaders?: (string | RegExp)[] | true
14+
redactHeaders?: (string | RegExp)[] | true
15+
}
16+
17+
export class HttpInstrumentation implements Instrumentation {
18+
instrumentationName = '@netlify/otel/instrumentation-http'
19+
instrumentationVersion = '1.0.0'
20+
private config: HttpInstrumentationConfig
21+
private provider?: api.TracerProvider
22+
23+
declare private _channelSubs: ListenerRecord[]
24+
private _recordFromReq = new WeakMap<ClientRequest, api.Span>()
25+
26+
constructor(config = {}) {
27+
this.config = config
28+
this._channelSubs = []
29+
}
30+
31+
getConfig() {
32+
return this.config
33+
}
34+
35+
setConfig() {}
36+
37+
setMeterProvider() {}
38+
setTracerProvider(provider: api.TracerProvider): void {
39+
this.provider = provider
40+
}
41+
getTracerProvider(): api.TracerProvider | undefined {
42+
return this.provider
43+
}
44+
45+
private annotateFromRequest(span: api.Span, request: ClientRequest): void {
46+
const extras = this.config.getRequestAttributes?.(request) ?? {}
47+
const url = new URL(request.path, `${request.protocol}//${request.host}`)
48+
49+
// these are based on @opentelemetry/semantic-convention 1.36
50+
span.setAttributes({
51+
...extras,
52+
'http.request.method': request.method,
53+
'url.full': url.href,
54+
'url.host': url.host,
55+
'url.scheme': url.protocol.slice(0, -1),
56+
'server.address': url.hostname,
57+
...this.prepareHeaders('request', request.getHeaders()),
58+
})
59+
}
60+
61+
private annotateFromResponse(span: api.Span, response: IncomingMessage): void {
62+
const extras = this.config.getResponseAttributes?.(response) ?? {}
63+
64+
// these are based on @opentelemetry/semantic-convention 1.36
65+
span.setAttributes({
66+
...extras,
67+
'http.response.status_code': response.statusCode,
68+
...this.prepareHeaders('response', response.headers),
69+
})
70+
71+
span.setStatus({
72+
code: response.statusCode && response.statusCode >= 400 ? api.SpanStatusCode.ERROR : api.SpanStatusCode.UNSET,
73+
})
74+
}
75+
76+
private prepareHeaders(
77+
type: 'request' | 'response',
78+
headers: IncomingHttpHeaders | OutgoingHttpHeaders,
79+
): api.Attributes {
80+
if (this.config.skipHeaders === true) {
81+
return {}
82+
}
83+
const everything = ['*', '/.*/']
84+
const skips = this.config.skipHeaders ?? []
85+
const redacts = this.config.redactHeaders ?? []
86+
const everythingSkipped = skips.some((skip) => everything.includes(skip.toString()))
87+
const attributes: api.Attributes = {}
88+
if (everythingSkipped) return attributes
89+
const entries = Object.entries(headers)
90+
for (const [key, value] of entries) {
91+
if (skips.some((skip) => (typeof skip == 'string' ? skip == key : skip.test(key)))) {
92+
continue
93+
}
94+
const attributeKey = `http.${type}.header.${key}`
95+
if (
96+
redacts === true ||
97+
redacts.some((redact) => (typeof redact == 'string' ? redact == key : redact.test(key)))
98+
) {
99+
attributes[attributeKey] = 'REDACTED'
100+
} else {
101+
attributes[attributeKey] = value
102+
}
103+
}
104+
return attributes
105+
}
106+
107+
private getRequestMethod(original: string): string {
108+
const acceptedMethods = ['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE']
109+
110+
if (acceptedMethods.includes(original.toUpperCase())) {
111+
return original.toUpperCase()
112+
}
113+
114+
return '_OTHER'
115+
}
116+
117+
getTracer() {
118+
if (!this.provider) {
119+
return undefined
120+
}
121+
122+
const tracer = this.provider.getTracer(this.instrumentationName, this.instrumentationVersion)
123+
124+
if (tracer instanceof SugaredTracer) {
125+
return tracer
126+
}
127+
128+
return new SugaredTracer(tracer)
129+
}
130+
131+
enable() {
132+
// Avoid to duplicate subscriptions
133+
if (this._channelSubs.length > 0) return
134+
135+
// https://nodejs.org/docs/latest-v20.x/api/diagnostics_channel.html#http
136+
this.subscribe('http.client.request.start', this.onRequest.bind(this))
137+
this.subscribe('http.client.response.finish', this.onResponse.bind(this))
138+
this.subscribe('http.client.request.error', this.onError.bind(this))
139+
}
140+
141+
disable() {
142+
this._channelSubs.forEach((sub) => {
143+
sub.unsubscribe()
144+
})
145+
this._channelSubs.length = 0
146+
}
147+
148+
private onRequest({ request }: { request: ClientRequest }): void {
149+
const tracer = this.getTracer()
150+
if (!tracer) return
151+
152+
const span = tracer.startSpan(
153+
this.getRequestMethod(request.method),
154+
{
155+
kind: api.SpanKind.CLIENT,
156+
},
157+
api.context.active(),
158+
)
159+
160+
this.annotateFromRequest(span, request)
161+
162+
this._recordFromReq.set(request, span)
163+
}
164+
165+
private onResponse({ request, response }: { request: ClientRequest; response: IncomingMessage }): void {
166+
const span = this._recordFromReq.get(request)
167+
168+
if (!span) return
169+
170+
this.annotateFromResponse(span, response)
171+
172+
span.end()
173+
174+
this._recordFromReq.delete(request)
175+
}
176+
177+
private onError({ request, error }: { request: ClientRequest; error: Error }): void {
178+
const span = this._recordFromReq.get(request)
179+
180+
if (!span) return
181+
182+
span.recordException(error)
183+
span.setStatus({
184+
code: api.SpanStatusCode.ERROR,
185+
message: error.name,
186+
})
187+
188+
span.end()
189+
190+
this._recordFromReq.delete(request)
191+
}
192+
193+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
194+
private subscribe(channelName: string, onMessage: (message: any, name: string | symbol) => void) {
195+
diagnosticsChannel.subscribe(channelName, onMessage)
196+
const unsubscribe = () => diagnosticsChannel.unsubscribe(channelName, onMessage)
197+
this._channelSubs.push({ name: channelName, unsubscribe })
198+
}
199+
}
200+
201+
interface ListenerRecord {
202+
name: string
203+
unsubscribe: () => void
204+
}

packages/otel/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default defineConfig([
1111
'src/main.ts',
1212
'src/exporters/netlify.ts',
1313
'src/instrumentations/fetch.ts',
14+
'src/instrumentations/http.ts',
1415
'src/opentelemetry.ts',
1516
],
1617
tsconfig: 'tsconfig.json',

0 commit comments

Comments
 (0)