@loxel.dev/pharos-browser
v0.12.0
Published
Browser client for Pharos — server-evaluated feature flags, error capture, and session replay.
Maintainers
Readme
@loxel.dev/pharos-browser
A thin browser client for Pharos: server-evaluated feature flags, error reporting, and session replay.
Bootstraps a context against the Pharos client API, then keeps flag values
current over a Server-Sent Events stream. Uncaught errors and unhandled
rejections are reported automatically. Session replay is opt-in, and its one
dependency (rrweb) is loaded on demand so you do not pay for it unless you
record — see Bundle size.
Built and tested with Bun.
Internals — the wire format, the local rest, the degradation ladder, and
the full export reference — are in TECHNICAL-DETAILS.md, shipped alongside
this file.
The server-evaluated model
This client never evaluates flags itself. Every value flag() returns was
computed server-side — rules, percentage rollouts, targeting attributes,
and bucketing all happen behind POST /api/v1/client/bootstrap, which
returns a flat {key: value} map for the given context.
The client's only job is to hold that map, keep it fresh via GET /api/v1/client/stream, and
hand values back synchronously. This keeps rule logic and segment membership
off the client (nothing to reverse-engineer from a bundle) and means a
targeting change takes effect for every open tab without a redeploy.
Install
bun add @loxel.dev/pharos-browser # or: npm install @loxel.dev/pharos-browserThe published tarball ships built output only (dist/, plus .d.ts
files), not TypeScript source: the exports map is what lets a bundler
resolve the bare specifier.
Before you start
Pharos is a hosted service, and access is by invitation. There is one
instance, at https://pharos-ops.app.
init() needs three values, and they all come from your console there:
| value | where it comes from |
|---|---|
| baseUrl | always https://pharos-ops.app |
| clientKey | ORGANIZATION → API keys → new key, kind client. Starts phc_. |
| context.application | the application that client key was minted for |
Register your browser origins on that application (ORGANIZATION → Applications) before the first request. An origin that is not on the list is refused by CORS, which surfaces as a network error rather than a permissions one — the most common first-run failure.
Use the phc_ client key, never the phs_ server key. The client key is
meant to ship in your bundle: it only authorises bootstrap, error ingest and
replay upload, for one application. The server key does much more and must
stay on a backend.
Bundle size
Importing this package does not cost you rrweb. The session recorder is
the only thing that needs it, and the recorder is not on the root entry:
| you import | you get |
|---|---|
| @loxel.dev/pharos-browser | flags, error reporting, the privacy policy, the replay buffer/encoder/store — no rrweb |
| @loxel.dev/pharos-browser/replay | the recorder, and therefore rrweb |
| await client.startReplay() | the recorder, fetched on demand as a separate chunk |
Measured on a real application (a React + Vite app, replay switched off): importing the client used to add 204 kB raw / 63 kB gzip to its main chunk, essentially all of it rrweb, paid whether or not anything ever recorded. It now adds 8.5 kB raw / 2.9 kB gzip.
startReplay() reaches the recorder through a dynamic import(). The chunk
still ships with your deployment, so turning replay on stays a configuration
change — it just stops being downloaded by visitors who never record.
rrweb (2.1.1) is an ordinary runtime dependency — not a CDN script.
Installing this package installs rrweb into your own node_modules; your
bundler compiles it into your own output, served from your own origin.
This package's build never inlines it. The published tarball contains no rrweb
files at all — only a single unresolved import { record } from "rrweb" for
your bundler to resolve. That is what lets a project already using rrweb end
up with one shared copy rather than two.
Usage
Set up
import { PharosBrowserClient } from "@loxel.dev/pharos-browser";
const client = await PharosBrowserClient.init({
baseUrl: "https://pharos-ops.app", // required
clientKey: "phc_...", // required
context: {
contextKey: "user-42", // required — who is asking; buckets rollouts
application: "tax-graph", // recommended — see below
release: "1.4.0", // recommended — see below
sessionId: "sess-abc", // optional — joins errors to replay windows
attributes: { plan: "enterprise", country: "US" }, // optional — flag targeting
},
});contextKey is the only context field the server enforces — a bootstrap
without one is a 400.
application and release are optional to the type, and you want both
anyway. application is hashed into the error fingerprint, so omitting it
collapses every browser application in your organization into one fingerprint
namespace and stops the console's application= filter from returning your
rows. release drives release attribution and the release= filter — and,
from 0.9.0, the Session Replays console's Release filter too:
client.startReplay() sends it with every replay window.
sessionId matters only if you want an error and the replay window captured
around it to be joinable; client.startReplay() gives you one id across both
without passing anything. attributes is free-form data for your flag
targeting rules — send nothing if no rule reads it.
Read a flag
// Synchronous, no network call — the value was computed server-side and is
// already here. Your default is returned until the bootstrap lands, for any
// key Pharos does not know, and for a value whose type disagrees with your
// default — a flag sent as the string "false" is not a boolean, and reading
// it through a boolean gate would otherwise turn the gate ON.
if (client.flag("new-nav", false)) {
renderNewNav();
}
// Values stay current over the stream, so a targeting change takes effect in
// every open tab without a redeploy. Re-read on change; do not trust the
// payload's shape.
const off = client.on("change", () => rerender());
// Stream and bootstrap failures surface here rather than throwing.
client.on("error", (err) => console.warn("pharos stream error", err));Identify a user
// After login. This is a full HTTP round trip, not a local re-key — call it
// on real identity changes, not per render.
await client.identify({
contextKey: user.id,
application: "tax-graph", // identify REPLACES the stored context, so
release: "1.4.0", // resend these or errors lose them
});The new user's flags arrive with it, so identify() fires change the same
way a server push does — a component that mounted while the user was
anonymous re-renders against the identified bucket without the app arranging
anything. If the re-bootstrap fails it still rejects (catch it), and the
client schedules a reconnect rather than leaving live updates dead until a
page reload.
Shut down
off(); // unsubscribe the change listener
client.close(); // close the stream, unregister the error listenersError reporting
init() reports errors to POST /api/v1/errors two ways:
- Auto-capture (on by default) — registers
window.addEventListener("error", ...)andwindow.addEventListener("unhandledrejection", ...)and reports every uncaught exception / rejected promise automatically. Opt out withinit({ ..., errors: false })to only report via explicitcaptureException()calls.close()unregisters both listeners. captureException(err, opts?)— call this yourself from acatchblock for errors your own code already handled.errcan be anError, a string, or anything else (String(err)is used as a fallback message);opts.sitelabels where it was caught (e.g. a route name) andopts.attributesattaches extra scalar (or array-of-scalar) context to this occurrence only.
// Uncaught errors and unhandled rejections are reported for you. Report a
// handled one yourself, with whatever context makes it diagnosable:
try {
await checkout(cart);
} catch (err) {
client.captureException(err, {
site: "checkout",
attributes: { step: "payment", itemCount: cart.length },
});
}If you use an error boundary, report from it explicitly. React hands a render error to the nearest boundary instead of letting it reach the window, so auto-capture never sees the one class of error that takes your whole UI down:
componentDidCatch(error, info) {
client.captureException(error, {
site: "ErrorBoundary",
attributes: { componentStack: info.componentStack ?? "" },
});
}Every report is a single-entry batch: message (from err.message,
falling back to String(err), never sent empty — "(no message)" is used
instead), kind (from err.name), stack (parsed from err.stack when
it's a string — V8 and Firefox stack-trace shapes are both recognized, via
the exported parseStack), the client's current flag map, and the same
reserved context fields init()'s bootstrap sends (contextKey,
application, release, sessionId — whichever are set on the current
context).
application/release matter here beyond bookkeeping: the
server hashes them into the error fingerprint and release-tracking, so
omitting them would silently merge every browser app's errors into one
fingerprint namespace and break the admin issues API's release= filter.
Every field is sanitized/truncated client-side to the server's
validation caps before sending, because the server rejects the whole
batch if any single field violates them (an empty flag map or attrs map
is simply omitted rather than sent empty) — this matters most for
auto-capture, which forwards whatever an uncaught exception happened to
contain.
Sending is fire-and-forget from the caller's side: captureException never
throws and returns nothing to await. A failed POST emits on("error"), like
the SSE stream's own failures do, and since 0.11.0 is kept and retried
rather than lost.
When Pharos is down
A report is attempted at once, exactly as before. If that attempt fails for a reason a retry could fix — the fetch rejected (offline, refused, DNS) or the server answered 5xx — the report enters a local queue and is delivered, in order, once Pharos can take it:
- One request per report, FIFO. The drain stops at the first report the server still cannot take and backs off from 1 s to a 30 s ceiling; reports that arrive during an outage join the back of the queue instead of being attempted, so ordering holds and a down server is not hammered.
- What wakes it: the backoff timer; the browser's
onlineevent (which resets the ramp — a tab that just got its network back tries at once); a delivered flag push on the live stream (proof the server is up); and the next page load. - Persisted across loads. Queued reports are written to IndexedDB (a
pharos-errorsdatabase, separate from the replay store) so a tab closed during an outage still delivers when your site next runs. They expire locally after 24 hours, the same rule as replay windows. Without IndexedDB the queue is memory-only and dies with the tab. - Bounded. At most 100 reports are held; past that the oldest is evicted, because late in an outage the newest errors describe the state the user is in now.
- A 4xx is not retried. Pharos answers 400 for a body that fails
validation and 403 for a key it refuses; neither changes with time, so
such a report is dropped and
on("error")receives{ type: "error-report-dropped", reason: "rejected" }. Every failed attempt, retryable or not, also emits{ type: "errors-post-failed", status }(or the thrown error), so an app watching that event sees each try.
const client = await PharosBrowserClient.init({
baseUrl, clientKey, context,
// The default. `false` restores the 0.10 behaviour (a failed report is
// gone); an object tunes the bound or the IndexedDB factory.
errorQueue: { maxEntries: 100 },
});
client.pendingErrors(); // reports held for retry right now
await client.flushErrors(); // try to deliver them all now, e.g. before teardownTwo tabs of the same origin opening together after an outage can each send the same persisted report before either deletes it — a duplicate occurrence, which is the cheaper failure; the replay store has the same property.
captureException after close() is a no-op (no fetch), matching identify()'s
documented behavior. close() also stops the queue's timer; reports it had
already persisted stay in IndexedDB for the next page load to deliver.
Session replay
Recording is opt-in. Importing this package records nothing.
One call starts a recorder, wires it to this client's errors, and uploads windows under the same session id your errors already carry:
const recorder = await client.startReplay();
// Consent withdrawn: stops recording, discards the buffer, and deletes
// anything already persisted locally.
client.detachReplay();
recorder.stop();It is async because it loads the recorder on demand — that is what keeps
rrweb out of your bundle until you actually record.
How it records. The recorder runs as a dashcam: it is always capturing
into a bounded local buffer and sends nothing until something goes wrong. A
trigger — anything reaching captureException, including window.onerror and
unhandled promise rejections — flushes a window covering preRollMs before
the error and postRollMs after it. A burst of errors from one broken render
produces one window, not fifty: triggers inside an existing post-roll
extend that window.
When to call it. Call startRecording() after the document is parsed if
you can. rrweb defers its first snapshot while document.readyState is
"loading"; the recorder pins recordAfter: "DOMContentLoaded" rather than
rrweb's "load" default, which shrinks that gap from "until every subresource
has loaded" to "until the document is parsed" — but it does not close it.
A recorder started from a <head> script captures nothing (and stats()
reports snapshots: 0) until DOMContentLoaded fires.
What it captures. DOM structure and mutations, clicks, focus, input events
and scroll (via rrweb), plus one Pharos-specific
addition: an activation mode (pointer, keyboard or programmatic)
emitted as an rrweb custom event tagged pharos-activation.
What it never captures: key identity. The keyboard listener records a
single enum — that the last input was a key — and never reads, stores or
transmits which key was pressed. A keydown listener sees passwords, and this
one cannot leak one.
Privacy. Every node is routed through this package's privacy policy
(decide, masksValue, maskText, scrubAttribute); the recorder
re-implements no rule of its own. Rendered text is recorded by default and
markers hide (pharos-exclude) or obfuscate (pharos-mask) it — a deliberate
posture, not an omission. See
the Pharos README for what
is and is not masked — including what it does not catch.
Retention is not yours to choose. Stored windows expire under a
per-application TTL, and each application also has a session cap —
at most 50 sessions per environment (Pharos 0.47): when it is exceeded the
environment's oldest sessions are deleted along with their windows and their
stored bytes. Both are per-application configuration delivered over
/api/v1/client/bootstrap; a value in an upload body is read by nothing. The
TTL is the user-facing promise, and the cap is what protects the disk —
one busy application would otherwise consume the whole retention window.
Idle stretches are not uploaded (0.12.0). A closed checkpoint segment
(~15 s at the defaults) that holds no user input — no pointer or mouse
interaction, mousemove, touch, drag, scroll, input, keyboard/pointer
activation or viewport resize — is not sent. A page changing its own DOM (a
clock, a ticker) is not activity. The skipped [start, end] spans ride on the
next window that is sent, as idleSpans, and the Pharos console shows them as
idle rather than as missing footage. The session's final open segment is
still sent on unload, and an error window is sent whether or not anyone
touched the page. stats().idleSegmentsSkipped counts what was skipped.
A session recording holds at most 300 MB (and 10 000 windows) on the
server. Once it is full Pharos answers 409 (SESSION_LIMIT_STATUS), and
0.12.0 stops recording for that session: the recorder is torn down (no
more DOM observers, snapshots or uploads), and a reload of the same tab does
not start it again. A new session (a new tab) records normally. Earlier
versions keep recording and park the refused window in IndexedDB to retry it
for 24 hours.
Wiring your own transport (startRecording, startReplayUpload), the wire
format, the local IndexedDB rest and its 24-hour expiry, what
droppedWindows counts, and the degradation ladder are all in
TECHNICAL-DETAILS.md, shipped in this package.
API at a glance
Full reference — every export, every option — is in TECHNICAL-DETAILS.md.
| | |
|---|---|
| PharosBrowserClient.init(config) | bootstrap and open the live stream |
| flag(key, default) | synchronous lookup; no network call, no evaluation |
| on("change" \| "error", cb) | subscribe; returns an unsubscribe function |
| identify(context) | re-bootstrap as a new context (a full round trip — call it on login, not per render) |
| captureException(err, opts?) | report a caught error; never throws; queued and retried if Pharos is down |
| pendingErrors() / flushErrors() | reports held for retry; deliver them now |
| startReplay(options?) | start a recorder wired to this client, and attach it |
| attachReplay / detachReplay | route this client's errors into a recorder you started yourself |
| close() | stop the stream and unregister the error listeners |
| reportDisplay (config) | send screen + viewport size with the bootstrap (default true; see below) |
| errorQueue (config) | queue failed error reports and deliver them once Pharos is back (default true; see "When Pharos is down") |
The privacy policy (decide, maskText, scrubAttribute,
collectRecordedStrings, the marker constants) is exported from the root so
you can verify what a recorder would send before turning one on.
What the bootstrap tells Pharos about the browser
Pharos's Users dashboard (Pharos 0.41.0) aggregates browsers, operating
systems, device classes, languages and countries. Pharos derives all of those
on the server, from the bootstrap request's own User-Agent and
Accept-Language headers and, behind a configured front proxy, a country
header. The SDK sends nothing extra for them, and page script cannot set
those headers. The one thing the server cannot see is the display, so the
bootstrap carries pharos.display: { screen: [w, h], viewport: [w, h] } in
CSS pixels: two pairs of numbers, with no pixel ratio, colour depth or
orientation. Pass reportDisplay: false to init to send neither.
Version history
0.12.0— Idle stretches are not uploaded, and a full session stops recording (Pharos 0.47 security hardening). A closed checkpoint segment with no user input (pointer, mouse interaction, mousemove, touch, drag, scroll, input, keyboard/pointer activation, viewport resize) is no longer sent; the skipped spans are reported on the next window asidleSpansso the console shows them as idle, not as a gap. A409from Pharos (the session reached its 300 MB / 10 000-window limit) is treated as final: the window is not parked for retry and nothing more is sent for that session. New:ReplayWindow.idleSpans,UploadEnvelopeMeta.idleSpans,RecorderStats.idleSegmentsSkipped,SESSION_LIMIT_STATUS, and an optionalonSessionLimitcallback onpostEnvelope.0.11.1— A dead stream token no longer strands the tab (#1847). The client used to wait for the browser to settle the stream intoCLOSEDbefore re-bootstrapping for a new token; one Chrome tab never did, and retried the same dead token every 3 s for a day after a Pharos restart. Now three stream errors in a row with no flag push between them also stop the stream and re-bootstrap, on the usual 1 s to 30 s backoff. (Pharos 0.45.1 also keeps stream sessions across restarts, so a restart no longer kills tokens in the first place.)0.11.0— Error reports survive a Pharos outage. AcaptureException(or auto-captured error) that Pharos cannot take — the fetch rejected, or a 5xx — is no longer lost. It enters a FIFO queue drained one report per request with a 1 s to 30 s backoff, woken by the browser'sonlineevent, by a delivered flag push, and on the next page load; queued reports rest in IndexedDB (pharos-errors) with a 24 h local TTL and a 100-report bound that evicts the oldest. A 4xx is dropped, not retried. The healthy path is unchanged: the first attempt is still issued synchronously, with no storage write. New:errorQueueconfig (falserestores the old fire-and-forget),pendingErrors(),flushErrors(), theerrors-post-failed/error-report-droppedshapes onon("error"), and theErrorQueueclass on the root for an app with its own transport. Before this the replay window an error triggered outlived the error that triggered it.0.10.0— The bootstrap reports screen and viewport size (loxel#1534).pharos.display: { screen: [w, h], viewport: [w, h] }(CSS pixels, read fromscreenandinnerWidth/innerHeight) rides the bootstrap for Pharos 0.41.0's Users dashboard. It is omitted when either size is unavailable, andreportDisplay: falseturns it off. Browser, OS, device and language need no SDK change because Pharos derives them from request headers. An older Pharos ignores the field. Also exportsreadDisplaySize().0.9.0— Replay windows carry the page's release (loxel#1517).startReplayUploadtakes areleaseoption and sends it aspharos.release— the same slot the errors envelope uses — on every window, live or persisted for a later drain.client.startReplay()passesPharosContext.releaseautomatically (an explicitreleaseoption still wins). Pharos 0.37.0 stamps it on the session so the replay console's top-bar Release filter can find it; an older Pharos ignores the field. Omitted, not sent empty, when the host declares no release.0.8.1— A failed replay start no longer leaves a dead recorder hooked into the page.When rrweb's
record()fails internally it returnsundefined, andstartRecordingalready hands back an inert handle. But rrweb has patchedElement.prototype.attachShadowby then, and only the stop function it never returned undoes that. The dead session kept observing every shadow root the page attached afterwards and emitted into the NEXT recording, as duplicate shadow-text events.startRecordingnow restores the nativeattachShadowwhenrecord()fails. It surfaced asleak.test.ts's shadow-DOM cases failing only when they ran afterrecorder.test.ts.0.8.0—identify()tells you the flags changed, and a failed one no longer kills live updates (#788).identify()swaps the whole flags map and emitted nothing, so a component that rendered against the anonymous bootstrap kept that value for the life of its mount while the server evaluated the real user's bucket on every request. It now emitschangewith the new payload. If its bootstrap rejects it still rejects — an app awaiting it on login has to see that — but it now also schedules a reconnect, where before the stream was left dead with nothing pending and nothing reported. Reconnects back off exponentially (1 s doubling to a 30 s ceiling, reset by a deliveredflagspush) instead of re-bootstrapping every second for the length of an outage.flag(key, default)now returns your default when the stored value's type disagrees with it. It was a cast (as T), so a flag delivered as the string"false"read as ON through a boolean gate.null/undefineddefaults opt out of the check, sincetypeofdescribes the absence of a value for both.The replay sensitive-field word list is no longer English-only (#823). It now carries the common Spanish, French, German, Portuguese, Japanese and Chinese spellings of the same fields, and normalization folds accents instead of deleting them (
contraseña→contrasena,Straße→strasse) — without which every accented entry would have missed by a character. A localised app previously got essentially nothing from this layer. Annotatingautocompleteis still the recommended integration step: it is language-independent and is checked first.Why MINOR:
changenow fires on a path where it did not, a type-mismatched flag stops being returned, and a localised app's fields are masked where they were recorded — so a pinnedcollectRecordedStringssnapshot will differ. Every move is in the safe direction, but a consumer sees different behaviour either way.0.7.0— a marker on a shadow host now protects its shadow root (#821), and the verification helper sees what the recorder sees (#822).hasMarkerAncestorwalkedparentElement, which isnullat a shadow boundary, sopharos-mask/pharos-excludeon a web component's host protected nothing inside it — while rrweb recorded that content regardless. Established empirically before the fix: rrweb captures shadow-root text unconditionally (no recorder option gates it), andmaskTextHookis invoked for those nodes, so the decision function was the sole failure point. The walk now re-roots throughgetRootNode()/.host, bounded against a malformed tree.collectRecordedStringsdid not traverse shadow DOM either. That was not a compensating margin — the auditor's blind spot lined up exactly with the recorder's leak surface, so an app could run the helper, get a clean report, and be shipping shadow-root content. It now traverses, andpathOf()renders a boundary crossing as>>>rather than truncating the path to''(which was indistinguishable from a root-level row).Why MINOR, not PATCH: two consumer-visible outputs change. Content inside a marked shadow host is now masked where it previously shipped in the clear, and
collectRecordedStringsreports strings it previously omitted — so a pinned audit snapshot will differ. Both changes are in the safe direction, but a consumer sees different output either way.STRUCTURAL_ATTRSis unchanged in membership and is now derived from a justification map: each entry declares akind—closed-enumeration(which must name its finite vocabulary) orcost-tradeoff(which must carrymeetsImpossibilityBar: false).classis recorded honestly as the second kind. The gate cannot verify a claim's truth; that stays human review.The first round of that fix only reached the FULL SNAPSHOT. rrweb decides whether to consult
maskTextFninneedMaskingText, which returns "nothing to mask" for any node that is not an element and has no parent element — and a text node whose parent is aShadowRootis exactly that. So on the incremental streams (characterData,childListadds) the decision function was never called at all and a marked host still shippedshadowRoot.textContent = user.namein the clear once recording had started. An element-wrapped<p>inside the same shadow root did not reproduce it (rrweb takes its element branch there), which is how it shipped. The recorder now post-processes both streams through the same policy, and applies it over rrweb's answer in the snapshot too — in the masking direction only, never unmasking.pharos-excludeacross a shadow boundary shipped attributes. rrweb'sisBlockedusesclosest(), which does not cross a boundary, so an add into an excluded host's shadow root was not dropped — and the scrub pass judges a serialized node through a detached surrogate that by construction has no host and no ancestry. Measured:<p title="SECRET">TEXT</p>appended into an excluded host's shadow root shipped itstitleverbatim while its text was masked. Such adds are now dropped, judged against the live parent the mutation names by id.Snapshot noise to expect on your first re-run: shadow roots almost always contain a
<style>, andcollectRecordedStringsnow reaches it and reports its CSS text as an ordinary text row. That is not a leak — author CSS is not user content — but a pinned audit snapshot will grow by more than the app's own strings.Still not covered:
- Closed shadow roots, which rrweb cannot reach either (
n.shadowRootisnull), so neither the recorder nor the helper sees them. - The full snapshot's own answer for a bare shadow text node is rrweb's,
not the policy's, and it flips on the host's shape:
needMaskingTextreturnsfalsefor an element with no child nodes, so whether a shadow subtree inherits masking depends on whether the host happens to have light-DOM children. The policy is now applied over the top, but only ever to mask MORE — so an unmarked host's bare shadow text can be masked in the snapshot whilecollectRecordedStringsreports it. The report is the truthful one: the recorder ships that string verbatim on the incremental stream. - An excluded host's shadow subtree can still leave structural traces.
Content is dropped, but rrweb's mirror holds nodes this SDK removed from
the
addsstream, so a laterremovesentry can carry their ids. Ids and tree shape, never text or attributes. - Light-DOM content projected through a native
<slot>is no longer on this list: it was measured and is covered. A slotted node is an ordinary light-DOM child of the host — rrweb serializes it under the host and the helper reaches it viael.children— so the two agree with no slot-specific handling, and a marker on the host governs it through ordinary light-DOM ancestry. There is a test for it so it does not have to be re-derived.
- Closed shadow roots, which rrweb cannot reach either (
0.6.0— first npm release, as@loxel.dev/pharos-browser. Ships built output with anexportsmap, so it resolves as an ordinary package.Breaking: the recorder moved to the
@loxel.dev/pharos-browser/replaysubpath (startRecording,startReplayUpload,resolveSessionId,SESSION_STORAGE_KEY,SESSION_DROPS_KEY), andclient.startReplay()now returns a promise. Everything else is unchanged on the root.Why:
rrwebwas reachable from the root entry, so it landed in every bundle whether or not anything recorded — +204 kB raw / +63 kB gzip on a real application with replay switched off. It is now loaded on demand, costing 8.5 kB raw / 2.9 kB gzip.0.5.2— stops shipping most checkpoint segments twice. A segment flushed by the unload path while still open was shipped again when it closed, roughly doubling ingest for always-on recording. No API change.0.5.1— replay-upload correctness.droppedWindowsis now cumulative across page loads. A window persisted by a tab switch is uploaded when the tab becomes visible again rather than waiting for a full page load. AddsstartReplay(), and a warning when replay and errors would be reported under different session ids.0.5.0— session-replay upload:startReplayUpload(), gzip + JSON envelope toPOST /api/v1/client/replay, IndexedDB rest for windows triggered during unload, a durable per-tab session id, anddroppedWindows.0.4.0— session-replay recorder:startRecording()with a pluggable sink, a checkpoint-segmented ring buffer, activation-mode capture that never reads key identity, error-driven windows, and self-degradation under load.0.3.0— session-replay privacy contract:pharos-exclude/pharos-maskmarkers, field-sensitivity detection, attribute/URL/style scrubbing, andcollectRecordedStringsfor verifying what a recorder would send.0.2.0— error reporting:captureException, auto-capture of uncaught errors and unhandled rejections, andparseStack.0.1.0— initial release: bootstrap + SSE stream,flag/on/identify/close.
