Skip to content

Commit 7bcfe6d

Browse files
authored
feat: Add skipping and redacting of headers (#404)
1 parent 0b0eabf commit 7bcfe6d

File tree

2 files changed

+129
-17
lines changed

2 files changed

+129
-17
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { FetchInstrumentation } from './fetch.ts'
3+
4+
describe('header exclusion', () => {
5+
test('skips configured headers', () => {
6+
const instrumentation = new FetchInstrumentation({
7+
skipHeaders: ['authorization'],
8+
})
9+
10+
// eslint-disable-next-line @typescript-eslint/dot-notation
11+
const attributes = instrumentation['prepareHeaders'](
12+
'request',
13+
new Headers({
14+
a: 'a',
15+
b: 'b',
16+
authorization: 'secret',
17+
}),
18+
)
19+
expect(attributes).toEqual({
20+
'http.request.header.a': 'a',
21+
'http.request.header.b': 'b',
22+
})
23+
})
24+
25+
test('it skips all headers if so configured', () => {
26+
const everything = new FetchInstrumentation({
27+
skipHeaders: true,
28+
})
29+
// eslint-disable-next-line @typescript-eslint/dot-notation
30+
const empty = everything['prepareHeaders'](
31+
'request',
32+
new Headers({
33+
a: 'a',
34+
b: 'b',
35+
authorization: 'secret',
36+
}),
37+
)
38+
expect(empty).toEqual({})
39+
})
40+
41+
test('redacts configured headers', () => {
42+
const instrumentation = new FetchInstrumentation({
43+
redactHeaders: ['authorization'],
44+
})
45+
46+
// eslint-disable-next-line @typescript-eslint/dot-notation
47+
const attributes = instrumentation['prepareHeaders'](
48+
'request',
49+
new Headers({
50+
a: 'a',
51+
b: 'b',
52+
authorization: 'a secret',
53+
}),
54+
)
55+
expect(attributes['http.request.header.authorization']).not.toBe('a secret')
56+
expect(attributes['http.request.header.authorization']).toBeTypeOf('string')
57+
expect(attributes['http.request.header.a']).toBe('a')
58+
expect(attributes['http.request.header.b']).toBe('b')
59+
})
60+
61+
test('redacts everything if so requested', () => {
62+
const instrumentation = new FetchInstrumentation({
63+
redactHeaders: true,
64+
})
65+
66+
// eslint-disable-next-line @typescript-eslint/dot-notation
67+
const attributes = instrumentation['prepareHeaders'](
68+
'request',
69+
new Headers({
70+
a: 'a',
71+
b: 'b',
72+
authorization: 'a secret',
73+
}),
74+
)
75+
expect(attributes['http.request.header.authorization']).not.toBe('a secret')
76+
expect(attributes['http.request.header.a']).not.toBe('a')
77+
expect(attributes['http.request.header.b']).not.toBe('b')
78+
expect(attributes['http.request.header.authorization']).toBeTypeOf('string')
79+
expect(attributes['http.request.header.a']).toBeTypeOf('string')
80+
expect(attributes['http.request.header.b']).toBeTypeOf('string')
81+
})
82+
})

packages/otel/src/instrumentations/fetch.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as api from '@opentelemetry/api'
2-
import { InstrumentationConfig, type Instrumentation } from '@opentelemetry/instrumentation'
3-
import { _globalThis } from '@opentelemetry/core'
42
import { SugaredTracer } from '@opentelemetry/api/experimental'
3+
import { _globalThis } from '@opentelemetry/core'
4+
import { InstrumentationConfig, type Instrumentation } from '@opentelemetry/instrumentation'
55

66
export interface FetchInstrumentationConfig extends InstrumentationConfig {
7-
getRequestAttributes?(request: Request | RequestInit): api.Attributes
7+
getRequestAttributes?(headers: Request): api.Attributes
88
getResponseAttributes?(response: Response): api.Attributes
9-
skipURLs?: string[]
9+
skipURLs?: (string | RegExp)[]
10+
skipHeaders?: (string | RegExp)[] | true
11+
redactHeaders?: (string | RegExp)[] | true
1012
}
1113

1214
export class FetchInstrumentation implements Instrumentation {
@@ -34,16 +36,6 @@ export class FetchInstrumentation implements Instrumentation {
3436
return this.provider
3537
}
3638

37-
private annotateFromResponse(span: api.Span, response: Response): void {
38-
const extras = this.config.getResponseAttributes?.(response) ?? {}
39-
// these are based on @opentelemetry/semantic-convention 1.36
40-
span.setAttributes({
41-
...extras,
42-
'http.response.status_code': response.status,
43-
...this.prepareHeaders('response', response.headers),
44-
})
45-
}
46-
4739
private annotateFromRequest(span: api.Span, request: Request): void {
4840
const extras = this.config.getRequestAttributes?.(request) ?? {}
4941
const url = new URL(request.url)
@@ -53,15 +45,50 @@ export class FetchInstrumentation implements Instrumentation {
5345
'http.request.method': request.method,
5446
'url.full': url.href,
5547
'url.host': url.host,
56-
'url.scheme': url.protocol.replace(':', ''),
48+
'url.scheme': url.protocol.slice(0, -1),
5749
'server.address': url.hostname,
5850
'server.port': url.port,
5951
...this.prepareHeaders('request', request.headers),
6052
})
6153
}
6254

55+
private annotateFromResponse(span: api.Span, response: Response): void {
56+
const extras = this.config.getResponseAttributes?.(response) ?? {}
57+
58+
// these are based on @opentelemetry/semantic-convention 1.36
59+
span.setAttributes({
60+
...extras,
61+
'http.response.status_code': response.status,
62+
...this.prepareHeaders('response', response.headers),
63+
})
64+
}
65+
6366
private prepareHeaders(type: 'request' | 'response', headers: Headers): api.Attributes {
64-
return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [`${type}.header.${key}`, value]))
67+
if (this.config.skipHeaders === true) {
68+
return {}
69+
}
70+
const everything = ['*', '/.*/']
71+
const skips = this.config.skipHeaders ?? []
72+
const redacts = this.config.redactHeaders ?? []
73+
const everythingSkipped = skips.some((skip) => everything.includes(skip.toString()))
74+
const attributes: api.Attributes = {}
75+
if (everythingSkipped) return attributes
76+
const entries = headers.entries()
77+
for (const [key, value] of entries) {
78+
if (skips.some((skip) => (typeof skip == 'string' ? skip == key : skip.test(key)))) {
79+
continue
80+
}
81+
const attributeKey = `http.${type}.header.${key}`
82+
if (
83+
redacts === true ||
84+
redacts.some((redact) => (typeof redact == 'string' ? redact == key : redact.test(key)))
85+
) {
86+
attributes[attributeKey] = 'REDACTED'
87+
} else {
88+
attributes[attributeKey] = value
89+
}
90+
}
91+
return attributes
6592
}
6693

6794
private getTracer(): SugaredTracer | undefined {
@@ -86,7 +113,10 @@ export class FetchInstrumentation implements Instrumentation {
86113
_globalThis.fetch = async (resource: RequestInfo | URL, options?: RequestInit): Promise<Response> => {
87114
const url = typeof resource === 'string' ? resource : resource instanceof URL ? resource.href : resource.url
88115
const tracer = this.getTracer()
89-
if (!tracer || this.config.skipURLs?.some((skip) => url.startsWith(skip))) {
116+
if (
117+
!tracer ||
118+
this.config.skipURLs?.some((skip) => (typeof skip == 'string' ? url.startsWith(skip) : skip.test(url)))
119+
) {
90120
return await originalFetch(resource, options)
91121
}
92122

0 commit comments

Comments
 (0)