Skip to content

[HDX-4127] Browser SDK: mask request/response headers and bodies#239

Open
wrn14897 wants to merge 4 commits intomainfrom
warren/HDX-4127
Open

[HDX-4127] Browser SDK: mask request/response headers and bodies#239
wrn14897 wants to merge 4 commits intomainfrom
warren/HDX-4127

Conversation

@wrn14897
Copy link
Copy Markdown
Member

@wrn14897 wrn14897 commented May 4, 2026

Summary

Add support to the Browser SDK for masking sensitive fields in captured HTTP
request/response headers and bodies before any telemetry leaves the
client. Closes HDX-4127 and
addresses upstream hyperdxio/hyperdx-js#199.

API

HyperDX.init({
  apiKey: '<API_KEY>',
  service: 'my-frontend-app',
  advancedNetworkCapture: true,
  maskFields: {
    headers: ['authorization', 'x-api-key'],
    body: ['password', 'creditCard.number'],
  },
});
  • Header matches are case-insensitive.
  • Body matches walk nested JSON objects and accept dotted paths (e.g.
    creditCard.number).
  • Non-JSON request/response bodies are passed through unchanged — masking is
    intentionally conservative to avoid mangling form-encoded or binary
    payloads.
  • Matched values are replaced with '***'. Masking happens before the value
    is set as a span attribute, so sensitive data never leaves the browser.

Why no maskPlaceholder option?

The original ticket proposed a configurable maskPlaceholder, but no
comparable browser RUM SDK exposes one (Sentry and Datadog both rely on
beforeSend-style callbacks for redaction) and the OpenTelemetry HTTP
semantic conventions hardcode REDACTED for sensitive query parameters.
Shipping with a hardcoded '***' keeps the API minimal; the option can be
reintroduced non-breakingly later if a real need surfaces.

Packages touched

  • @hyperdx/otel-web — masking utilities (maskBody, shouldMaskHeader,
    extended headerCapture) and plumbing into
    HyperDXFetchInstrumentation + HyperDXXMLHttpRequestInstrumentation.
  • @hyperdx/browser — exposes maskFields on BrowserSDKConfig and
    forwards it to the fetch/xhr instrumentations.

Changeset: @hyperdx/browser minor, @hyperdx/otel-web patch. The patch
bump on otel-web keeps @hyperdx/otel-web-session-recorder's ^0.16.2
peer-dep range satisfied so it does not get force-bumped to a new major.

Notes

  • Drive-by fix in headerCapture: switched from for...of over the
    internal Map to Map.prototype.forEach so the function is exercisable
    by the node-mocha test runner (test:unit:ci-node), which currently
    resolves an ES5-targeted ts-node config and silently no-ops Map iterators
    without downlevelIteration. Production behaviour is identical.

Testing

  • New mocha specs in packages/otel-web/test/masking.test.ts (17 tests)
    covering header masking, body masking (top-level, nested, dotted paths,
    arrays, non-JSON bodies, non-string bodies) and case-insensitive
    matching.
  • yarn workspace @hyperdx/browser ci:lint — passes (warnings only,
    pre-existing).
  • yarn workspace @hyperdx/browser ci:unit — passes.
  • yarn workspace @hyperdx/browser build — passes.
  • Full yarn ci:unit — passes (5 suites, 27 tests).
  • Full yarn ci:build and yarn ci:lint surface pre-existing failures in
    @hyperdx/instrumentation-exception (Sentry types drift) and
    @hyperdx/deno (deno lint npm: imports), reproducible on main and
    unrelated to this change.

Follow-up work

