npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 a window).
  • 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 in redactKeys / redactPiiKeys.
npm install @foam-ai/browser

Quick 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 enabled to 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 tracePropagationTargets only after their CORS preflight permits traceparent AND baggage (foam sends both — the baggage carries session.id for browser→backend session stitching).
  • Leave sessionReplay off unless you need DOM recordings; when on, keep maskAllInputs: 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 traceparent injection once you set BOTH escape hatches — networkCapture: 'off' stops capture, but only tracePropagation: false stops 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:

  1. Open DevTools → Console — there should be no [foam] warnings.
  2. DevTools → Network — a POST to https://otel.api.foam.ai/v1/traces with authorization: Bearer … (your token).
  3. Trigger a user action (or addAction('install-check')) and confirm another traces export lands within a few seconds (or on tab background).
  4. 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 headersauthorization, proxy-authorization, cookie, set-cookie, x-api-key, x-auth-token, www-authenticate — matched as header names wherever headers are captured, including the http.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_keyX-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 in contract/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_url is untouched. Everything else still exports RAW exactly as before; redactKeys/redactPiiKeys are 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_… — publishable pk_ deliberately excluded), JWTs, -----BEGIN … PRIVATE KEY blocks (truncated blocks mask to the end of the value, fail-closed; PUBLIC KEY/CERTIFICATE never 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 gitleaks generic-api-key keyword+entropy assignment scan (password = hunter2SecretXyz99 under NO listed key) and the detect-secrets whole-value high-entropy base64 token check. Both carry the vendored gitleaks stopword / placeholder / key-context allowlists, so api_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 refused keeps its diagnostic value). A value-pattern hit is terminal: the redactKeys tail 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 with secretHeuristics: 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 (your redactKeys names 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 redact object is the preferred spelling — the same object in every foam SDK: redact: { secrets: [...], pii: [...], detect: [...] }. secrets and pii carry exactly the semantics of the two legacy options below; detect is the separate PII detection tier. The flat options remain working ALIASES — a legacy option and its corresponding redact field UNION when both are set.
  • redactKeys — secret masking. The exact key names to tail-mask (case- and separator-insensitive, so X-Api-Keyx_api_key) in attributes and captured headers/bodies — including the bare header name of http.*.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-in redact.detect entities (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.
  • 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/pii keys. 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, unknown detect entity 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.pii instead.
  • 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.detect does not reach it. DOM masking is controlled solely by the sessionReplay masking knobs (maskAllInputs, maskAllText, maskClass, blockClass).

Migration note — 1.6.0 (minor): the grouped redact option + 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-in detect list of PII entity names (email, phone, ssn, credit_card, ip — frozen in contract/pii-detect.json) that are pattern-masked in free text with typed placeholders ([EMAIL], [PHONE], [SSN], [CREDIT_CARD], [IP]). With redact absent, behavior is byte-identical to 1.5 — nothing new masks by default. The legacy options keep working unchanged. One browser-specific posture: a malformed redact warns 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 resumes

redact(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 anywhere

A 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 sends traceparent — two trace contexts on one request. Foam's presence check keys on traceparent, 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 follow sentry-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, set tracePropagation: false; if foam does, disable Sentry's browser tracing or empty its tracePropagationTargets. If you turn on Sentry's propagateTraceparent option, pick exactly one writer — both SDKs skip when a traceparent is 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 admit traceparent — extend it to include traceparent, tracestate before putting that origin in tracePropagationTargets, 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's traceparent + session.id baggage — 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 in ignoredOutboundHosts (host strings can't express a same-origin path): ignoredOutboundHosts: [/^https:\/\/app\.example\.com\/ingest([/?#]|$)/]. The same recipe fits a same-origin Sentry tunnel route. 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 — strip traceparent/baggage there via PostHog's maskCapturedNetworkRequestFn, 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 (default true): guaranteed traceparent + session.id baggage injection on same-origin requests and tracePropagationTargets matches, independent of networkCapture. The visible behavior change is at networkCapture: '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, set tracePropagation: false to 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.ai

Page unload — why there is no flush()

Buffered spans are force-flushed automatically on visibilitychange