Changelog#
All notable changes to @revu-ai/core are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.4.0] - 2026-09-23#
Give the server a second, independent source for the browser a visitor claims to be, so automated traffic that wears a convincing user agent can be caught by contradiction rather than by pattern matching a string against itself.
Added#
context.ua_brands/context.ua_mobile/context.ua_platform. The low-entropy User-Agent Client Hints, read synchronously fromnavigator.userAgentDataand stamped on every event. The browser engine generates these, so a client that sets a UA header or redefinesnavigator.userAgentdoes not get matching hints to go with it, and the server can cross-check one source against the other. Chromium and secure contexts only, so the trio is absent on Safari, Firefox, and plain-http pages and is simply omitted there. The brand list is passed through as reported, including the randomized GREASE entry, which the server compares verbatim. The high-entropy set (getHighEntropyValues()) is deliberately not read: it is asynchronous, so it would miss the first$pageviewthat the server pins the visitor's type from, and the full version list, platform version, and device model are fingerprinting-relevant.context.languages. The browser's ordered language list (the pluralnavigator.languages, alongside the existing singularcontext.language). An empty list is stamped rather than omitted, because an empty list is itself the signal. Low entropy: the same information already rides every request asAccept-Language.context.navigation_type. The Navigation Timing entry type for this page load (navigate,reload,back_forward, orprerender), so a genuine landing can be told apart from a reload or a back-forward. Both attribution models key off the visitor's first$pageviewand could not previously distinguish the three.
Fixed#
- The page-hide batch is no longer lost. The terminal flush delivered its batch with
navigator.sendBeaconand a body typedapplication/json. That is not a CORS-safelisted request content type, so cross-origin the request needs a preflight and a beacon does not survive one; every site is cross-origin to the ingest host.sendBeaconstill reported success, and the batch was dropped from the durable queue on the strength of it, so the events were gone with no retry and no error. Measured across Chromium, Safari and Firefox: the beacon never arrived, whilefetchwithkeepalivearrived in all three during a real unload. The terminal flush now usesfetchwithkeepalive. This was losing$page_leaveand itsengagement_time_ms, the$web_vitalevents (LCP, INP and CLS are emitted on page hide by design) and the last events of every session that ended by navigation or close, in every release so far. - A batch can no longer outgrow what the browser will send.
maxBatchbounds a batch by event count, which says nothing about its serialized size, and a browser refuses akeepaliverequest whose body exceeds roughly 64 KiB (measured: 64 KiB accepted, 65 KiB rejected, and less than that when another such request is in flight, since the quota is shared per origin). Both send paths setkeepalive, so a batch of events carrying rich properties could be refused on every attempt and wedge the durable queue behind it indefinitely. A batch is now halved until it fits and the remainder waits for the next flush. A single event too large to split is sent withoutkeepaliveon the normal path, where the page is alive and the limit does not apply, rather than being dropped or retried forever. - A batch is removed from the queue by identity, not by position. Confirmation is asynchronous now, so a terminal send and a normal send can be in flight over the same batch, and whichever confirms first shifts the queue. Removing "the oldest N" at that point would delete whatever had moved to the head, including events that were never sent. Each confirmation now removes exactly the events it delivered, so any interleaving is safe.
- The SDK no longer sends a terminal batch twice itself. A desktop close fires both
pagehideandvisibilitychange -> hidden. The old path committed synchronously, so the second signal found an empty queue; with asynchronous confirmation the second signal would have re-sent the batch the first was still delivering. A send already in flight now suppresses that duplicate. Delivery remains at-least-once by design, and in practice a terminal batch often does arrive twice: a browser may replay an in-flightkeepaliverequest when it tears the page down, and a second terminal signal legitimately flushes again when events were emitted between the two, carrying the still-unconfirmed earlier events with it. Both are measured behavior rather than theory, in Chromium, Safari and Firefox. Neither is worth preventing, because the only way to suppress the overlap is to drop events before their delivery is confirmed, which is the bug fixed above. Ingest is idempotent on the client-generatedevent_id, so a repeated batch is discarded rather than counted twice; anything you build on this data should key onevent_idand not assume one delivery per batch. - A failure that throws instead of rejecting can no longer escape the page-hide flush.
fetchthrows synchronously under a CSPconnect-srcblock, and on a page that has replaced it with something of its own. The terminal request is deliberately not awaited, so such an error surfaced in the host page as an unhandled rejection. It is contained now, the batch stays queued, and a later terminal signal can still retry. - A confirmed page-hide send now clears the retry backoff. Each failed flush during an outage lengthens the backoff, up to a minute between attempts. The normal path resets that on a successful send, but the page-hide path did not, so a session that had been backing off kept refusing normal flushes even after a terminal send had confirmed the endpoint was healthy again, leaving a backlog stranded that could have been draining. Both paths now clear it.
- Nothing leaves the queue unconfirmed. The terminal batch is now removed only on a confirmed 2xx, which a page that survives the signal does see (a tab switch or a mobile backgrounding fires the hide signal without tearing the page down). When the page really goes away the response never arrives, the batch stays queued and ships on the visitor's next page load, where the endpoint discards it if it already landed, keyed on the client-generated
event_id. The previous behavior traded that duplicate for a permanently lost batch.
- Array-valued context fields are no longer shared between events.
context.ua_brandsandcontext.languagesareFrozenArrayvalues owned by the browser engine. They are now copied when read and copied again per event, so abeforeSendhook that adjusts one event's list cannot leak that edit into every later event on the page, and the engine's own arrays are never handed out. Every other context value is a primitive and was never affected. - A hostile
navigatorgetter can no longer suppress all capture. User-agent spoofing extensions and privacy tools replacenavigatorproperties with getters of their own, and a badly written one throws on read. Everynavigatorread in the context layer is now guarded, so such a getter costs at most the field it belongs to instead of failinginit()and leaving the visitor with no analytics.userAgentData, the property those tools replace most often, is read last so a throw there cannot cost the signals collected before it.
Changed#
- The size budget is now one number instead of four.
packages/core/.size-limit.jsdeclares a single brotli budget of 10 kB, which is what "cold-loads in single-digit kilobytes" means for a browser downloading the SDK from the CDN, and derives the gzip (fallback transfer) and raw-minified (parse cost) gates from it. Previously each gate was an independent figure, so a gate could be raised on its own to admit a change; now buying room means raising the one budget, which is a deliberate decision rather than a build fix. The raw-minified gate is also expressed as a maximum ratio to compressed size rather than a fixed byte count, so it detects the one thing it usefully can: the bundle growing faster uncompressed than compressed. Brotli was never gated before this, despite being the figure the README quoted.
- Web Vitals moved out of core into
@revu-ai/core/vitals. BREAKING for module consumers. Core's one job is behavioral capture, and page performance is a different question about the same page: a host can reasonably want every click and no vitals, or the reverse. In core it cost every visitor its bytes whether the host wanted it or not, which is what the plugin seam exists to prevent. Core is now 9.31 kB brotli (from 9.83), and the plugin is 0.83 kB that only an importer pays.
```js import revu from "@revu-ai/core"; import webVitals from "@revu-ai/core/vitals";
revu.init({ apiKey: "revu_pk_...", plugins: [webVitals()] }); ```
The <script> install is unchanged and needs no action: a tag consumer has no import to make and cannot tree-shake, so the CDN bundle registers the plugin itself and keeps reporting vitals exactly as before. The captureWebVitals option is gone, since installing the plugin or not is now the switch; passing it is simply ignored.
- The measurement boundary is written down.
docs/concepts.mdgains "What the SDK measures, and what the server works out", stating the rule the SDK follows: it computes a value only when that value cannot be reconstructed from what it would otherwise send. Scroll depth, engagement time, idle and active durations, and Web Vitals are the entire list, each with the reason it is on it. Everything else, including user agent parsing, campaign attribution from a URL, geography, sessionization and bot classification, is worked out server-side from the events that arrive, so it can be corrected and re-run over history without anyone redeploying. - The durable queue is stored in chunks rather than as one blob.
localStorage.setItemrewrites whatever it is given in full, so appending one event to a long queue cost a serialization and a write of the entire queue, on the capture path, for every event. The queue is now mirrored as a series of 64-event chunks with a small index, so an append rewrites one chunk. Events at the cap are also dropped a block at a time rather than one at a time, because pruning a single event on every append would shift the front and dirty every chunk, undoing the point. The cap is still never exceeded and pruning is still oldest-first. A queue written by an earlier version is read in its old layout and rewritten in chunks on the next append, so an upgrade never loses a pending event.
Performance#
Measured on the built bundle in Safari 27 and Firefox 155, seven interleaved rounds against the previous build, medians reported.
- Building an event's context costs 0.26 us (Safari) / 0.44 us (Firefox) per event, up 0.02 us and 0.06 us respectively. The per-event copies of
ua_brandsandlanguagesintroduced in this release are the reason those numbers moved at all, and they are the smallest part of it. - A full
capture()call no longer gets slower as the queue grows. On a queue holding 1000 rich events, an append cost about 2.5 ms before the chunked layout and about 0.1 ms after it, and the figure is now flat from an empty queue to a full one instead of rising with depth. This matters most in the case that produces a long queue in the first place: a session capturing steadily while the network is unavailable. - A full
capture()call costs 44 us (Safari) / 58 us (Firefox) per event, up around 6 us and 4 us. That figure is dominated by the durable queue's synchronous storage write, not by building the event, so it scales with the serialized size of an event rather than with the work done to create one. An event grew by 49 bytes (Safari) / 56 bytes (Firefox), about 6 to 7 percent, and the added time tracks that growth. - With the queue at its 1000-event cap, the worst case,
capture()costs 164 us (Safari) / 330 us (Firefox). This is the storage write scaling with a full queue and is unchanged in character from previous releases.
Size#
- Bundle size: 9.35 kB brotli on the wire / 10.41 kB gzipped / 34.01 kB minified, plus 0.83 kB brotli for the vitals plugin if you import it. The
<script>bundle, which includes the plugin, is 9.9 kB brotli. The chunked queue accounts for around 0.27 kB of that and buys a 25x reduction in append cost at depth.
[0.3.0] - 2026-09-05#
Capture the browser automation signal so the server can separate headless and synthetic traffic from real visits, even when the automated client wears a normal browser user agent.
Added#
context.webdriver.navigator.webdriveris stamped on every event when the browser reports automation (Selenium, Playwright, Puppeteer, headless Chrome, and most synthetic monitors), and omitted otherwise so a genuine visit adds no bytes. It rides the session-scoped context likecontext.gpc, so it lands on the first$pageviewand every subsequent event, which lets the server classify automated traffic that carries a human-looking user agent (a headless browser on a normal UA string, which server-side user-agent parsing cannot catch on its own). Absence means "not automated", consistent with the server treating an unclassified visit as human.
Size#
- Bundle size: 33.78 kB minified / 10.4 kB gzipped (around 9 kB brotli on the wire), still under the 34 kB / 12 kB CI gate.
[0.2.0] - 2026-06-22#
Clean, unified identity that does not over-merge on shared devices. A family, home, library, or kiosk computer (one OS login, several people using the same web app) previously risked collapsing into a single person; sequential users on one device are now tracked separately.
Changed#
autoIdentifynow defaults tofalse(wastrue). Anonymous visitors are identified byanonymous_idalone anduser_idstaysnulluntil you callidentify(), so a non-nulluser_idalways denotes a real authenticated account. This also makes the dashboard's "identified only" filter meaningful (previously every visitor had an auto id). SetautoIdentify: trueto restore the old per-device auto id, though it is no longer recommended. Wire impact: pre-login events now shipuser_id: nullinstead of an auto UUID.reset()now rotates theanonymous_id(in addition to the session and user id). Logout severs the device thread so the next person on a shared device starts a clean identity. A returning user re-unifies by theiruser_idon the nextidentify(), so rotation does not fragment them.identify()treats a switch to a different known user as an implicit logout. When a different account logs in, the SDK emits$reset, rotates theanonymous_id, and does not stitch the two user ids together, so two accounts never merge just because they shared a device, even if the host did not callreset()on logout. An anonymous-to-identified transition still binds the existing device to the user as before.- Campaign attribution is now visitor-scoped and cleared on logout.
reset()(and the implicit logout above) now clears first-touch and last-touch attribution along with theanonymous_id, so the next person on a shared device does not inherit the previous person's acquisition campaign. The server still derives per-event campaign from the$pageviewURL; only the persisted cross-session copy is rotated.
Added#
- Debug-mode integration hint. In
debug: true, the SDK logs a one-time console hint if events flow for a while withoutidentify()ever being called, in case an app with logins forgot to wire it. Silent in production and silent onceidentify()is called; the message notes that intentionally anonymous-only sites can ignore it. - Identity integration contract documented in
docs/concepts.md(callidentify()on login,reset()on logout,alias()to join two accounts) with the shared-device guarantees spelled out per setup. - Device-id management API.
revu.getAnonymousId()returns the current device id (parity with other SDKs'getDeviceId());revu.regenerateAnonymousId()mints a fresh device id on demand, rotating only the device id and leaving the user, session, and consent intact (for an explicit device reset outside the logout flow).
Size#
- Bundle size: 33.71 kB minified / 10.58 kB gzipped (around 9 kB brotli on the wire), still under the 34 kB / 12 kB CI gate.
[0.1.0] - 2026-06-21#
First public release. Lean capture core for web behavioral analytics: one-line install, autocapture out of the box, category consent with GPC, client-side campaign attribution, a durable offline queue, persistent first-party identity, and zero runtime dependencies.
Added#
- One-line boot.
revu.init({ apiKey })wires capture, identity, consent, attribution, transport, and the queue in a single call. Every public entry (init,capture,identify,alias,reset,optOut,optIn,hasOptedOut,consent.set/consent.get,flush,use) is wrapped withsafe()so the SDK can never throw into the host page. - Autocapture.
$pageview(initial load plus SPA route changes via pushState / replaceState / popstate / hashchange),$autocapture(clicks anywhere),$rightclick,$rageclick(3 clicks on the same target within 1 s),$scrollmilestones (25 / 50 / 75 / 100%),$resize(debounced to settled value),$form_submit(field metadata only, never values),$change(control type plus checkbox / radiochecked, never values),$file_download,$outbound_link, and$page_restore(back-forward cache restore). Each element event carries a stable selector fingerprint (tag, text, role, id, classes, ordinal) plus the route path. - Engagement layer.
$page_leavewithengagement_time_ms(visible time on the page; hidden time excluded, idle time included),$tab_hidden/$tab_visiblewith paired durations,$idle/$activewith paired durations (defaultidleTimeoutMs: 30_000, off when set to0).captureAttention: falsekeeps the engagement clock but suppresses the synthetic events. - Web Vitals.
$web_vitalevents for LCP, INP, and CLS emitted on terminal page lifecycle (pagehide/ visibility-hidden). Pure PerformanceObserver, zero runtime dependencies. Disable withcaptureWebVitals: false. - Category consent + GPC. Three consent categories (
analytics,marketing,functional) viarevu.consent.set({ ... })/revu.consent.get(), withrevu.optOut()/revu.optIn()/revu.hasOptedOut()as aliases for denying / grantinganalytics. Onlyanalyticsgates capture (a denied analytics category suppresses every event before it is built);marketingandfunctionalare declarative and stamped on every event ascontext.consentfor the server to honor on downstream destinations. Global Privacy Control is stamped ascontext.gpc, andhonorGpc(default off) defaultsanalyticsto denied on a GPC signal unless the visitor has made an explicit choice. State persists in the first-party store; a legacy binary opt-out is honored on upgrade. - Campaign attribution. First touch (
context.initial_utm_*,initial_gclid,initial_fbclid, plusinitial_landing_path/initial_seen_at) is captured once and never overwritten; last touch (context.utm_*,gclid,fbclid) refreshes on each new campaign or external-referrer landing. Both persist client-side so a conversion pages or days later still carries the campaign that acquired the visitor; the server still derives a session's immediate landing from the$pageviewURL. - Persistent identity.
anonymousId(device-level) generated on first visit and persisted across reloads.userId(person-level) withautoIdentifydefault-on: a UUID is auto-generated and persisted; a laterrevu.identify("real-id")replaces it.revu.reset()rotates to a fresh auto id.sessionIdrolls forward across reloads inside a 30-minute continuation window (sessionTimeoutMs); set to0to give every page load a fresh session. An absolute cap (sessionMaxMs, default 24 h) rotates even a continuously-active session so a long-lived tab or kiosk does not accumulate one multi-day session.- Both persistent ids mirrored to
localStorageand a first-party cookie by default, so eviction of one store recovers from the other. Switch withpersistentStorage: "localStorage"to drop the cookie. cookieDomainconfig shares one visitor across subdomains.
- Identity transitions.
$identify,$reset(withprevious_user_id), and$aliasevents so the server can join the pre- and post-login behavioral graph and stitch a person across devices.revu.alias(authoritativeId)declares "the current id is the same person asauthoritativeId" without changing the local user id (motivating flow: sign up on desktop, click an email link on phone). Idempotent on the server; distinct fromidentify(), which replaces the local id. - Pipeline hooks.
beforeSend(event)runs on every built event just before it is queued: return the event (mutated or replaced) to send it,null/falseto drop it, or nothing to send it unchanged. Fail-open (a throwing hook sends the original event), and the returnedpropertiesare re-sanitized so a hook cannot poison the durable queue.autocaptureAllowSelectors/autocaptureDenySelectorsscope element-targeted autocapture by CSS selector; deny wins and suppresses the file-download / outbound-link / rage events derived from a click too. - Session sampling.
sampleRate(0-1, default1) drops whole sessions before they queue. The decision is session-sticky (a session is captured or skipped whole, never half), identity events are always sent, and kept sampled events carrycontext.sample_rateso the server can scale aggregates. - Durable transport. Batched JSON POST to
/v1/behavior/events.fetchwithkeepalive: truewhile the page is live;navigator.sendBeacononpagehide/visibilitychange=hiddento flush the last batch on unload. Capped exponential backoff on transient failures;event_idis the idempotency key so a retried batch de-dupes server-side. - Durable offline queue.
localStorage-backed buffer survives reloads and offline gaps. Auto-flushes on theonlineevent so events captured offline ship the moment connectivity returns. An unserializable event is quarantined so one bad event never blocks the queue. - First-party ingest. Point
hostat your own domain to route events first-party (data-completeness measure); reverse-proxy recipes (Cloudflare, nginx, Caddy, Next.js) are indocs/first-party-ingest.md. - Plugin contract.
revu.use(plugin)orinit({ plugins: [...] }). Plugins registered before init are queued and drained on init. The Web Vitals layer ships through this contract as a built-in plugin. - Event shape:
context+properties. Every event carries a top-levelcontextobject (engine environment, unprefixed:user_agent,language,timezone,screen_*,viewport_*,online,connection_*,environment,sdk_version,consent,gpc,sample_rate, and attribution) separate fromproperties(the event's own payload plus callercapture()props). The two buckets never collide, so there is no$-prefix and no caller-vs-engine merge - the de-facto context-vs-properties shape every warehouse / BI / SQL consumer expects.properties.pathremains the per-event path;screenis the top-level route. - SDK build version.
context.sdk_versionis stamped on every event so the server can correlate behavior with SDK versions when investigating a regression or rolling out a fix. The same string is exposed asrevu.version. Source of truth ispackage.json;src/version.jsis regenerated byscripts/sync-version.js(theprebuildhook) so a release-day bump touches one file. The generated file is committed so the vanilla example runs straight fromsrc/. - Environment label.
environment: "production" | "staging" | "development"config field (default"production") stampscontext.environmentso the dashboard can keep dev and staging traffic out of the production view. Invalid values throw at init. - Input masking and redaction. Input values are never read from any field. Click fingerprints on
<input>/<textarea>/<select>/contenteditable/[data-revu-mask]subtrees are redacted (tag, role, and selector survive; text,aria-label, andtitleare dropped);$changeskips password / file / hidden inputs entirely; form submits emit metadata only (field_names,field_types,field_count), never values. Credential and PII values in captured URLs and referrers - in both the query string and the fragment (e.g. an OAuth implicit-flow#access_token=...) - are scrubbed at source while UTM and click ids are preserved. - Shadow DOM coverage. Clicks inside open Shadow DOM custom elements are captured against the actual internal element via
composedPath()instead of the retargeted host. Ancestor walks in the fingerprint (selector path) and masking check (data-revu-mask) cross the shadow boundary viaShadowRoot.host, so a mask on the host applies to its shadow tree and selectors keep their full ancestor context across Web Components and component-library UIs.
Architecture#
- Vanilla JavaScript (ESM) with JSDoc as the single source of truth for both runtime and emitted
.d.tstypes. - Zero runtime dependencies. No published package declares a
dependenciesentry. Platform and Web APIs only. - Tree-shakeable.
"sideEffects": false; unused modules drop out at the consumer's bundler. - Bundle size: 32.41 kB minified / 10.22 kB gzipped (around 9 kB brotli on the wire), under the 34 kB / 12 kB CI gate.
- Defensive boundary. Every public entry is
safe()-wrapped; internal errors are swallowed and (indebug: true) logged, never propagated to the host page.
Security#
- Input values are never read from any field (
<input>,<textarea>,<select>,contenteditable) - only interactions and structure. - Form submit events carry shape metadata only, never values.
data-revu-maskopts a subtree out of text and label capture;autocaptureDenySelectorssuppresses capture for a region entirely.- Credential and PII values in captured URLs and referrers are scrubbed at source - in both the query string and the fragment (UTM and click ids preserved).
- Consent is enforced before an event is built (a denied
analyticscategory produces no event); Global Privacy Control is honored whenhonorGpcis set. - The transport sends only fields explicitly built by the client; no DOM serialization, no cookie reads other than the SDK's own first-party identity cookie.
[0.3.0]: https://github.com/revu-ai/sdk-web/releases/tag/v0.3.0 [0.2.0]: https://github.com/revu-ai/sdk-web/releases/tag/v0.2.0 [0.1.0]: https://github.com/revu-ai/sdk-web/releases/tag/v0.1.0