Tracked in HDX-4148
Unify HTTP capture/mask config across @hyperdx/browser and
@hyperdx/node-opentelemetry. That ticket covers:

  • Standardizing the placeholder (Browser '***' → shared '[Filtered]',
    matching Node and Sentry relay).
  • Shipping a default deny list on Browser to match the 38-entry list
    Node already applies automatically.
  • Exposing requestHook / responseHook on both SDKs as
    user-overridable config (Node has them internally but unsurfaced; Browser
    doesn't have them at all).
  • Migrating Node body masking from whole-body keyword suppression to the
    field-level JSON walk that this PR introduces on Browser, so both SDKs
    share one masking algorithm.

Links

@wrn14897 wrn14897 added the ai-generated Pull request authored with AI assistance label May 4, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 4, 2026

🦋 Changeset detected

Latest commit: 3f08340

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@hyperdx/browser Minor
@hyperdx/otel-web Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@wrn14897 wrn14897 marked this pull request as ready for review May 4, 2026 17:10
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
@wrn14897 wrn14897 force-pushed the warren/HDX-4127 branch from 2e1018d to 50004d1 Compare May 4, 2026 17:47
@dhable dhable self-requested a review May 5, 2026 13:49
@dhable
Copy link
Copy Markdown
Contributor

dhable commented May 5, 2026

It seems like the Unit / main (pull_request) action includes the deno ci:lint target but running that locally I get

❮ yarn ci:lint
yarn run v1.22.22
$ npx nx run-many --target=ci:lint
npm warn Unknown env config "version-git-tag". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "argv". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "version-commit-hooks". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "version-git-message". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown env config "version-tag-prefix". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
npm warn Unknown user config "prefer-symlinked-executables". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.

   ✔  nx run @hyperdx/instrumentation-sentry-node:ci:lint (4s)
   ✔  nx run @hyperdx/instrumentation-exception:ci:lint (4s)
   ✔  nx run @hyperdx/node-opentelemetry:ci:lint (4s)

——————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————
   ✖  nx run @hyperdx/deno:ci:lint
$ deno lint mod.ts
      Warning "overrides" field can only be specified in the workspace root package.json file.
          at file:///Users/dhable/work/hyperdx-js/packages/otel-web/package.json
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
       --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:2:22
        | 
      2 | import * as log from 'https://deno.land/std@0.203.0/log/mod.ts';
        |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
       --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:4:32
        | 
      4 | import type { LogRecord } from 'https://deno.land/std@0.203.0/log/logger.ts';
        |                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
       --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:8:8
        | 
      8 | } from 'https://deno.land/std@0.203.0/log/levels.ts';
        |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:10:38
         | 
      10 | import { SeverityNumber, logs } from 'npm:@opentelemetry/api-logs@0.43.0';
         |                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:11:33
         | 
      11 | import { OTLPLogExporter } from 'npm:@opentelemetry/exporter-logs-otlp-http@0.43.0';
         |                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:12:44
         | 
      12 | import { OTLPExporterNodeConfigBase } from 'npm:@opentelemetry/otlp-exporter-base@0.43.0';
         |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:20:8
         | 
      20 | } from 'npm:@opentelemetry/resources@1.17.0';
         |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:26:8
         | 
      26 | } from 'npm:@opentelemetry/sdk-logs@0.43.0';
         |        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:28:29
         | 
      28 | import type { Logger } from 'npm:@opentelemetry/api-logs@0.43.0';
         |                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      error[no-import-prefix]: Inline 'npm:', 'jsr:' or 'https:' dependency not allowed
        --> /Users/dhable/work/hyperdx-js/packages/deno/mod.ts:29:33
         | 
      29 | import type { Attributes } from 'npm:@opentelemetry/api@1.6.0';
         |                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
         = hint: Add it as a dependency in a deno.json or package.json instead and reference it here via its bare specifier
      
        docs: https://docs.deno.com/lint/rules/no-import-prefix
      
      
      Found 10 problems
      Checked 1 file
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
      
      
   ✔  nx run @hyperdx/browser:ci:lint (2s)
   ✔  nx run @hyperdx/node-logger:ci:lint (3s)

——————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

 NX   Ran target ci:lint for 6 projects (7s)

   ✔  5/6 succeeded [0 read from cache]

   ✖  1/6 targets failed, including the following:

      - nx run @hyperdx/deno:ci:lint

error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

Maybe a deno version mismatch?

