Skip to content

Commit 50004d1

Browse files
committed
feat(browser): mask sensitive request/response fields before export
Add a maskFields option to HyperDX.init in the Browser SDK. When advancedNetworkCapture is enabled, matching headers and JSON body fields are replaced with '***' before they are recorded as span attributes, so sensitive data never leaves the client. Header matches are case-insensitive. Body matches walk through nested JSON objects and accept dotted paths (e.g. 'creditCard.number'). Non-JSON request/response bodies are passed through unchanged. Drive-by: switch headerCapture from for...of over a Map to Map.forEach so the function is exercisable by the node-mocha test runner, which currently resolves an ES5-targeted ts-node config and silently no-ops Map iterators without downlevelIteration. Production behaviour is identical. Refs HDX-4127
1 parent f981535 commit 50004d1

8 files changed

Lines changed: 386 additions & 25 deletions

File tree

.changeset/mask-network-fields.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@hyperdx/browser': minor
3+
'@hyperdx/otel-web': patch
4+
---
5+
6+
Browser SDK: support masking sensitive fields in captured request/response
7+
headers and bodies before telemetry leaves the client. Add a `maskFields`
8+
option to `HyperDX.init`. Header matches are case-insensitive; body matches
9+
traverse nested JSON objects and accept dotted paths (e.g.
10+
`creditCard.number`). Matched values are replaced with `***`.

