@foam-ai/browser
v1.8.1
Published
Foam's browser telemetry companion: a thin, never-throw enrichment layer over the HyperDX browser SDK — automatic web instrumentation (fetch/XHR/WebSocket/socket.io/connectivity/document-load/errors), page & landing analytics, always-on session stitching
Keywords
Readme
@foam-ai/browser
Foam's browser telemetry companion: a thin, never-throw enrichment
layer over the HyperDX browser SDK
(which distributes the OpenTelemetry web
SDK). Automatic web instrumentation (fetch, XHR, WebSocket, socket.io,
connectivity, document-load, user interactions, page & landing
analytics — pageviews, referrer, UTM, time-on-page — errors,
web-vitals), always-on session stitching (session.id on every span +
W3C Baggage to your backends), guaranteed trace-id propagation (the
propagation-only path — ON by default, injects traceparent even with
network capture off), opt-in key redaction, propagation safety, and
session replay with safe masking defaults (shipped, OFF by default,
FDE-activated).
Three guarantees, everywhere:
- Never throws. Every export is wrapped; a telemetry failure can never become an app failure.
- No-ops before/without init. Safe to call from anywhere, in any
environment, including during SSR (
init()itself no-ops without awindow). - An always-on credential floor; everything else FULLY OPT-IN. A
frozen credential/secret denylist (
Authorization,Cookie,x-api-key,password,token, … — see The default credential denylist) is masked to[REDACTED]in every signal, with no off switch. Beyond that floor the package masks NOTHING by default — every other value is captured RAW, including secrets and PII under unlisted names; masking happens only for the keys you list inredactKeys/redactPiiKeys.
npm install @foam-ai/browserQuick start
Initialize once, in browser-only code, before app code runs. enabled
is a build-time decision — wire it to a constant your bundler
inlines. The token is a public, browser-scoped ingest token (it ends
up visible in the shipped bundle — expected; never a server secret).
// Next.js: instrumentation-client.ts (or the first import of your root client component)
import { init } from '@foam-ai/browser';
init({
name: 'web-app',
environment: process.env.NEXT_PUBLIC_APP_ENV!, // 'production' | 'staging' | 'development' | 'test'
enabled: process.env.NEXT_PUBLIC_APP_ENV === 'production',
token: process.env.NEXT_PUBLIC_FOAM_TOKEN!,
version: process.env.NEXT_PUBLIC_GIT_SHA, // strongly recommended
});Scenario matrix
Copy-paste-ready snippets, one per supported situation.
// 1 — NEXT.JS (the common case): instrumentation-client.ts, NEXT_PUBLIC_* inlined at build
init({
name: 'web-app',
environment: process.env.NEXT_PUBLIC_APP_ENV!,
enabled: process.env.NEXT_PUBLIC_APP_ENV === 'production',
token: process.env.NEXT_PUBLIC_FOAM_TOKEN!,
version: process.env.NEXT_PUBLIC_GIT_SHA,
});// 2 — VITE: import.meta.env, VITE_* inlined at build
init({
name: 'web-app',
environment: import.meta.env.VITE_APP_ENV,
enabled: import.meta.env.PROD,
token: import.meta.env.VITE_FOAM_TOKEN,
version: import.meta.env.VITE_GIT_SHA,
});// 3 — RAW WEBPACK/ESBUILD: values injected via DefinePlugin / define
init({
name: 'web-app',
environment: __APP_ENV__,
enabled: __APP_ENV__ === 'production',
token: __FOAM_TOKEN__,
version: __GIT_SHA__,
});// 4 — BESIDE ANOTHER RUM VENDOR (non-OTel — Datadog RUM, FullStory, ...): disjoint pipelines.
// Keep foam's traffic out of their network capture on their side; keep theirs out of foam's here:
init({
name: 'web-app', environment: env, enabled: true, token: TOKEN,
ignoredOutboundHosts: ['browser-intake.vendor.example'],
sessionReplay: false, // leave replay to whichever tool owns it — never record twice
});
// If they must remain the ONLY owner of the network signal, add: networkCapture: 'off'
// (and tracePropagation: false if they must own the outbound request HEADERS too —
// otherwise foam keeps injecting traceparent/baggage, capture stays theirs)// 5 — TENANT RIDES FOAM (a scoped SDK wants span access): constructed instance + REQUIRED ignore entry
import { EvalToolSpanProcessor } from 'eval-tool-sdk';
init({
name: 'web-app', environment: env, enabled: true, token: TOKEN,
additionalSpanProcessors: [new EvalToolSpanProcessor({ project: 'prod' })],
ignoredOutboundHosts: ['ingest.eval-tool.example'], // REQUIRED: never trace the tenant's own export traffic
});// 6 — A FOREIGN OTEL SDK ALREADY OWNS THE PAGE: call init() normally — foam detects it,
// goes inert (never displaces), and warns once naming the owner. Foam is DARK for traces;
// see "Coexistence" below for exactly what that means and what to do.
init({ name: 'web-app', environment: env, enabled: true, token: TOKEN });// 7 — TESTS/CI: fully inert, silent, no token needed (token validates only when enabled)
init({ name: 'web-app', environment: 'test', enabled: false });init(options) — every option
init(options: FoamBrowserInitOptions): void — call once. A second call
warns and is ignored. Never throws. On invalid required options it
prints one [foam]-prefixed console.warn and telemetry stays off —
the app is never affected. Invalid OPTIONAL values warn and fall back
to their safe default.
| Option | Type | Required | Default | What it changes on the wire |
| --- | --- | --- | --- | --- |
| name | string | yes | — | service.name on every span. |
| environment | string | yes | — | deployment.environment.name. Canonical values: production | staging | development | test; anything else warns and is recorded verbatim. |
| enabled | boolean | yes | — | false → fully inert and SILENT (the expected off state; nothing validates, nothing patches, nothing exports). No default: it must be a deliberate, build-time decision. |
| token | string | when enabled | — | The foam ingest token → Authorization: Bearer <token> on every export. Raw value expected; an already-Bearer-prefixed value passes through. Validated only when enabled: true. Browser convention: read it from your bundler's PUBLIC variable — NEXT_PUBLIC_FOAM_TOKEN (Next) / VITE_FOAM_TOKEN (Vite) — the public-scoped sibling of the server's FOAM_OTEL_TOKEN. It ships in the bundle, so use a browser-scoped credential, never the server secret. |
| version | string | no | unset | service.version. Absent → one warning (deploys are much easier to correlate with one). |
| redact | { secrets?: string[], pii?: string[], detect?: string[] } | no | {} | The grouped redaction option — the preferred spelling; every foam SDK carries the same object. secrets = redactKeys semantics (tail mask), pii = redactPiiKeys semantics (full [REDACTED]); each UNIONS with its legacy alias when both are set. detect = the opt-in PII detection tier: entity names exactly email | phone | ssn | credit_card | ip (frozen in contract/pii-detect.json), matched by value SHAPE in free text and masked span-only with typed placeholders ([EMAIL], [PHONE], [SSN], [CREDIT_CARD], [IP]). All fields default empty — with redact absent, behavior is byte-identical to 1.5. Validation posture (browser divergence, deliberate): the server cores THROW at init on a malformed redact (unknown field, non-array value, unknown entity name); the browser never crashes the host page — each malformed piece gets one [foam] warning naming it and is ignored, while the valid remainder still applies. |
| redactKeys | string[] | no | [] | Legacy ALIAS of redact.secrets (keeps working; the lists UNION when both are set). OPT-IN secret masking, ADDITIVE on top of the always-on credential floor and the always-on value-pattern secret layer. Exact key names tail-masked (********f456) in attributes and captured headers/bodies (case-/separator-insensitive) — including the bare header name inside http.*.header.* span attributes. In URL query/fragment params and form-urlencoded bodies a listed name masks its value to the full literal [REDACTED] (a tail there would hand over the secret's last four characters). Default [] = NO masking beyond the floor and the value layer. A floor name listed here stays fully [REDACTED] (the floor wins, never a tail), and a credential-SHAPED value under a listed key redacts in full too (the value layer wins over the tail). |
| redactPiiKeys | string[] | no | [] | Legacy ALIAS of redact.pii (keeps working; the lists UNION when both are set). OPT-IN PII erasure, additive on top of the credential floor. YOUR PII field names, e.g. ['email', 'phone']. Values under listed keys redact in FULL ([REDACTED]) — never a tail. Default [] = NO erasure beyond the floor. Foam ships no PII preset and infers nothing from key names: this list is the entire customer key-based PII set (value-SHAPE detection is the separate, opt-in redact.detect). |
| secretHeuristics | boolean | no | true | The Tier-2 heuristics toggle of the value-pattern secret layer. false disables ONLY the two generic heuristics (keyword+entropy assignment scan, whole-value high-entropy token check) — for apps whose legitimate telemetry collides (e.g. base64 content-addressed ids). The named provider patterns (AWS/GCP/GitHub/Slack/Stripe/JWT/private-key/…) and the credential floor stay always-on; disabling logs one [foam] line so the decision is auditable. |
| diagnostics | boolean | no | false | Verbose [foam] console output + the upstream SDK's debug channel (batch-drop warnings etc.). Never silences failure warnings — those always print. |
| tracePropagationTargets | (string \| RegExp)[] | no | unset | Cross-origin URLs that receive traceparent and the baggage header carrying session.id (session stitching — see below). Same-origin requests get both automatically; cross-origin ONLY when matched here (see CORS below). Gates BOTH propagation paths identically: the capture-coupled one and the propagation-only one (tracePropagation). |
| tracePropagation | boolean | no | true | The propagation-only path — GUARANTEED trace-id propagation. A header-only fetch/XHR wrapper that injects traceparent + the session.id baggage on every same-origin request (and every tracePropagationTargets match) at EVERY networkCapture level, 'off' included. Unlike advanced network capture, the guarantee never depends on a span being recorded or an instrumentation being patched: a request the capture path already stamped passes through untouched (span parentage is never clobbered), and one that would otherwise leave headerless gets the injection — anchored to the live span context, else the CURRENT pageview span's context (the view's backend spans join one REAL exported trace), else a fresh valid W3C context (never fails dark, even if the propagator seam drifts). Captures NOTHING — no spans, no bodies, no timing. The loop guard and ignoredOutboundHosts are always excluded. false = foam leaves outbound request headers entirely alone; combined with networkCapture: 'off' that restores the fully unpatched fetch/XHR posture (and the pre-1.8 loss of browser→backend trace continuity). |
| ignoredOutboundHosts | (string \| RegExp)[] | no | [] | Outbound traffic that produces NO spans. Strings are hosts (every URL on that host); RegExps match the full URL. Foam's own endpoint is always ignored (loop guard) — these extend that list. Required when a tenant or second vendor exports from the page. |
| networkCapture | 'off' \| 'basic' \| 'full' | no | 'basic' | 'off': fetch/XHR left unpatched by the CAPTURE path, and the WebSocket/socket.io instrumentations stay off too (the "another tool owns the network signal" escape hatch covers every network-capture patch). Since 1.8.0, 'off' no longer stops trace-id propagation: the propagation-only path (tracePropagation, ON by default) still injects traceparent/baggage on same-origin requests and allowlist matches — add tracePropagation: false when another tool must own the outbound request headers too, accepting the loss of browser→backend trace continuity. 'basic': a span per request — method, URL (query values masked for credential-floor param names and your listed keys), status, timing — plus WebSocket lifecycle/message spans and socket.io send/receive spans. 'full': adds request headers+bodies and response headers, with the credential floor, the value-pattern secret layer, and your listed redactKeys/redactPiiKeys masked wherever foam can rewrite the value (with no keys listed, 'full' captures unshaped non-floor content raw). Since 1.5.0, upstream-captured XHR REQUEST headers are re-masked at capture time through the full engine (floor + your keys against the bare header name + the value scan), and form-urlencoded bodies get by-key masking — the remaining residual is XHR request bodies foam only masks at the span processor and response bodies (read in a .then that races span end), which SHIP to preserve client signal instead of staying uncaptured; credential-shaped spans on those surfaces are still masked by the value layer at the span processor before export; the FDE clamp is the per-customer valve. Fixed for the page's lifetime — no runtime toggle. |
| consoleCapture | boolean | no | true | console.* as telemetry (spans through the trace pipeline). Default on so browser console errors/warnings reach foam without a second integration — upstream HyperDX defaults this off; foam flips it because console is the browser's native log signal and the package ships no LoggerProvider. Masking: credential-floor names and your listed keys mask inside logged objects, the value-pattern secret layer masks credential-SHAPED spans inside free prose, and opted-in redact.detect entities mask PII-shaped spans; unshaped prose not under a floor/listed key (and not an opted-in detect entity) ships RAW. Set false if your app logs user content you don't want exported. |
| sessionReplay | boolean \| SessionReplayOptions | no | false | rrweb DOM recording — the heaviest feature; opt-in. true enables it with input masking on; an object enables it with explicit masking/sampling controls (below). Leave off if another tool records sessions. |
| additionalSpanProcessors | SpanProcessor[] | no | [] | The tenant seam: each processor sees spans AFTER redaction, wrapped in a fault-isolating guard (a throwing tenant warns once and never breaks the pipeline). Processors receive onEnd/forceFlush/shutdown only — onStart is never forwarded, because the redacted view exists only at span end (creation-time attributes are still raw at onStart). Pair with ignoredOutboundHosts. |
| additionalResourceAttributes | Record<string, string \| number \| boolean> | no | {} | Extra resource attributes on ALL telemetry from the page (deployment/team constants). Values under credential-floor names or your listed redactKeys/redactPiiKeys are masked; foam's identity attributes cannot be overridden (attempts warn and are ignored). |
sessionReplay object form
All masking defaults are safe; you opt OUT, never accidentally in.
| Key | Default | Meaning |
| --- | --- | --- |
| maskAllInputs | true | Every input field's value is masked in the recording. |
| maskAllText | false | Mask ALL text content (heavy hammer; usually prefer maskClass). |
| maskClass | upstream default | CSS class whose elements' text is masked. |
| blockClass | upstream default | CSS class whose elements are not recorded at all. |
| ignoreClass | upstream default | CSS class whose input events are ignored. |
| recordCanvas | false | Record canvas contents (expensive). |
| sampling | upstream default | Event throttles ({ mousemove, mouseInteraction, scroll, media, input }). |
init({
name: 'web-app', environment: env, enabled: true, token: TOKEN,
sessionReplay: { maskAllInputs: true, blockClass: 'no-record', sampling: { mousemove: 50 } },
});Deliberately absent knobs
| Absent | Why |
| --- | --- |
| endpoint / URL option | Telemetry always goes to https://otel.api.foam.ai. This SDK runs in end-user browsers: a configurable endpoint would let injected config redirect user telemetry. There is no env-var override either — see the next table. |
| Sampling knobs (traces) | Foam's platform owns trace-sampling decisions server-side; a browser knob would silently bias the data. (Replay event throttles above are a recording-density control, not trace sampling.) |
| disabledEnvironments | Replaced by the explicit build-time enabled boolean — one deliberate decision instead of a list to keep in sync. |
| Raw instrumentations passthrough | Foam owns the instrumentation config so the loop guard, propagation allowlist, and masked capture can never be disassembled by option drift. The supported controls are networkCapture, tracePropagationTargets, ignoredOutboundHosts. |
| Log/metric options | This package ships traces only (see "What ships and what doesn't"). |
| beforeSend export hook | Intentionally absent, not forgotten. Every foam server SDK ships beforeSend — run your own function over every record right before it leaves the process — but the browser export path (hyperdx) needs its own design before the hook can exist here. Until that design lands, this package deliberately does not wire one. |
| createFoamIngest* entries | Server-core surface, excluded here BY DESIGN: this package is a thin enrichment layer over its foundation SDK — a browser page hosts no foreign OTel server pipeline for foam to tap. The ingest entries live in the server cores (@foam-ai/otel, foam-otel for Python/Ruby/Java). |
Environment variables — the browser posture
This package reads no environment variables: browsers have no
process.env at runtime, so server-side levers like
OTEL_EXPORTER_OTLP_ENDPOINT and FOAM_ENABLED do not exist here.
The equivalents are build-time — your bundler inlines the value into the
shipped bundle:
| Runtime | Mechanism |
| --- | --- |
| Next.js | NEXT_PUBLIC_* vars (anything else compiles to undefined in client code) |
| Vite | import.meta.env.VITE_* / import.meta.env.PROD |
| webpack / esbuild | DefinePlugin / define constants |
The kill switch is enabled wired to such a constant.
Do / Never do
Do
- Wire
enabledto a build-time constant (process.env.NODE_ENV === 'production',import.meta.env.PROD, …) so non-prod builds ship fully inert. - Call
init()once, from browser-only code, before app code that issues fetch/XHR. - List every PII field name in
redactPiiKeys— foam infers nothing. - Allowlist backend origins in
tracePropagationTargetsonly after their CORS preflight permitstraceparentANDbaggage(foam sends both — the baggage carriessession.idfor browser→backend session stitching). - Leave
sessionReplayoff unless you need DOM recordings; when on, keepmaskAllInputs: true(the default).
Never
- Never reuse a server-side secret as
token— it ships in the client bundle by construction. Use a browser-scoped public credential. - Never enable session replay in two tools at once (double recording).
- Never assume anything beyond the credential floor is masked by default: above the frozen floor (see The default credential denylist), redaction is FULLY OPT-IN and key-based — with no
redactKeys/redactPiiKeys, everything else ships RAW (secrets and PII under unlisted names included). List the keys you need masked. - Never call
init()after app code has already run, or more than once (patch-ordering → silent zero coverage; GOTCHAS G1). - Never expect a configurable endpoint or cross-async manual span parenting. And never expect ANY
traceparentinjection once you set BOTH escape hatches —networkCapture: 'off'stops capture, but onlytracePropagation: falsestops the guaranteed propagation-only path.
What data leaves the browser
Exact inventory for privacy/security sign-off. Two transports:
A — Traces → POST https://otel.api.foam.ai/v1/traces (always when active)
| Signal | What is captured |
| --- | --- |
| Document load | Navigation/timing spans from the document-load instrumentation. |
| fetch / XHR | Per-request spans: method, URL (query param values masked for credential-floor names and your listed keys — everything else raw), status, timing. 'full' also adds request headers + string request bodies and response headers, with the credential floor and your listed keys masked where foam can rewrite them. The technically-unmaskable residual — XHR request headers/bodies and response bodies — ships raw to preserve signal (the FDE clamp is the per-customer valve); floor-named headers/fields on those surfaces are still masked at the span processor before export. 'off' captures nothing and injects no traceparent/baggage. |
| WebSocket | Connection/send/message lifecycle spans (connect, error states, URL) — ON by default; off under networkCapture: 'off'. Honors ignoredOutboundHosts and the loop guard. |
| socket.io | Producer/consumer spans per emit/on event (messaging.system: socket.io, namespace, event name) — ON by default when a window.io socket.io client is present (presence-checked; inert otherwise); off under networkCapture: 'off'. |
| Connectivity | connectivity spans on offline→online transitions (online: false/true, offline duration bounded by the timestamps) — ON by default. |
| User interaction | Clicks and similar UI events the foundation instruments. |
| Page & landing analytics | pageview + time-on-page spans — ON by default, raw (UTM/query values masked for credential-floor names and your listed keys). See "Page & landing analytics" below for the exact span shapes. |
| Web vitals | LCP/CLS/etc. as spans. |
| Long tasks | longtask spans for main-thread blocks >50 ms (PerformanceObserver): name, entry type, duration, attribution (container type/src/id/name) — ON by default (foundation default). |
| Post-load resources | resourceFetch spans for resources loaded after the document (scripts/images by default): the full resource URL — RAW; query values masked for credential-floor names and your listed keys — plus network timing. ON by default (foundation default) and a real privacy surface when resource URLs carry tokens/ids in query strings: floor names (token, signature, …) mask automatically; list anything else. Honors ignoredOutboundHosts. |
| Page visibility | visibility spans on every visibilitychange (tab hidden/shown; hidden: true/false) — ON by default (forced on by the pinned wrapper). |
| Console (consoleCapture, default on) | console.* messages as spans; masking is key-based (credential floor + your listed keys inside logged objects), so free-form prose (and anything not under a floor/listed key) ships RAW. |
| Errors | Uncaught errors (window error + unhandledrejection listeners) + recordException — message/stack on the span; free-text status.message/message rides RAW by default (masked only via listed keys). |
| Resource-load errors | Failed <img>/<script>/<link> loads (document capture-phase error listener, part of the errors instrumentation): an error span with target_element/target_xpath/target_src — ON by default. |
| addAction / setGlobalAttributes | Customer events and global attributes; values under credential-floor names or your listed keys are masked, everything else RAW. |
| Session identity | session.id (OTel semconv) on EVERY span, plus session.previous_id after a session rotation — independent of session replay. See "Session stitching" below. |
| Resource identity | service.name, deployment.environment.name, service.version (if set), telemetry.distro.name/version, service.instance.id, plus redacted additionalResourceAttributes. |
B — Session replay → POST https://otel.api.foam.ai/v1/logs (only when sessionReplay is enabled)
rrweb DOM snapshots and incremental mutations. Outside the span redaction processor. Only masking is the rrweb knobs foam forwards (maskAllInputs default true, plus mask/block/ignore class, maskAllText, sampling, recordCanvas). Treat as a separate, heavier privacy surface.
Page & landing analytics
ON by default, raw, session-correlated — no configuration, no extra option needed. Two event-shaped spans:
pageview — one per page view: the initial load AND every SPA route
change (history.pushState/replaceState are patched call-through;
popstate/hashchange are listened to; duplicate URLs are ignored).
| Attribute | Value |
| --- | --- |
| pageview.type | initial (the landing) or route_change (SPA navigation) |
| pageview.id | one id per view — joins the view's time-on-page reports to it |
| page.url / page.path / page.title | the view's full URL (raw; listed redact keys masked in the query), path, and document title |
| page.referrer | landing: document.referrer; route change: the previous in-app URL (GA4 semantics) |
| page.utm_source / page.utm_medium / page.utm_campaign / page.utm_term / page.utm_content | the landing/route URL's utm_* query params, raw, only when present |
time-on-page — VISIBLE milliseconds per view, reported
incrementally (the pattern that survives tab kills): a delta on every
visibilitychange → hidden, on pagehide, and on route change (charged
to the outgoing view). Sum the deltas server-side; join them to their
pageview via pageview.id. Attributes: page.time_on_page_ms (the
delta), time_on_page.trigger (hidden | pagehide | route_change),
plus pageview.id, page.url, page.path. Time in a background tab
counts for nothing — only visible time accrues.
Both spans carry component: page-analytics, ride the normal trace
pipeline (redaction + session stamp + batch), and are stamped with
session.id like everything else.
Session stitching — session.id on every signal
Foam always stamps session.id (OTel semconv) on EVERY span, via a
global processor that runs ahead of the export batch — independent of
session replay (the id exists and stamps with replay dark). The id is
the same one getSessionId() returns (the foundation's persisted
session: 4-hour cap, 15-minute inactivity). When the session rotates —
in-page or between page loads (foam persists the last-seen id in
localStorage) — new spans also carry session.previous_id, so
sessions chain across rotations.
The backend leg is W3C Baggage. Wherever foam injects traceparent
(same-origin always; cross-origin per tracePropagationTargets — by
the capture path or the guaranteed propagation-only path alike), it
also sends a baggage header carrying session.id=<id> — foam server
packages copy it onto backend spans and logs, stitching a browser
session to its backend telemetry end to end. The receiving API's CORS
allowlist must therefore permit baggage alongside traceparent
(see Propagation and CORS below) — an API that allows only traceparent
will fail the preflight once it is listed in tracePropagationTargets.
Storage failures (Safari private mode, disabled storage) degrade to in-memory rotation tracking; stamping never stops and nothing throws.
Verify it's working
After a production-shaped init in a real browser:
- Open DevTools → Console — there should be no
[foam]warnings. - DevTools → Network — a
POSTtohttps://otel.api.foam.ai/v1/traceswithauthorization: Bearer …(your token). - Trigger a user action (or
addAction('install-check')) and confirm another traces export lands within a few seconds (or on tab background). getSessionId()returns a non-empty string while active — attach that id to a support ticket to correlate the session. (There is no foam session URL helper; the foundation's points at HyperDX product chrome and is not re-exported.)
Failure modes — what you'll see and where
All warnings are [foam]-prefixed lines in the browser console of
the affected page.
| Situation | Behavior |
| --- | --- |
| enabled: false | Fully inert and SILENT — no warning, nothing patched, nothing validated. The expected off state. |
| Missing/blank name, environment, or token (when enabled) | One console warning naming the option; telemetry stays off; the app is untouched. |
| Missing version | One advisory warning; telemetry proceeds without service.version. |
| Invalid optional value (networkCapture, sessionReplay, additionalResourceAttributes, ...) | One warning naming the option; the safe default applies; everything else proceeds. |
| init() called twice | Warning; second call ignored. |
| A foreign OTel SDK owns the page | Foam goes inert and warns once — see Coexistence. |
| Called during SSR / no window | Silent no-op. |
| Upstream throws inside init | Caught; one warning; telemetry off; app untouched. |
| The upstream pipeline shape drifts (no processor seam, unrecognizable chain, no batch buffer) | One warning naming the failure; the pipeline is shut down and telemetry stays OFF — foam fails closed rather than export unmasked data. |
What the missing/blank-required-argument case looks like, concretely:
// A blank required option — ONE warning, nothing else happens:
init({ name: '', environment: 'production', enabled: true, token: 'pub-tok' });
// browser console: [foam] init(): 'name' is required (the service name); telemetry stays off.
// The app keeps running; nothing is exported; nothing throws.Redaction
Always on, fail-closed, applied to every span before export — including
attributes, event attributes, status.message, span names (the
exception path builds them from error text), network capture, console
capture, and spans that ended while init was still wiring up (those
are swept before they can export raw). The redaction processor
runs ahead of the export batch in the span-processor chain (only foam's
own session-stamp processor precedes it) — so even the batch's own
size-triggered synchronous flush only ever serializes masked,
session-stamped spans; if the pipeline shape ever prevents that
ordering, telemetry goes dark with a warning. Before it shuts the
pipeline down, foam best-effort masks any spans already sitting in a
recognizable init-window batch buffer (the shutdown itself flushes
that buffer); the one residual is spans buffered in an unrecognizable
upstream shape, which that shutdown flush can still export — see
GOTCHAS B6. Reachable only if the pinned foundation drifts.
Session replay is a separate path. Replay is rrweb DOM recording
posted to /v1/logs, outside the span redaction processor. Its only
masking is the upstream rrweb knobs foam forwards via sessionReplay
(default maskAllInputs: true). Do not assume span redaction covers
DOM content in a recording.
The default credential denylist — always on
The one exception to "raw by default": every foam SDK ships an
always-on CREDENTIAL/SECRET DENYLIST that
masks, to the literal [REDACTED] (full — no tail, no length
preservation: these are credentials, not debug aids), the VALUES of:
- The seven credential headers —
authorization,proxy-authorization,cookie,set-cookie,x-api-key,x-auth-token,www-authenticate— matched as header names wherever headers are captured, including thehttp.request.header.<name>/http.response.header.<name>span attributes (whatever case or-/_spelling they arrive in, foam's own capture hooks or the foundation's). - The frozen 52-name key list of
contract/credential-denylist.json(the verbatim union of sentry-python's (MIT) default denylists, foam's documented reference roots, and the seven headers —password,token,secret,api_key,session,jwt,ssn,cvv, …), matched as attribute/field names everywhere the engine walks: span and event attributes, resource attributes, captured JSON bodies at every depth, and URL query parameter names.
The matching rule: case-insensitive, dash/underscore-normalized
EXACT name-equality — never substring. Authorization,
AUTHORIZATION, and x_api_key ≡ X-Api-Key all match;
authorization_url, x-api-key-id, and secretary do NOT. The floor
itself never pattern-scans values — credential-SHAPED values under
unlisted names are the job of the separate
value-pattern secret layer
below; UNSHAPED values under unlisted names still ship raw (list a key
or use redact() for those).
There is no off switch. No init option, no configuration, no
redactKeys interaction can disable, shrink, or weaken the floor — a
floor name you also list in redactKeys stays fully [REDACTED]
rather than downgrading to a tail. Rationale: these names carry live
credentials in the overwhelming majority of real traffic, and a
misconfigured page must not be able to leak them to anyone — foam's
backend, tenant processors, or a foreign pipeline. enabled: false
remains fully inert (nothing exports at all).
Migration note — 1.4.0 (minor): always-on credential masking. As of this version foam masks, by default and in every signal, the VALUES of a fixed list of credential/secret header and field NAMES (
authorization,cookie,set-cookie,proxy-authorization,x-api-key,x-auth-token,www-authenticate, and the 52-name key list incontract/credential-denylist.json— sentry-python (MIT) parity plus foam's documented roots) to the literal[REDACTED]. Matching is exact name-equality, case-insensitive, dash/underscore-insensitive — never substring:authorization_urlis untouched. Everything else still exports RAW exactly as before;redactKeys/redactPiiKeysare unchanged and additive. There is no off switch — if a dashboard keyed off a raw credential value (it should not have), it will now see[REDACTED].
The value-pattern secret layer — always on
The second and final exception to "raw by default": the floor masks by NAME; this layer masks by VALUE SHAPE, because foam telemetry is read downstream by LLMs and a leaked credential is exfiltratable by prompt injection — a secret under an innocuous key is still a secret.
- Tier 1 — named provider patterns, no off switch: AWS access-key
ids and keyword-anchored secret keys, GCP
AIza…keys, Azure client secrets and storage account keys, GitHub tokens and fine-grained PATs, GitLab tokens, Slack tokens and webhooks, Stripe secret keys (sk_live_…— publishablepk_deliberately excluded), JWTs,-----BEGIN … PRIVATE KEYblocks (truncated blocks mask to the end of the value, fail-closed;PUBLIC KEY/CERTIFICATEnever match), PuTTY key files,scheme://user:pass@URI credentials (the whole userinfo masks, username included),Bearer <token>/Basic <b64>values, and Anthropic/OpenAI API keys. The frozen ruleset derives from gitleaks (MIT), detect-secrets (Apache-2.0), and secretlint (MIT) — see THIRD-PARTY-NOTICES. - Tier 2 — generic heuristics, ON by default, one opt-out
(
secretHeuristics: false): the gitleaksgeneric-api-keykeyword+entropy assignment scan (password = hunter2SecretXyz99under NO listed key) and the detect-secrets whole-value high-entropy base64 token check. Both carry the vendored gitleaks stopword / placeholder / key-context allowlists, soapi_version = 2024-06-01,token = true, trace ids, git SHAs, and UUIDs stay raw. - The mask is the literal
[REDACTED]on exactly the matched span — no partial reveal, ever (conn to postgres://[REDACTED]@db:5432 refusedkeeps its diagnostic value). A value-pattern hit is terminal: theredactKeystail never runs over a shaped secret. - Where it runs: every value-bearing string on every export path —
span/event attribute values,
status.message, span names, captured headers and bodies (JSON leaves, form-urlencoded pair values, free text), console messages, exception messages, URL query AND fragment pair values, and resource attributes (once at init). - Bounded and fail-closed: compile-once rules behind literal-anchor pre-filters (normal telemetry does zero regex work); values over 256 KiB are never regex-scanned — any anchor hit masks the whole value, no anchor passes it to the key passes; match floods past 1 000 mask the whole value; a scanner error masks the whole value. Never a raw pass-through, never a crash.
Migration note — 1.5.0 (minor): value-pattern secret masking + redaction coverage fix. foam now masks credential-SHAPED values (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI keys, JWTs, private-key blocks,
user:pass@URI credentials, bearer/basic tokens) to[REDACTED]in every exported string value — regardless of field name — plus a generic keyword+entropy heuristic you can disable withsecretHeuristics: false(the named patterns and the credential floor cannot be disabled). Redaction now also reaches URL FRAGMENTS (OAuth implicit-flow tokens, hash-router params),http.*.header.*attribute keys (yourredactKeysnames now match bare header names, and upstream-captured XHR request headers are re-masked at capture time), form-urlencoded bodies (the JSON-only body contract is retired), and nested structures fail CLOSED at the depth cap. URL pair values matched by name now mask to the full literal instead of the tail. If a dashboard keyed off a raw token value (it should not have), it will now see[REDACTED].
Customer keys — opt-in, additive
- Above the floor and the value-pattern layer, redaction is FULLY OPT-IN. By default the package masks nothing else: attributes, captured headers/bodies, URL query params, and console/error text are all captured RAW, including UNSHAPED secrets and PII under unlisted names (credential-SHAPED values are the value layer's job). Customer masking acts SOLELY on the keys you list.
- The grouped
redactobject is the preferred spelling — the same object in every foam SDK:redact: { secrets: [...], pii: [...], detect: [...] }.secretsandpiicarry exactly the semantics of the two legacy options below;detectis the separate PII detection tier. The flat options remain working ALIASES — a legacy option and its correspondingredactfield UNION when both are set. redactKeys— secret masking. The exact key names to tail-mask (case- and separator-insensitive, soX-Api-Key≡x_api_key) in attributes and captured headers/bodies — including the bare header name ofhttp.*.header.*span attributes. In URL query/fragment params and form-urlencoded bodies a listed name masks to the full literal instead (a tail there reveals the secret).redactPiiKeys— PII erasure. Your PII field names, full-masked to[REDACTED]. Foam infers nothing — no email/phone pattern guessing, no preset key list; the list is the entire PII set.- Resolution order per name: credential floor (
[REDACTED], terminal) → value-pattern scan ([REDACTED]per matched span, terminal for that span) → opted-inredact.detectentities (typed placeholder per matched span) →redactKeys/redact.secrets(tail) →redactPiiKeys/redact.pii(full) → RAW. - Mask shapes:
- the credential floor: always the full literal
[REDACTED](captured header attributes keep their array shape, one[REDACTED]per header instance); - the value-pattern layer: the literal
[REDACTED]on exactly the matched span, surrounding text preserved; redactKeys: tail mask —sk-live-abc123def456→********f456(full********when shorter than 12 chars). Arrays of scalars (captured header attributes) mask per element; other non-scalar values under a listed key →[REDACTED]. A credential-SHAPED value under a listed key redacts in full (the value layer wins — a tail would reveal the secret's last four characters).redactPiiKeys: FULL mask, always[REDACTED]— there is no useful "tail" of an email address.
- the credential floor: always the full literal
redact(value)exposes the same masking on demand for your own code (an explicit call — it always masks; below).
init({
name: 'web-app', environment: env, enabled: true, token: TOKEN,
redact: {
secrets: ['x-internal-auth'], // secrets → tail mask (== redactKeys)
pii: ['email', 'phone'], // your PII keys → full [REDACTED] (== redactPiiKeys)
detect: ['email', 'credit_card'], // opt-in value-shape detection → [EMAIL], [CREDIT_CARD]
},
});The legacy spelling keeps working unchanged (and unions with the object when both are set):
init({
name: 'web-app', environment: env, enabled: true, token: TOKEN,
redactKeys: ['x-internal-auth'], // alias of redact.secrets
redactPiiKeys: ['email', 'phone'], // alias of redact.pii
});PII detection (redact.detect) — opt-in
Off by default — nothing here runs unless you list an entity. Where
redact.pii masks by KEY name, redact.detect masks by VALUE SHAPE:
each listed entity is pattern-matched inside every free-text string the
engine walks, and ONLY the matched span is replaced with the entity's
typed placeholder — surrounding text stays verbatim ("sent to
[email protected]" → "sent to [EMAIL]"). The entity set, placeholders, and
behavior vectors are frozen in
contract/pii-detect.json —
byte-identical in every foam SDK:
| Entity name | Placeholder | Detects | Never masks |
| --- | --- | --- | --- |
| email | [EMAIL] | RFC-shaped addresses with a real TLD ([email protected]) | bare @ signs, user@localhost without a TLD |
| phone | [PHONE] | separator-delimited numbers, optional +CC / (area) (+1 (415) 555-0142, 415-555-0199) | undelimited digit runs (order ids), dotted versions, clock times |
| ssn | [SSN] | delimited 3-2-4 with a CONSISTENT -/space delimiter (536-90-4399) | structurally invalid SSNs (000/666/9xx area etc.), undelimited 9-digit runs |
| credit_card | [CREDIT_CARD] | 13–19 digits in 4-4-4-rest grouping that pass Luhn (4111 1111 1111 1111) | any digit string failing Luhn — tracking ids, serials |
| ip | [IP] | IPv4 with per-octet range validation, full and ::-compressed IPv6 | out-of-range octets (999.1.1.1), 4-digit octets (version strings) |
How it composes and behaves:
- Order: credential floor (terminal) → value-pattern secret layer →
detect → your
secrets/piikeys. Detect never weakens an earlier layer, and a value under one of YOUR listed keys keeps its key mask (tail or full[REDACTED]) — never a typed placeholder. - Where it runs: everywhere the value-pattern secret layer runs —
span/event attribute strings,
status.message, span names, captured headers and bodies (JSON leaves, form-urlencoded pair values, free text), console messages, and URL query/fragment leaves. - Idempotent: placeholders are never re-scanned; running detection twice is byte-identical.
- Bounded and fail-closed: same caps as the secret layer (values
over 256 KiB are never detect-scanned; match floods past 1 000 mask
the whole value), and a detector error masks the whole value to
[REDACTED]with one[foam]warning — never a raw pass-through, never a crash. - Validation — the browser warns where servers throw. On the server
cores a malformed
redact(unknown field, non-array value, unknowndetectentity name) THROWS at init — loud at boot. A browser SDK must never hard-crash the host page, so this package instead prints one[foam]warning naming the bad piece and ignores it — the valid remainder still applies, and the floor + value layer are untouched either way. This is the ONLY behavioral difference from the server cores' detect tier; the entities, placeholders, matching, and precedence are identical. - What it can NOT detect: person names and free-text prose ("John
Smith lives at..."). Those need NER, which is server-side scope —
list the keys that carry them in
redact.piiinstead. - Session replay is NOT covered. Replay is rrweb DOM recording
posted outside the span redaction engine (see the note at the top of
this section);
redact.detectdoes not reach it. DOM masking is controlled solely by thesessionReplaymasking knobs (maskAllInputs,maskAllText,maskClass,blockClass).
Migration note — 1.6.0 (minor): the grouped
redactoption + opt-in PII detection.init()gains ONE additive option,redact— an object grouping the two existing key lists under intent-named fields (secrets==redactKeys,pii==redactPiiKeys; each UNIONS with its legacy alias) plus a NEW opt-indetectlist of PII entity names (phone,ssn,credit_card,ip— frozen incontract/pii-detect.json) that are pattern-masked in free text with typed placeholders ([EMAIL],[PHONE],[SSN],[CREDIT_CARD],[IP]). Withredactabsent, behavior is byte-identical to 1.5 — nothing new masks by default. The legacy options keep working unchanged. One browser-specific posture: a malformedredactwarns and is ignored where the server cores throw (a browser SDK never crashes the host page).
One concept, every foam package — the parameter names
The customer options carry the same names (per-language casing) on every foam SDK, and the credential floor is identical — byte-for-byte — in all of them:
| Concept | js/otel | js/browser | python | ruby | java |
|---|---|---|---|---|---|
| grouped redact object — CANONICAL (fields secrets/pii/detect) | redact | redact | redact= dict | redact: hash | .redact(…) builder |
| detect entities (opt-in PII value detection) | redact.detect | redact.detect | redact={"detect": […]} | redact: { detect: […] } | via .redact(…) |
| secret keys (tail-mask) — legacy alias of redact.secrets, union | redactKeys | redactKeys | redact_keys | redact_keys: | .redactKeys(String…) |
| PII keys (full [REDACTED]) — legacy alias of redact.pii, union | redactPiiKeys | redactPiiKeys | redact_pii_keys | redact_pii_keys: | .redactPiiKeys(String…) |
The credential floor has no parameter in any language — deliberately.
The API — browser-native by design
The whole surface: init plus seven functions. Every one never throws
and no-ops safely before init, when disabled, and during SSR. There is
deliberately NO manual-span helper set here (span, setAttribute,
getTracer, flush, ...) — see "Differences from the foam server
cores" below for the reasoning and the full record.
recordException(error, attributes?)
(error: unknown, attributes?: Attributes) => void
Records an error as exception telemetry (works with non-Error values
too). Use it in React error boundaries — errors caught by a boundary
never reach window.onerror, so automatic capture cannot see them.
Foam never patches your components (no boundary monkey-patching); the
idiomatic one-liner is yours:
<ErrorBoundary fallback={<CrashScreen />}
onError={(error, info) => recordException(error, { componentStack: info.componentStack })}>
<App />
</ErrorBoundary>addAction(name, attributes?)
(name: string, attributes?: Attributes) => void
Named product event — a click, a feature toggle, a business milestone.
Attribute values under credential-floor names or your listed
redactKeys/redactPiiKeys are masked before export (everything else
rides RAW).
import { addAction } from '@foam-ai/browser';
addAction('workflow.published', { workflowId: 'wf_123', plan: 'team' });setGlobalAttributes(attributes)
(attributes: Record<string, string>) => void
Attach identity/tenant metadata to all subsequent session telemetry. Values are subject to redaction like everything else.
import { setGlobalAttributes } from '@foam-ai/browser';
// after login — every subsequent span from this page carries both keys
setGlobalAttributes({ userId: user.id, teamId: user.teamId });getSessionId()
() => string | undefined
The upstream session id (persisted in a same-site cookie; sessions cap
at 4 hours / 15 minutes of inactivity) — correlate a support ticket
with a session. This is the SAME id foam stamps as session.id on
every span and sends as baggage to your backends (see "Session
stitching"), so a support ticket carrying it joins directly to the
session's telemetry, replay dark or not. undefined before init, when
disabled, or in inert mode. (There is no getSessionUrl: the
foundation's helper returns a HyperDX product URL, which is vendor
chrome, not a foam surface.)
import { getSessionId } from '@foam-ai/browser';
// the support-ticket pattern: attach the session id to the report
function buildSupportTicket(description: string) {
return {
description,
// undefined before init / when disabled / in inert mode
foamSessionId: getSessionId() ?? 'no-session',
};
}stopSessionRecorder() / resumeSessionRecorder()
() => void — pause/resume session replay around sensitive screens
(replay masking handles inputs by default; these handle whole flows).
No-ops when replay is off or foam is not active.
import { resumeSessionRecorder, stopSessionRecorder } from '@foam-ai/browser';
stopSessionRecorder(); // entering the card-entry step — recording pauses
// ... the sensitive flow runs unrecorded ...
resumeSessionRecorder(); // back on a safe screen — recording resumesredact(value)
(value: unknown) => string
The package's own redaction engine for your code, e.g. before attaching
something questionable to a ticket or log line. Scalars get the tail
mask (********f456); everything else returns [REDACTED]; on internal
failure it returns '' — never the raw payload. Works in every state,
including before init().
import { redact } from '@foam-ai/browser';
redact('sk-live-abc123def456'); // → '********f456'
redact({ password: 'hunter2' }); // → '[REDACTED]' (non-scalar)
ticket.debugNotes = redact(rawHeaderDump); // safe to attach anywhereA default export carrying the same surface exists
(import foam from '@foam-ai/browser') for namespaced call sites.
Differences from the foam server cores
@foam-ai/browser is a companion package, not a browser build of
the foam core. The same principles hold — never-throw, fail-closed
redaction, honest coexistence, the required enabled gate, no
speculative surface — but the core's init table and helper set are
core surface, deliberately not transplanted into a page. The shape is
deliberate: a thin enrichment layer, one owner per signal, diagnosable
misconfiguration.
What that means concretely — every deviation, with its reason:
| Core surface | Here | Why |
| --- | --- | --- |
| span / setAttribute / setAttributes / addEvent | absent | Browser telemetry is auto-instrumentation + events, not manual span trees; upstream's stack-based context manager can't parent across async gaps anyway (GOTCHAS B4). addAction is the browser-native event primitive. |
| getMeter / incrementCounter / recordHistogram / setMetric | absent | The foundation constructs no MeterProvider; OTel browser metrics are experimental. RED metrics derive server-side from spans. |
| log() / getLogger | absent | No LoggerProvider in the foundation; consoleCapture covers the browser's native log signal (as trace spans, redacted). |
| flush() / shutdown() | absent | The upstream pipeline batches continuously and force-flushes on visibilitychange → hidden. A public flush would require reaching upstream's sealed provider for marginal gain (see "Page unload"). |
| getTraceContext() / getTracer() | absent | Raw OTel escape hatches presume a manual-span workflow this package doesn't have. |
| OTEL_* / FOAM_* env vars | absent | No process.env in a page; the equivalents are build-time constants (table above). |
| captureException alias | removed at 1.0.0 | One name per function: recordException. |
| Runtime capture toggles (enable/disableAdvancedNetworkCapture) | replaced | One init-time networkCapture: 'off' \| 'basic' \| 'full' knob — invalid states unrepresentable, no mid-session capture escalation. |
| attachToReactErrorBoundary (old 0.x surface) | removed | Foam never monkey-patches customer code; the onError recipe above is explicit and equivalent. |
HyperDX foundation parity
Everything the foundation can do has a foam path — exposed, replaced,
or absent for a stated reason. Methods: wrapped never-throw
(init, recordException, addAction, setGlobalAttributes,
getSessionId, stopSessionRecorder, resumeSessionRecorder) or
deliberately replaced/cut (the two capture toggles → networkCapture;
attachToReactErrorBoundary → the onError recipe; getSessionUrl →
absent, vendor product URL). Init options:
| Upstream option | Foam path |
| --- | --- |
| apiKey | token (raw key; foam adds the Bearer prefix) |
| service | name |
| url | absent by design — pinned foam endpoint |
| debug | diagnostics |
| advancedNetworkCapture | replaced by networkCapture: 'full' — foam masks every rewritable surface; the technically-unmaskable residual ships raw rather than staying uncaptured (the FDE clamp is the per-customer valve) |
| captureConsole (deprecated upstream) / consoleCapture | consoleCapture |
| disableReplay, maskAllInputs, maskAllText, maskClass, blockClass, ignoreClass, recordCanvas, sampling | sessionReplay (boolean or options object; safe defaults) |
| ignoreUrls | ignoredOutboundHosts (strings = hosts, RegExps = full URLs, verbatim) — always extended by foam's loop guard |
| instrumentations | owned by foam (see "Deliberately absent knobs") — foam enables the full foundation set by default (fetch, XHR, document, interactions, postload, longtask, errors, web-vitals, visibility, websocket, socketio, connectivity — the last three ship off upstream; foam turns them on); networkCapture: 'off' leaves fetch/XHR/WebSocket/socket.io unpatched |
| tracePropagationTargets | tracePropagationTargets (same semantics, plus foam's session.id baggage rides the same allowlist — which also gates foam's propagation-only path, tracePropagation, a foam-owned guarantee with no upstream equivalent) |
| disableIntercom | absent — foam pins it true; no vendor integrations foam did not sanction |
| otelResourceAttributes | additionalResourceAttributes (redacted; identity attributes protected) |
Coexistence — when foam is not alone on the page
Foam checks the OpenTelemetry global BEFORE registering anything, never displaces an existing owner, and warns once telling you exactly what happened. Three situations:
A — another RUM vendor (non-OTel: Datadog RUM, FullStory, ...).
Disjoint pipelines; both can run. Add their intake host to
ignoredOutboundHosts (scenario 4) so foam never traces their export
traffic, and let exactly one tool record session replay. If they must
remain the only owner of network telemetry, set
networkCapture: 'off' — and note that since 1.8.0 the propagation-only
path still injects headers at 'off': add tracePropagation: false
when the other tool must own the outbound request headers too.
A, worked examples — Sentry and PostHog. Both wrap fetch/XHR and
neither claims the OpenTelemetry global, so foam runs fully active
beside them. What to know:
- Sentry sends
sentry-trace, foam sendstraceparent— two trace contexts on one request. Foam's presence check keys ontraceparent, which Sentry's browser SDK does not set by default, so same-origin requests end up carrying both headers: backends reading W3C trace-context join foam's trace while Sentry backends followsentry-trace, and a mixed stack can split one request across two disconnected traces. Foam warns once in the console when it observes the overlap. Decide which tool owns distributed tracing: if Sentry does, settracePropagation: false; if foam does, disable Sentry's browser tracing or empty itstracePropagationTargets. If you turn on Sentry'spropagateTraceparentoption, pick exactly one writer — both SDKs skip when atraceparentis already present, so init order would otherwise silently decide the winner per request. - A CORS allowlist copied from Sentry does not cover foam. Sentry's
documented recipe (
Access-Control-Allow-Headers: sentry-trace, baggage) does NOT admittraceparent— extend it to includetraceparent, tracestatebefore putting that origin intracePropagationTargets, or the preflight fails the request itself (see "Propagation and CORS" below). Foam's string targets are also EXACT full-URL matches, not Sentry's substring semantics — port a Sentry list as RegExps, not strings. - PostHog's same-origin reverse proxy gets stamped. PostHog's
recommended ad-blocker-resistant setup proxies its ingest through
your own origin (e.g.
/ingest), and same-origin requests always receive foam'straceparent+session.idbaggage — which the proxy forwards verbatim to PostHog's servers. If you don't want foam's session id leaving for a third party, exclude the proxy path with a full-URL RegExp inignoredOutboundHosts(host strings can't express a same-origin path):ignoredOutboundHosts: [/^https:\/\/app\.example\.com\/ingest([/?#]|$)/]. The same recipe fits a same-origin Sentrytunnelroute. Separately, if you enable PostHog session replay's network header capture, foam's injected headers (the foam session id included) are recorded into PostHog recordings — striptraceparent/baggagethere via PostHog'smaskCapturedNetworkRequestFn, or exclude one side.
B — a foreign OpenTelemetry SDK owns the page. Foam goes INERT:
nothing is captured or exported to foam — foam is dark, not
degraded. The one-time warning names the owner and states what still
works: recordException() keeps functioning but emits through the
foreign SDK, so those errors land in ITS backend under ITS resource
identity (typically a third-party vendor you pay by volume); every
other foam helper no-ops. One edge case, detected automatically: if
the other SDK registered through an OpenTelemetry API older than
foam's (api 1.4–1.7-era builds), foam's API copy gets a no-op view and
recordException() cannot ride it either — the init warning tells you
which case you are in, and recordException() warns once instead of
silently dropping the error. Foam is dark here; the door-2 ingest
entries (createFoamIngest*) exist in the SERVER cores and will not
ship in the browser package — this package is a thin enrichment layer
over its foundation SDK, and a page hosts no foreign OTel server
pipeline for foam to tap. Your options: remove the other SDK so
foam owns the page, instrument the backend with a server core, or raise
the need with foam. Note a coexistence conflict can also arrive LATE — an SDK
loaded after foam (tag manager, lazy chunk) cannot displace foam (the
global is first-wins), and foam initialized after an SDK goes inert as
above.
C — a scoped tenant SDK wants to ride foam's spans. Supported and
fault-isolated: pass its processor via additionalSpanProcessors
(scenario 5). Tenants see spans AFTER redaction — by construction:
onEnd/forceFlush/shutdown are forwarded, onStart is not (a
span's creation-time attributes are still unmasked when onStart
fires, so no tenant code can observe them). A throwing tenant is
contained and warned about once; its export host MUST be listed in
ignoredOutboundHosts. The equivalent log/metric seams don't exist
here because this package ships no log/metric pipeline (below).
What ships and what doesn't
Ships (wire-proven by e2e): traces — network capture (fetch, XHR,
WebSocket, socket.io, connectivity — e2e'd at 'full' and 'off';
'basic' is the same span path minus the capture hooks), page &
landing analytics (pageviews, referrer, UTM, time-on-page, ON by
default), console capture, errors (uncaught errors, unhandled
rejections, and recordException), event-shaped addAction /
setGlobalAttributes, always-on session stitching (session.id on
every span, session.previous_id across rotations, baggage to
propagation targets), and the guaranteed propagation-only path
(traceparent/baggage injection under networkCapture: 'off',
anchored to the exported pageview span — e2e'd on the wire). Session
replay ships but is OFF by default (FDE-activated), masked by default
when on.
Ships (foundation-inherited, ON by default — no wire proof of their own yet): document-load, user-interaction, web-vitals, long-task, post-doc-load resource, and page-visibility spans (table A above lists what each carries). They are activated by the pinned foundation's defaults and ride the same wire-proven pipeline (redaction, session stamp, batch export), but the suite does not yet assert these six span families on the wire — a foundation bump that silently dropped one of them would not turn CI red. Tracked as an open coverage gap: treat their continued emission as foundation-trusted, not foam-proven.
Doesn't ship: metrics helpers, log(), additionalMetricReaders,
additionalLogRecordProcessors, createFoamIngest* entries. The
HyperDX foundation constructs no MeterProvider or LoggerProvider, and
OTel's browser metrics/logs story is still experimental (citations in
RESEARCH.md §5). Foam's platform derives RED metrics
server-side from spans, so dashboards do not depend on browser metrics.
The createFoamIngest* entries are excluded by design: they are
server-core surface, shipped in the server cores
— a browser page hosts no foreign OTel server pipeline for foam to tap.
For anything else on this list, file the need with foam.
Platform notes
Propagation and CORS — read before setting tracePropagationTargets
traceparent — and the baggage header carrying session.id (session
stitching) — are attached to same-origin requests automatically.
Cross-origin requests get them ONLY when they match
tracePropagationTargets — because adding the headers makes the request
preflighted, and an API that doesn't allow them will make the browser
fail your actual request, not just the trace.
Two paths inject, one guarantee. When network capture is on
('basic'/'full') the fetch/XHR instrumentations stamp the headers on
the requests they capture, carrying each request span's own trace id.
Underneath rides the propagation-only path (tracePropagation, ON by
default): a header-only wrapper that injects for any gated request the
capture path did NOT stamp — at every capture level, 'off' included,
whether or not a span was recorded, even if the propagator seam drifts
(hand-built W3C fallback). It never overrides an existing traceparent,
and with no live span it anchors to the CURRENT pageview span's context,
so a page view's backend spans join one real exported trace. Stopping
ALL propagation therefore takes BOTH hatches: networkCapture: 'off'
(no capture) and tracePropagation: false (no injection) — only then do
you lose browser→backend trace and session continuity.
Migration note — 1.8.0 (minor): the propagation-only path.
init()gains one additive option,tracePropagation(defaulttrue): guaranteedtraceparent+session.idbaggage injection on same-origin requests andtracePropagationTargetsmatches, independent ofnetworkCapture. The visible behavior change is atnetworkCapture: 'off': pages that previously sent NO propagation headers now send them by default (same-origin + allowlist only — the CORS posture is unchanged). If another tool must own the outbound request headers, settracePropagation: falseto restore the old posture exactly.
Before allowlisting an origin, its API must respond to preflights with
ALL THREE headers (baggage included — an allowlist that stops at
traceparent fails the preflight, and with it your request):
Access-Control-Allow-Headers: traceparent, tracestate, baggage(plus your usual CORS headers). An allowlist copied from a Sentry setup
(sentry-trace, baggage) does NOT cover traceparent — extend it with
all three names above. Then:
init({ /* ... */ tracePropagationTargets: [/^https:\/\/api\.example\.com\//] });CSP
If the page sets a Content-Security-Policy, allow foam's endpoint or every export is blocked:
connect-src https://otel.api.foam.aiPage unload — why there is no flush()
Buffered spans are force-flushed automatically on
visibilitychange
