Skip to content

Commit 6c21b4c

Browse files
committed
Add a mechanism to redact fetch instrumentation
1 parent 4ad6c88 commit 6c21b4c

File tree

2 files changed

+161
-25
lines changed

2 files changed

+161
-25
lines changed

packages/otel-web/src/HyperDXFetchInstrumentation.ts

Lines changed: 127 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,122 @@ import {
44
} from '@opentelemetry/instrumentation-fetch';
55

66
import { captureTraceParent } from './servertiming';
7-
import { headerCapture } from './utils';
7+
import { RedactableKey, headerCapture, shouldRedactKey } from './utils';
88

99
export type HyperDXFetchInstrumentationConfig = FetchInstrumentationConfig & {
1010
advancedNetworkCapture?: () => boolean;
11+
redactKeys?: {
12+
headers?: RedactableKey[];
13+
body?: RedactableKey[];
14+
};
1115
};
1216

17+
function redactValue(): string {
18+
return '[REDACTED]';
19+
}
20+
21+
function redactObject(obj: any, redactConfig: RedactableKey[] | undefined) {
22+
if (!redactConfig || !obj || typeof obj !== 'object') {
23+
return obj;
24+
}
25+
26+
if (Array.isArray(obj)) {
27+
return obj.map((item) => redactObject(item, redactConfig));
28+
}
29+
30+
const result: any = {};
31+
for (const [key, value] of Object.entries(obj)) {
32+
if (shouldRedactKey(key, redactConfig)) {
33+
result[key] = redactValue();
34+
} else if (value && typeof value === 'object') {
35+
result[key] = redactObject(value, redactConfig);
36+
} else {
37+
result[key] = value;
38+
}
39+
}
40+
return result;
41+
}
42+
43+
function redactFormData(
44+
formData: FormData,
45+
redactConfig: RedactableKey[] | undefined,
46+
): string {
47+
if (!redactConfig) {
48+
return formData.toString();
49+
}
50+
51+
const entries: Array<[string, string]> = [];
52+
formData.forEach((value, key) => {
53+
if (shouldRedactKey(key, redactConfig)) {
54+
entries.push([key, redactValue()]);
55+
} else {
56+
entries.push([key, value.toString()]);
57+
}
58+
});
59+
60+
return JSON.stringify(Object.fromEntries(entries));
61+
}
62+
63+
function redactURLSearchParams(
64+
params: URLSearchParams,
65+
redactConfig: RedactableKey[] | undefined,
66+
): string {
67+
if (!redactConfig) {
68+
return params.toString();
69+
}
70+
71+
const newParams = new URLSearchParams();
72+
params.forEach((value, key) => {
73+
if (shouldRedactKey(key, redactConfig)) {
74+
newParams.set(key, redactValue());
75+
} else {
76+
newParams.set(key, value);
77+
}
78+
});
79+
80+
return newParams.toString();
81+
}
82+
83+
function redactRequestBody(
84+
body: ReadableStream<Uint8Array> | BodyInit,
85+
redactConfig: RedactableKey[] | undefined,
86+
): string {
87+
if (!body) return '';
88+
89+
// Maintain backward compatibility with ReadableStream
90+
if (body instanceof ReadableStream) {
91+
return '[ReadableStream]';
92+
}
93+
94+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
95+
return redactFormData(body, redactConfig);
96+
}
97+
98+
if (
99+
typeof URLSearchParams !== 'undefined' &&
100+
body instanceof URLSearchParams
101+
) {
102+
return redactURLSearchParams(body, redactConfig);
103+
}
104+
105+
if (typeof body === 'string') {
106+
if (!redactConfig) {
107+
return body;
108+
}
109+
110+
try {
111+
const parsed = JSON.parse(body);
112+
const redacted = redactObject(parsed, redactConfig);
113+
return JSON.stringify(redacted);
114+
} catch {
115+
// Not JSON, return as-is
116+
return body;
117+
}
118+
}
119+
120+
return body.toString();
121+
}
122+
13123
// not used yet
14124
async function readStream(stream: ReadableStream): Promise<string> {
15125
const chunks: string[] = [];
@@ -43,21 +153,18 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
43153

44154
if (config.advancedNetworkCapture?.() && span) {
45155
if (request.headers) {
46-
headerCapture('request', Object.keys(request.headers))(
47-
span,
48-
(header) => request.headers?.[header],
49-
);
156+
headerCapture(
157+
'request',
158+
Object.keys(request.headers),
159+
config.redactKeys?.headers,
160+
)(span, (header) => request.headers?.[header]);
50161
}
51162
if (request.body) {
52-
if (request.body instanceof ReadableStream) {
53-
span.setAttribute('http.request.body', '[ReadableStream]');
54-
// FIXME: This is not working yet
55-
// readStream(request.body).then((body) => {
56-
// span.setAttribute('http.request.body', body);
57-
// });
58-
} else {
59-
span.setAttribute('http.request.body', request.body.toString());
60-
}
163+
const redactedBody = redactRequestBody(
164+
request.body,
165+
config.redactKeys?.body,
166+
);
167+
span.setAttribute('http.request.body', redactedBody);
61168
}
62169

63170
if (response instanceof Response) {
@@ -66,15 +173,17 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
66173
response.headers.forEach((value, name) => {
67174
headerNames.push(name);
68175
});
69-
headerCapture('response', headerNames)(
70-
span,
71-
(header) => response.headers.get(header) ?? '',
72-
);
176+
headerCapture(
177+
'response',
178+
headerNames,
179+
config.redactKeys?.headers,
180+
)(span, (header) => response.headers.get(header) ?? '');
73181
}
74182
response
75183
.clone()
76184
.text()
77185
.then((body) => {
186+
// TODO: redact response body
78187
span.setAttribute('http.response.body', body);
79188
})
80189
.catch(() => {

packages/otel-web/src/utils.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@ export function waitForGlobal(
196196
}
197197

198198
// https://github.com/open-telemetry/opentelemetry-js/blob/b400c2e5d9729c3528482781a93393602dc6dc9f/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts#L573
199-
export function headerCapture(type: 'request' | 'response', headers: string[]) {
199+
export function headerCapture(
200+
type: 'request' | 'response',
201+
headers: string[],
202+
redactConfig: RedactableKey[] | undefined = undefined,
203+
) {
200204
const normalizedHeaders = new Map(
201205
headers.map((header) => [header, header.toLowerCase().replace(/-/g, '_')]),
202206
);
@@ -213,14 +217,37 @@ export function headerCapture(type: 'request' | 'response', headers: string[]) {
213217
}
214218

215219
const key = `http.${type}.header.${normalizedHeader}`;
216-
217-
if (typeof value === 'string') {
218-
span.setAttribute(key, [value]);
219-
} else if (Array.isArray(value)) {
220-
span.setAttribute(key, value);
220+
// normalized_header uses "_" instead of "-" but the ui shows "-"?
221+
const maybeRedactedValue = shouldRedactKey(normalizedHeader, redactConfig)
222+
? '[REDACTED]'
223+
: value;
224+
225+
if (typeof maybeRedactedValue === 'string') {
226+
span.setAttribute(key, [maybeRedactedValue]);
227+
} else if (Array.isArray(maybeRedactedValue)) {
228+
span.setAttribute(key, maybeRedactedValue);
221229
} else {
222-
span.setAttribute(key, [value]);
230+
span.setAttribute(key, [maybeRedactedValue]);
223231
}
224232
}
225233
};
226234
}
235+
236+
export type RedactableKey = string | RegExp;
237+
238+
export function shouldRedactKey(
239+
key: string,
240+
redactConfig: RedactableKey[] | undefined,
241+
): boolean {
242+
if (!redactConfig) return false;
243+
244+
return redactConfig.some((pattern) => {
245+
if (typeof pattern === 'string') {
246+
return key === pattern;
247+
}
248+
if (pattern instanceof RegExp) {
249+
return pattern.test(key);
250+
}
251+
return false;
252+
});
253+
}

0 commit comments

Comments
 (0)