packages/browser/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ HyperDX.init({
3535
- `consoleCapture` - (Optional) Capture all console logs (default `false`).
3636
- `advancedNetworkCapture` - (Optional) Capture full request/response headers
3737
and bodies (default false).
38+
- `maskFields` - (Optional) Field names to mask in captured request/response
39+
headers and bodies before telemetry leaves the browser. Only applies when
40+
`advancedNetworkCapture` is enabled. Header matches are case-insensitive.
41+
Body matches walk through nested JSON objects and accept dotted paths to
42+
target nested properties (e.g. `creditCard.number`). Non-JSON request/
43+
response bodies are passed through unchanged. Matched values are replaced
44+
with `***`. Example:
45+
```js
46+
HyperDX.init({
47+
apiKey: '<YOUR_API_KEY_HERE>',
48+
service: 'my-frontend-app',
49+
advancedNetworkCapture: true,
50+
maskFields: {
51+
headers: ['authorization', 'x-api-key'],
52+
body: ['password', 'creditCard.number'],
53+
},
54+
});
55+
```
3856
- `url` - (Optional) The OpenTelemetry collector URL, only needed for
3957
self-hosted instances.
4058
- `maskAllInputs` - (Optional) Whether to mask all input fields in session

packages/browser/src/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ type ErrorBoundaryComponent = any; // TODO: Define ErrorBoundary type
1313
type Instrumentations = RumOtelWebConfig['instrumentations'];
1414
type IgnoreUrls = RumOtelWebConfig['ignoreUrls'];
1515

16+
/**
17+
* Sensitive field names to mask in captured network telemetry. Header field
18+
* matches are case-insensitive. Body fields support dotted paths to address
19+
* nested object properties (e.g. `creditCard.number`). Masking is applied
20+
* before any data leaves the browser.
21+
*/
22+
export type MaskFields = {
23+
headers?: string[];
24+
body?: string[];
25+
};
26+
1627
type BrowserSDKConfig = {
1728
advancedNetworkCapture?: boolean;
1829
apiKey: string;
@@ -28,6 +39,13 @@ type BrowserSDKConfig = {
2839
maskAllInputs?: boolean;
2940
maskAllText?: boolean;
3041
maskClass?: string;
42+
/**
43+
* Sensitive field names to mask in captured request/response headers and
44+
* bodies before telemetry leaves the browser. Only applies when
45+
* `advancedNetworkCapture` is enabled. Matched values are replaced with
46+
* `'***'`.
47+
*/
48+
maskFields?: MaskFields;
3149
recordCanvas?: boolean;
3250
sampling?: RumRecorderConfig['sampling'];
3351
service: string;
@@ -47,6 +65,7 @@ function hasWindow() {
4765

4866
class Browser {
4967
private _advancedNetworkCapture = false;
68+
private _maskFields: MaskFields | undefined;
5069

5170
init({
5271
advancedNetworkCapture = false,
@@ -63,6 +82,7 @@ class Browser {
6382
maskAllInputs = true,
6483
maskAllText = false,
6584
maskClass,
85+
maskFields,
6686
recordCanvas = false,
6787
sampling,
6888
service,
@@ -93,6 +113,7 @@ class Browser {
93113
const resolvedLogsUrl = logsUrl ?? `${urlBase}/v1/logs`;
94114

95115
this._advancedNetworkCapture = advancedNetworkCapture;
116+
this._maskFields = maskFields;
96117

97118
Rum.init({
98119
debug,
@@ -112,6 +133,7 @@ class Browser {
112133
}
113134
: {}),
114135
advancedNetworkCapture: () => this._advancedNetworkCapture,
136+
maskFields: () => this._maskFields,
115137
},
116138
xhr: {
117139
...(tracePropagationTargets != null
@@ -120,6 +142,7 @@ class Browser {
120142
}
121143
: {}),
122144
advancedNetworkCapture: () => this._advancedNetworkCapture,
145+
maskFields: () => this._maskFields,
123146
},
124147
...instrumentations,
125148
},

packages/otel-web/src/HyperDXFetchInstrumentation.ts

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

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

99
export type HyperDXFetchInstrumentationConfig = FetchInstrumentationConfig & {
1010
advancedNetworkCapture?: () => boolean;
11+
maskFields?: () => MaskFieldsConfig | undefined;
1112
};
1213

1314
// not used yet
@@ -42,11 +43,12 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
4243
span.setAttribute('component', 'fetch');
4344

4445
if (config.advancedNetworkCapture?.() && span) {
46+
const maskFields = config.maskFields?.();
47+
4548
if (request.headers) {
46-
headerCapture('request', Object.keys(request.headers))(
47-
span,
48-
(header) => request.headers?.[header],
49-
);
49+
headerCapture('request', Object.keys(request.headers), {
50+
maskFields: maskFields?.headers,
51+
})(span, (header) => request.headers?.[header]);
5052
}
5153
if (request.body) {
5254
if (request.body instanceof ReadableStream) {
@@ -56,7 +58,10 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
5658
// span.setAttribute('http.request.body', body);
5759
// });
5860
} else {
59-
span.setAttribute('http.request.body', request.body.toString());
61+
span.setAttribute(
62+
'http.request.body',
63+
maskBody(request.body, maskFields?.body),
64+
);
6065
}
6166
}
6267

@@ -66,16 +71,18 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
6671
response.headers.forEach((value, name) => {
6772
headerNames.push(name);
6873
});
69-
headerCapture('response', headerNames)(
70-
span,
71-
(header) => response.headers.get(header) ?? '',
72-
);
74+
headerCapture('response', headerNames, {
75+
maskFields: maskFields?.headers,
76+
})(span, (header) => response.headers.get(header) ?? '');
7377
}
7478
response
7579
.clone()
7680
.text()
7781
.then((body) => {
78-
span.setAttribute('http.response.body', body);
82+
span.setAttribute(
83+
'http.response.body',
84+
maskBody(body, maskFields?.body),
85+
);
7986
})
8087
.catch(() => {
8188
// Ignore

packages/otel-web/src/HyperDXXMLHttpRequestInstrumentation.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '@opentelemetry/instrumentation-xml-http-request';
77

88
import { captureTraceParent } from './servertiming';
9-
import { headerCapture } from './utils';
9+
import { headerCapture, maskBody, MaskFieldsConfig } from './utils';
1010

1111
type ExposedSuper = {
1212
_addResourceObserver: (xhr: XMLHttpRequest, spanUrl: string) => void;
@@ -20,6 +20,7 @@ type ExposedSuper = {
2020
export type HyperDXXMLHttpRequestInstrumentationConfig =
2121
XMLHttpRequestInstrumentationConfig & {
2222
advancedNetworkCapture?: () => boolean;
23+
maskFields?: () => MaskFieldsConfig | undefined;
2324
};
2425

2526
export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrumentation {
@@ -36,17 +37,24 @@ export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrume
3637
if (span) {
3738
if (config.advancedNetworkCapture?.()) {
3839
xhr.addEventListener('readystatechange', function () {
40+
const maskFields = config.maskFields?.();
41+
3942
if (xhr.readyState === xhr.OPENED) {
4043
shimmer.wrap(xhr, 'setRequestHeader', (original) => {
4144
return function (header, value) {
42-
headerCapture('request', [header])(span, () => value);
45+
headerCapture('request', [header], {
46+
maskFields: maskFields?.headers,
47+
})(span, () => value);
4348
return original.apply(this, arguments);
4449
};
4550
});
4651
shimmer.wrap(xhr, 'send', (original) => {
4752
return function (body) {
4853
if (body) {
49-
span.setAttribute('http.request.body', body.toString());
54+
span.setAttribute(
55+
'http.request.body',
56+
maskBody(body, maskFields?.body),
57+
);
5058
}
5159
return original.apply(this, arguments);
5260
};
@@ -62,12 +70,14 @@ export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrume
6270
}
6371
return result;
6472
}, {});
65-
headerCapture('response', Object.keys(headers))(
66-
span,
67-
(header) => headers[header],
68-
);
73+
headerCapture('response', Object.keys(headers), {
74+
maskFields: maskFields?.headers,
75+
})(span, (header) => headers[header]);
6976
try {
70-
span.setAttribute('http.response.body', xhr.responseText);
77+
span.setAttribute(
78+
'http.response.body',
79+
maskBody(xhr.responseText, maskFields?.body),
80+
);
7181
} catch (e) {
7282
// ignore (DOMException if responseType is not the empty string or "text")
7383
}

0 commit comments

Comments
 (0)