Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/mask-network-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hyperdx/browser': minor
'@hyperdx/otel-web': patch
---

Browser SDK: support masking sensitive fields in captured request/response
headers and bodies before telemetry leaves the client. Add a `maskFields`
option to `HyperDX.init`. Header matches are case-insensitive; body matches
traverse nested JSON objects and accept dotted paths (e.g.
`creditCard.number`). Matched values are replaced with `***`.
89 changes: 89 additions & 0 deletions .opencode/commands/do-linear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
description:
Fetch a Linear ticket, implement the fix/feature, test, commit, push, and
raise a PR
---

Look up the Linear ticket $ARGUMENTS. Read the ticket description, comments, and
any linked resources thoroughly.

## Phase 1: Understand the Ticket

- Summarize the ticket — what is being asked for (bug fix, feature, refactor,
etc.)
- Identify acceptance criteria or expected behavior from the description
- Note any linked issues, related tickets, or dependencies
- Identify which package(s) under `packages/` are likely affected (e.g.
`node-opentelemetry`, `browser`, `instrumentation-exception`)

If the ticket description is too vague or lacks enough information to proceed
confidently, **stop and ask me for clarification** before writing any code.
Explain exactly what information is missing and what assumptions you would need
to make.

## Phase 2: Plan and Implement

Before writing code, read `AGENTS.md` at the repo root to understand the
monorepo layout, build tooling (Yarn workspaces + Nx), and code style
conventions (Prettier, ESLint, `simple-import-sort`, naming).

1. Explore the codebase to understand the relevant code paths and existing
patterns. Use `npx nx graph` or inspect `packages/*/package.json` to
understand inter-package dependencies when changes span packages.
2. Create an implementation plan — which package(s) and files to change, what
approach to take
3. Implement the fix or feature following existing codebase patterns:
- Single quotes, trailing commas, semicolons (Prettier)
- Sorted imports (`simple-import-sort`): external packages first, then
relative imports separated by a blank line
- Use `import type { ... }` for type-only imports
- Prefer named exports
- Use `diag.error/debug` from `@opentelemetry/api` for OTel-internal errors,
`console.warn` for user-facing warnings
4. Keep changes minimal and focused on the ticket scope
5. If the change is user-facing or modifies a published package, add a
changeset: `yarn changeset` and commit the generated file under
`.changeset/`

## Phase 3: Verify

Run lint and type checks, then run the appropriate tests based on which
packages were modified. Nx will only re-run affected targets when caching is
warm, so prefer `nx affected` for speed on large changes.

1. Run `yarn ci:build` to verify all packages build (respects topological
order via Nx)
2. Run `yarn ci:lint` to verify ESLint + `tsc --noEmit` pass across the
workspace
3. Run `yarn ci:unit` to verify unit tests pass across all packages

For a single package, run targeted commands instead:

```bash
cd packages/<pkg> && npx jest
cd packages/<pkg> && npx jest --testPathPattern="<file>"
```

Note: `otel-web` uses Karma + Mocha (not Jest) — see its `package.json` for
`test:unit:ci-node` and `test:unit:ci`.
4. If any checks fail, fix the issues and re-run until everything passes

## Phase 4: Commit, Push, and Open PR