Comment thread packages/otel-web/src/utils.ts Outdated
body: unknown,
fieldsToMask: string[] | undefined,
): string {
const original = typeof body === 'string' ? body : jsonToString(body);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems odd to convert an object into a string, only to parse it back into an object. Couldn't we just do

let parsed: unknown;
try {
   parsed = typeof body === 'string' ? JSON.parse(body) : body;
} catch {
   return jsonToString(body);
}

and then eliminate line 244? It would skip one stringify call cycle on every call to maskBody.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in abb4e33. The new maskBody skips jsonToString when body is already an object and only calls JSON.parse on string bodies, so the redundant round-trip is gone.

Comment thread packages/otel-web/src/utils.ts Outdated
try {
return JSON.stringify(masked);
} catch {
return original;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the catch behavior is the same for both, why not expand the try { ... } block to cover everything from line 250 through 262. Unless we specifically want exceptions from maskJsonValue to not be caught.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in abb4e33. The two try/catch blocks are collapsed into one wider block covering parse → clone → mask → stringify. Any failure now uniformly falls back to the original body.

Comment thread packages/otel-web/src/utils.ts Outdated
const lowerPath = path.toLowerCase();
for (const field of fieldsToMask) {
const lowerField = field.toLowerCase();
if (lowerField === lowerPath || lowerField === lowerKey) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not understanding why we need to check both the path and the key here. In fact it seems that including the key in the or is problematic.

Case 1: No nesting

fieldsToMask = ['myField']
inputObject = {
   "myField": "xyz"
}

This correctly results in a call of matchesField("myField", "myField", fieldsToMask). The second equality check is redundant since both the path and the key are the same value.

Case 2: Nesting

fieldsToMask = ['inner.myField']
inputObject = {
   "inner": {
      "myField": "xyz"
   }
}

This correctly results in a call of matchesField("myField", "inner.myField", fieldsToMask). In this kind of nested setup, the key will never match.

Case 3: Nesting with multiple fields

fieldsToMask = ['myField']
inputObject = {
   "myField": "abc",
   "inner": {
      "myField": "xyz"
   }
}

This is going to have two calls here

matchesField("myField", "myField", fieldsToMask) is going to correctly match and mask the field.

matchesField("myField", "inner.myField", fieldsToMask) is where the implementation seems broken. Since the key is going to match a value in fieldsToMask, it will become masked even though the config did not include the nested path.

This could be fine if we allowed something with a leading dot to anchor to the root, like .myField. But we set currentPath to an empty string and then line 287 will never add a dot prefix when traversing. I think we either need to just match on the path or use a default starting value of '.' in order to handle root anchoring. There could be situations where ambiguous field names should be redacted based on context (e.g. nesting level).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this was inconsistent with the dotted-path docs. Fixed in abb4e33: body matching is now path-exact via lodash.has / lodash.set. A bare key like 'token' only matches a root-level token; nested fields require their full path (e.g. inner.token). Body matching also became case-sensitive as a side effect — JSON object keys are case-sensitive by spec, so this is more correct anyway. Header matching stays case-insensitive. README and JSDoc updated to make the contract explicit, and a new test asserts the Case 3 expectation (nested token is left alone when only 'token' is configured).

One ergonomic regression worth flagging: under the new semantics, masking the same field at multiple paths/array indices requires enumerating each path. Wildcards (e.g. **.password) are tracked as part of the broader unification effort in HDX-4148 alongside the requestHook/responseHook escape hatch.

assert.deepStrictEqual(span.attributes['http.request.header.authorization'], [
DEFAULT_MASK_PLACEHOLDER,
]);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably needs a test case to handle a request where there are multiple headers and the values would be an array. There's code to mask each array value but nothing to validate it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in abb4e33: new test 'masks each element when the header value is an array' passes getHeader returning a string[] for a set-cookie-style header and asserts every element is replaced with the placeholder.

wrn14897 added 2 commits May 6, 2026 17:52
- Switch body field matching from bare-key-or-path to path-exact via
  lodash.has/lodash.set. Bare keys like 'password' now only match
  root-level fields; nested fields require their full dotted path.
  Aligns the JSDoc and README with the actual behavior.
- Skip the redundant stringify->parse round-trip in maskBody when the
  body is already an object.
- Collapse the two try/catch blocks in maskBody into one wider try so
  a fault in cloneDeep/has/set or stringify falls back to the original
  body uniformly.
- Body matching becomes case-sensitive (JSON object keys are
  case-sensitive by spec). Headers stay case-insensitive.
- New test exercises array-valued header masking (e.g. set-cookie).
- README updated with header vs. body semantics, indexed-array
  example, case-sensitivity note.

Refs HDX-4127, PR #239 review by @dhable
Modern Chromium routes errors thrown inside setTimeout callbacks and
`<img>` load failures through 'unhandledrejection' rather than the
window 'error' or document 'error' events, depending on async-task
runtime semantics that have shifted across Chrome versions. The
HyperDXErrorInstrumentation captures all three sources equivalently;
only the resulting span name differs.

Loosen the assertions in the 'test error' and 'test unloaded img'
suites so either span name is accepted. The instrumentation behavior
under test is unchanged.

Refs HDX-4127
@wrn14897
Copy link
Copy Markdown
Member Author

wrn14897 commented May 6, 2026

Update: pushed 3f08340 loosening the two karma error-instrumentation tests so they accept either onerror / eventListener.error or unhandledrejection as the captured span name. Modern Chromium routes setTimeout-thrown errors and <img> load failures through unhandledrejection rather than the previously-used event sources; the HyperDXErrorInstrumentation captures all three identically, only the span name differs.

CI is now green: unit run ✓.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-generated Pull request authored with AI assistance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants