Privacy-safe browser telemetry for Sentinel, implemented as a thin TypeScript wrapper around the official OpenTelemetry JavaScript SDK.
import { Sentinel } from "@unkcode/sentinel";
const sentinel = Sentinel.init({
endpoint: "https://ingest.example.com",
publicKey: "sip_pub_0000000000000000000000000000000000000000000",
serviceName: "gofip-frontend",
release: "1.0.0",
rum: { enabled: true },
});
sentinel.info("checkout loaded", { cartSize: 2 });
await sentinel.startActiveSpan("checkout.submit", async span => {
span.setAttribute("checkout.method", "card");
});Sentinel configures official trace, log, and metric providers, batch
processors/readers, OTLP/HTTP exporters, W3C Trace Context, Zone context, and
fetch instrumentation. The public credential is sent only as an
Authorization: Bearer exporter header.
endpoint,publicKey, andserviceNameare required.release,environment, andtracesSampleRateset resource/sampling data.tracePropagationTargetsallows additional cross-origin W3C propagation. Official fetch instrumentation always propagates to same-origin requests.instrumentFetchandcaptureErrorsdefault totrue.beforeSendsynchronously rewrites or drops Sentinel-owned drafts after initial sanitization; its output is sanitized again.diagnosticsdefaults tofalse; enabled diagnostics are content-free and rate-limited.rum.enableddefaults tofalse; enabling it captures semantic browser RUM.replay.enableddefaults tofalse. Replay also requiresrum.enabledand a fresh enabled policy from Ingest for the exact public credential and Origin.
Replay is opt-in with rum: { enabled: true }, replay: { enabled: true }. The SDK
reads <endpoint>/v1/rum/replay/policy with the public credential and records
only while its enabled v1 policy is fresh. The first semantic JavaScript error,
network error, rage click, or dead click promotes a bounded in-memory rrweb
checkpoint buffer. Chunks go only to <endpoint>/v1/rum/replay/chunks; a 202
response confirms persistence. There is no continuous replay upload.
Add data-sentinel-private to the root of every sensitive subtree before
recording begins. Its contents are blocked, including descendants and later
mutations. The recorder retains visible page text, masks form values and strips source
attributes. Use data-sentinel-mask or an explicit maskTextSelector for sensitive
page text; data-sentinel-private still blocks entire subtrees. It retains a closed set of resolved layout properties (sizes,
spacing, flex/grid layout, typography and colors) as generated inline styles,
including updates when an element's attributes change. Source CSS, selectors,
classes, resource URLs, generated text, images, custom fonts, frames, canvas,
media, scripts, unsafe DOM surfaces, plugins, cookies and browser storage values
remain excluded. This restores layout and text, not a pixel-perfect copy. Stylesheet-only changes are reflected at the next checkpoint. Existing
recordings without layout cannot recover it. The original request and replay
limits still apply, so sufficiently large snapshots stop capture locally.
Mark dynamic private roots before
attaching them to the document. The replay viewer itself must carry this marker
before recorder rollout.
Changing this marker on an existing element stops replay and discards local
buffers; it cannot retract chunks that Ingest has already accepted.
Enable CSS and public resources explicitly:
replay: {
enabled: true,
visual: {
enabled: true,
resourceOrigins: ["https://your-store.example", "https://your-cdn.example"],
},
// Optional: mask additional sensitive page text.
maskTextSelector: "[data-sentinel-mask]",
}Visual mode retains class/id and a closed set of visual attributes, sanitized
source CSS, SVG shapes, empty form controls, mouse/scroll/resize events and
stylesheet/attribute changes. Relative resources are resolved against their
original stylesheet/document base. CSS generated text, scripts, private roots,
form values, media, frames and shadow DOM stay excluded. Public CSS and class/id
values must not contain private application data. Visible page text is captured
by default, including text added/updated during scroll. Values in input, textarea,
select/option and contenteditable surfaces remain excluded regardless of public
selectors. data-sentinel-private still blocks an entire subtree.
maskTextSelector defaults to [data-sentinel-mask]; set it to "*" to restore
full page-text masking. unmaskTextSelector can reveal selected non-control text.
Masked text uses a constant "*", independent of its original length.
The isolated player separately needs the matching REPLAY_RESOURCE_ORIGINS list.
Origins are exact (no wildcards); only configured public HTTP loopback/HTTPS
resources are admitted at capture, and production playback accepts HTTPS only.
Credential-bearing and signed/token query URLs are excluded; common image
resize/version and public font query parameters are accepted. Image-optimizer
url/src query references must recursively satisfy the same allowlist.
CORS must permit anonymous image, stylesheet and font loads. No resources are
stored in Sentinel: changing/removing them can change old playback.
Source CSS supersedes computed styles in visual mode; computed layout is the
fallback when the browser cannot read a stylesheet. Constructed/adopted
stylesheets are not supported yet. Chunks are gzip-compressed once before upload
when CompressionStream is available, and retries reuse the same bytes. Both
wire and decoded server policy limits still apply; identity fallback is bounded
by the wire cap. CSS capture and changed text policy need new recordings and an updated consumed
SDK release. Previously removed text/images cannot be restored from old chunks.
If playback has only structure/colors, replay: { enabled: true } alone is layout
mode: enable visual.enabled and configure both SDK and player resource origins
to include the recorded site and its image/font/CDN hosts.
An unpromoted prebuffer is discarded on hard navigation. A promoted replay with
at least one acknowledged chunk can continue after a reload in the same tab and
Sentinel session: sessionStorage
contains only bounded replay identity/count metadata, never rrweb events,
snapshots or chunks. Each new document obtains a fresh policy and page ID with
its own FullSnapshot. A navigation before the first valid 202 cannot continue
that replay: the next document starts a fresh buffer and needs a new semantic
trigger. Page exit cannot guarantee delivery of in-flight chunks. Later pages
count toward continuation only after their own first chunk is acknowledged.
The SDK stops replay independently when policy, privacy, upload or size limits
fail; semantic RUM and OpenTelemetry remain active.
sentinel.replayStatus() exposes only a fixed local state, with no replay or
session identifiers. With RUM-8, a 429 quota_exceeded stops only Replay with
state quota-exceeded and is not retried. Operational 429 responses still use
the existing bounded retry policy. Daily quotas share usage across all credentials
and Ingest replicas in the same tenant/application/environment.
Opt in with rum: { enabled: true }. The browser sends page_view, click,
rage_click, dead_click, scroll_depth, javascript_error, and
network_error events as schema v1 JSON to <endpoint>/v1/rum/events, using
the same public key in an Authorization header. It complements the existing
OpenTelemetry traces, logs, and metrics; it does not use OTLP.
The SDK creates a random tab-scoped session ID in sessionStorage, reuses it
across reloads, and rotates it after 30 minutes of inactivity. If storage is
blocked, it continues with an in-memory session. Events are batched at 20 or
every 5 seconds in a 200-event memory queue; overflow drops the oldest event.
Transient failures get one retry. Page lifecycle delivery uses authorized
fetch with keepalive and is best effort. A successful Ingest response in
RUM-2A confirms admission, not durable storage.
rage_click represents at least three rapid, nearby interaction attempts on
one stable target. An attempt can be a native click or a short, completed
pointer press without a click (for example, on a disabled button). A press
that produces a click counts once. The serialized click_count field is a
legacy name for the number of attempts. click and dead_click retain their
existing browser-click behavior. Historical rage events were detected from
clicks only, so detection coverage changes with this SDK version.
RUM records pathname-only routes, normalized interaction positions, bounded semantic
target fields (tag, role, approved data-testid), and safe event metadata.
For small interactive controls, it also automatically captures the control's
own safe aria-label as target.label, or bounded visible control text as
target.text when no safe label exists. Each human-readable field is at most
80 UTF-8 bytes. Add data-sentinel-private to a control or ancestor to omit
these human-readable fields within the eight-element ancestor lookup; click-like events can still carry
safe tag, role, and test ID metadata. Input, textarea, select, password, and
contenteditable values or contents are never captured as target text.
Rage targets on non-interactive elements carry only safe semantic identifiers;
the SDK does not read arbitrary page text from them.
It never reads form values, arbitrary page text, HTML, cookies, request bodies, headers,
or storage contents other than its own session metadata. JavaScript error
messages are generic to avoid leaking application data. No user identity or
fingerprinting is included in semantic events. Replay snapshots are a separate,
explicitly enabled signal. Ingest derives tenant,
application, and environment scope from the public credential.
javascript_error comes from automatic window.error and
unhandledrejection observation. Calling captureException() explicitly
continues to emit OTel telemetry without claiming a browser RUM error.
Trace and span IDs are attached only when a valid OpenTelemetry context is active at event creation. Ordinary DOM listeners may run in their registration context, so interaction correlation is best effort.
Fetch pathnames are retained while queries, fragments, user information, sensitive headers, and bodies are excluded. Sentinel owns the browser global OpenTelemetry setup in 0.1.x and fails deterministically if incompatible global tracing or existing fetch wrapping is detected.
@unkcode/sentinel/react exports SentinelErrorBoundary. React is an optional
peer dependency. @unkcode/sentinel/web-vitals exports instrumentWebVitals,
which records supported Web Vitals through an existing Sentinel instance.
Call flush() for an explicit best-effort export and shutdown() to disable
Sentinel-owned listeners, instrumentation, providers, and globals.