1. Create a new branch named `<current-user>/$ARGUMENTS-<short-description>`.
Use the current git/OS username when available, and use `whoami` as a
fallback to determine the prefix (e.g.
`warren/HDX-1234-fix-winston-transport`)
2. Commit the changes using conventional commit format (`feat:`, `fix:`,
`chore:`, `refactor:`, `docs:`) and reference the ticket ID. The
pre-commit hook (`husky` + `lint-staged`) will auto-run `prettier --write`
and `eslint --fix` on staged `.ts`/`.tsx` files.
3. Push the branch to the remote
4. Open a draft pull request with:
- Title: `[$ARGUMENTS] <description>`. If multiple tickets are being
addressed, omit the arguments from the title.
- Body: Include a summary of the change, which package(s) were modified,
testing notes, and a link to the Linear ticket. Mention whether a
changeset was added (and the bump type) if the change touches a
published package.
- Label: Attach the `ai-generated` label
25 changes: 25 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ HyperDX.init({
- `consoleCapture` - (Optional) Capture all console logs (default `false`).
- `advancedNetworkCapture` - (Optional) Capture full request/response headers
and bodies (default false).
- `maskFields` - (Optional) Field names to mask in captured request/response
headers and bodies before telemetry leaves the browser. Only applies when
`advancedNetworkCapture` is enabled.
- **Headers**: case-insensitive name match. `'authorization'` matches the
`Authorization` header.
- **Body**: path-exact match against JSON request/response bodies, using
dotted-path notation (e.g. `creditCard.number`). `'password'` only
matches a top-level `password` field, not a nested `user.password` —
supply the full path for nested fields. Array elements can be addressed
via bracket notation (e.g. `users[0].password`). Body matching is
case-sensitive (JSON object keys are case-sensitive by spec). Non-JSON
request/response bodies are passed through unchanged.

Matched values are replaced with `***`. Example:
```js
HyperDX.init({
apiKey: '<YOUR_API_KEY_HERE>',
service: 'my-frontend-app',
advancedNetworkCapture: true,
maskFields: {
headers: ['authorization', 'x-api-key'],
body: ['password', 'creditCard.number', 'user.ssn'],
},
});
```
- `url` - (Optional) The OpenTelemetry collector URL, only needed for
self-hosted instances.
- `maskAllInputs` - (Optional) Whether to mask all input fields in session
Expand Down
23 changes: 23 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ type ErrorBoundaryComponent = any; // TODO: Define ErrorBoundary type
type Instrumentations = RumOtelWebConfig['instrumentations'];
type IgnoreUrls = RumOtelWebConfig['ignoreUrls'];

/**
* Sensitive field names to mask in captured network telemetry. Header field
* matches are case-insensitive. Body fields support dotted paths to address
* nested object properties (e.g. `creditCard.number`). Masking is applied
* before any data leaves the browser.
*/
export type MaskFields = {
headers?: string[];
body?: string[];
};

type BrowserSDKConfig = {
advancedNetworkCapture?: boolean;
apiKey: string;
Expand All @@ -28,6 +39,13 @@ type BrowserSDKConfig = {
maskAllInputs?: boolean;
maskAllText?: boolean;
maskClass?: string;
/**
* Sensitive field names to mask in captured request/response headers and
* bodies before telemetry leaves the browser. Only applies when
* `advancedNetworkCapture` is enabled. Matched values are replaced with
* `'***'`.
*/
maskFields?: MaskFields;
recordCanvas?: boolean;
sampling?: RumRecorderConfig['sampling'];
service: string;
Expand All @@ -47,6 +65,7 @@ function hasWindow() {

class Browser {
private _advancedNetworkCapture = false;
private _maskFields: MaskFields | undefined;

init({
advancedNetworkCapture = false,
Expand All @@ -63,6 +82,7 @@ class Browser {
maskAllInputs = true,
maskAllText = false,
maskClass,
maskFields,
recordCanvas = false,
sampling,
service,
Expand Down Expand Up @@ -93,6 +113,7 @@ class Browser {
const resolvedLogsUrl = logsUrl ?? `${urlBase}/v1/logs`;

this._advancedNetworkCapture = advancedNetworkCapture;
this._maskFields = maskFields;

Rum.init({
debug,
Expand All @@ -112,6 +133,7 @@ class Browser {
}
: {}),
advancedNetworkCapture: () => this._advancedNetworkCapture,
maskFields: () => this._maskFields,
},
xhr: {
...(tracePropagationTargets != null
Expand All @@ -120,6 +142,7 @@ class Browser {
}
: {}),
advancedNetworkCapture: () => this._advancedNetworkCapture,
maskFields: () => this._maskFields,
},
...instrumentations,
},
Expand Down
29 changes: 18 additions & 11 deletions packages/otel-web/src/HyperDXFetchInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
} from '@opentelemetry/instrumentation-fetch';

import { captureTraceParent } from './servertiming';
import { headerCapture } from './utils';
import { headerCapture, maskBody, MaskFieldsConfig } from './utils';

export type HyperDXFetchInstrumentationConfig = FetchInstrumentationConfig & {
advancedNetworkCapture?: () => boolean;
maskFields?: () => MaskFieldsConfig | undefined;
};

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

if (config.advancedNetworkCapture?.() && span) {
const maskFields = config.maskFields?.();

if (request.headers) {
headerCapture('request', Object.keys(request.headers))(
span,
(header) => request.headers?.[header],
);
headerCapture('request', Object.keys(request.headers), {
maskFields: maskFields?.headers,
})(span, (header) => request.headers?.[header]);
}
if (request.body) {
if (request.body instanceof ReadableStream) {
Expand All @@ -56,7 +58,10 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
// span.setAttribute('http.request.body', body);
// });
} else {
span.setAttribute('http.request.body', request.body.toString());
span.setAttribute(
'http.request.body',
maskBody(request.body, maskFields?.body),
);
}
}

Expand All @@ -66,16 +71,18 @@ export class HyperDXFetchInstrumentation extends FetchInstrumentation {
response.headers.forEach((value, name) => {
headerNames.push(name);
});
headerCapture('response', headerNames)(
span,
(header) => response.headers.get(header) ?? '',
);
headerCapture('response', headerNames, {
maskFields: maskFields?.headers,
})(span, (header) => response.headers.get(header) ?? '');
}
response
.clone()
.text()
.then((body) => {
span.setAttribute('http.response.body', body);
span.setAttribute(
'http.response.body',
maskBody(body, maskFields?.body),
);
})
.catch(() => {
// Ignore
Expand Down
26 changes: 18 additions & 8 deletions packages/otel-web/src/HyperDXXMLHttpRequestInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@opentelemetry/instrumentation-xml-http-request';

import { captureTraceParent } from './servertiming';
import { headerCapture } from './utils';
import { headerCapture, maskBody, MaskFieldsConfig } from './utils';

type ExposedSuper = {
_addResourceObserver: (xhr: XMLHttpRequest, spanUrl: string) => void;
Expand All @@ -20,6 +20,7 @@ type ExposedSuper = {
export type HyperDXXMLHttpRequestInstrumentationConfig =
XMLHttpRequestInstrumentationConfig & {
advancedNetworkCapture?: () => boolean;
maskFields?: () => MaskFieldsConfig | undefined;
};

export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrumentation {
Expand All @@ -36,17 +37,24 @@ export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrume
if (span) {
if (config.advancedNetworkCapture?.()) {
xhr.addEventListener('readystatechange', function () {
const maskFields = config.maskFields?.();

if (xhr.readyState === xhr.OPENED) {
shimmer.wrap(xhr, 'setRequestHeader', (original) => {
return function (header, value) {
headerCapture('request', [header])(span, () => value);
headerCapture('request', [header], {
maskFields: maskFields?.headers,
})(span, () => value);
return original.apply(this, arguments);
};
});
shimmer.wrap(xhr, 'send', (original) => {
return function (body) {
if (body) {
span.setAttribute('http.request.body', body.toString());
span.setAttribute(
'http.request.body',
maskBody(body, maskFields?.body),
);
}
return original.apply(this, arguments);
};
Expand All @@ -62,12 +70,14 @@ export class HyperDXXMLHttpRequestInstrumentation extends XMLHttpRequestInstrume
}
return result;
}, {});
headerCapture('response', Object.keys(headers))(
span,
(header) => headers[header],
);
headerCapture('response', Object.keys(headers), {
maskFields: maskFields?.headers,
})(span, (header) => headers[header]);
try {
span.setAttribute('http.response.body', xhr.responseText);
span.setAttribute(
'http.response.body',
maskBody(xhr.responseText, maskFields?.body),
);
} catch (e) {
// ignore (DOMException if responseType is not the empty string or "text")
}
Expand Down
Loading
Loading