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

@reopt-ai/data-sdk-client

v0.9.0

Published

reopt-data browser SDK — vanilla, React and Next.js App Router client components

Readme

@reopt-ai/data-sdk-client

Browser SDK for reopt-data. Three entry points, one client:

| Entry | Use it from | | --------------------------------- | --------------------------------------------------------------- | | @reopt-ai/data-sdk-client | a <script> tag, a plain SPA, any framework — no directive | | @reopt-ai/data-sdk-client/react | React client components ("use client") | | @reopt-ai/data-sdk-client/next | Next.js App Router — importing it from a server component is OK |

Server-side tracking (route handlers, server components, workers) lives in @reopt-ai/data-sdk-server. Importing that package from a client component fails at build time on purpose.

Install

pnpm add @reopt-ai/data-sdk-client

Next.js App Router

// app/layout.tsx — a server component
import { ReoptProvider, ReoptPageView, ReoptWebVitals } from "@reopt-ai/data-sdk-client/next";
import { getBootstrap } from "@/lib/reopt"; // @reopt-ai/data-sdk-server — optional

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const bootstrap = await getBootstrap(); // null when the server knows nothing about this visitor
  return (
    <html>
      <body>
        <ReoptProvider
          config={{ writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!, baseUrl: "/ingest" }}
          bootstrap={bootstrap}
        >
          <ReoptPageView />
          <ReoptWebVitals />
          {children}
        </ReoptProvider>
      </body>
    </html>
  );
}
  • ReoptPageView sends $pageview on every navigation — pathname and query string — and a $pageleave with time-on-page for the page it replaces. It is a sibling of your content, not a wrapper, so a navigation does not rerender your tree. It carries its own <Suspense>, so it will not knock a prerendered route into client rendering.
  • ReoptWebVitals forwards Next's Core Web Vitals as $web_vitals.
  • baseUrl: "/ingest" assumes the reoptProxy from @reopt-ai/data-sdk-server/proxy rewrites /ingest/* to your reopt-data deployment (the SDK sends /ingest/api/track, which lands on <reopt-data>/api/track). Without the proxy, pass the deployment origin.
  • bootstrap is optional. With it, the first render already agrees with the server about who the visitor is and what they consented to.

Then, anywhere in a client component:

"use client";
import { useTrack, useIdentify } from "@reopt-ai/data-sdk-client/next";

export function BuyButton() {
  const track = useTrack();
  return <button onClick={() => track("checkout_started", { plan: "pro" })}>Buy</button>;
}

React (any router)

import { ReoptProvider } from "@reopt-ai/data-sdk-client/react";

<ReoptProvider config={{ writeKey, baseUrl: "https://data.example.com" }}>
  <App />
</ReoptProvider>;

The provider sends the first $pageview itself (initialPageView={false} to opt out). Call usePageView() on route changes; there is no history.pushState patching.

Hooks: useReopt, useReoptClient, useTrack, useIdentify, usePageView, useConsent, useTrackOnMount, usePageViewOnMount.

The client is created during render and kept on window per write key, so a child's effect can use it on first mount, StrictMode's double render shares one instance, and two copies of this package on one page do not create two devices. It is not closed on unmount; call client.close() if you need to — the next render then creates a fresh client.

Vanilla

import { init, track, identify, pageView } from "@reopt-ai/data-sdk-client";

init({ writeKey: "wk_…", baseUrl: "https://data.example.com" });
track("signup_completed", { plan: "pro" });
identify("cus_123", { email: "[email protected]" });
pageView(); // sent automatically on init(); call this on client-side navigation

Configuration

init({
  writeKey: "wk_…",
  baseUrl: "https://data.example.com", // or "/ingest" behind the proxy
  bootstrap: null, // from @reopt-ai/data-sdk-server getBootstrap()
  identity: {
    storage: "auto", // cookie → localStorage → memory. Cookies survive Safari ITP; localStorage does not
    cookieDomain: undefined, // ".example.com" to share across subdomains
    cookieMaxAgeSeconds: 400 * 24 * 3600,
  },
  capture: {
    pageview: true, // first $pageview on init (the Next entry sets false — <ReoptPageView /> owns it)
    pageleave: true, // $pageleave with `duration` (seconds) and `scroll_depth`
    scrollDepth: true,
    exceptions: false, // $exception for uncaught errors and unhandled rejections; loads as its own chunk when on
  },
  release: undefined, // build id stamped on every event as $release_id; see below
  tracingHeaders: false, // opt-in: add reopt-device-id to same-origin fetch/XHR (true) or to listed hosts; loads as its own chunk
  normalizePath: (pathname) => pathname, // see below; applied to every path the SDK stamps itself
  fetch: undefined, // transport override for tests
  observe: undefined, // local lifecycle observer for devtools; never sent over the network
  consent: { categories: ["analytics", "marketing"], defaultConsent: true },
  batch: { size: 100, intervalMs: 1000, maxBytes: 400_000 },
  retry: { maxRetries: 3, baseDelay: 1000, maxDelay: 30000, jitter: 0.1 },
  circuitBreaker: { failureThreshold: 5, recoveryTimeout: 60000 },
  debug: false,
});

Release tracking

release stamps every event with $release_id. Error tracking reads it to say which build an issue first and last appeared in, and — once you resolve an issue "in" a release — to tell a plain reopen from a regression, where the fix you declared did not hold.

init({ writeKey: "wk_…", release: "1.4.0" });

Most apps do not know their version at the call site. The SDK falls back to __REOPT_RELEASE__, read as a bare identifier — the form a bundler define replaces, and one that also resolves a global of that name a script assigned. That is where a build step writes it (a non-string value is converted with String(); an undefined one is simply absent):

// vite.config.js
export default { define: { __REOPT_RELEASE__: JSON.stringify(process.env.GIT_SHA) } };

// next.config.js
module.exports = {
  webpack: (config, { webpack }) => {
    config.plugins.push(
      new webpack.DefinePlugin({ __REOPT_RELEASE__: JSON.stringify(process.env.VERCEL_GIT_COMMIT_SHA) })
    );
    return config;
  },
};

An explicit release wins over the global. With neither, the property is omitted entirely — not sent as an empty string, which would create a release named "" and label every issue with it.

The label is yours: a version, a commit sha, a build id. The server never issues one and never rejects an event for carrying an unfamiliar release.

Runtime-resolved keys, manual page views, external consent

The three things a multi-tenant host needs, in the shape it needs them:

// Server component: the write key comes from your database, per tenant, per request.
const writeKey = await resolveBrandWriteKey(brandId);
<AnalyticsMount config={{ writeKey, baseUrl: "/ingest", capture: { pageview: false }, consent: { persist: false } }} />;
  • capture.pageview: false and call pageView({ path, properties }) yourself — for example only when the page carries the markers that make it a real published page. There is no automatic page view you have to suppress, and init() is idempotent per write key, so a remount or a soft navigation never creates a second client.
  • Opt-out removes identity. Refusing analytics deletes the device cookie (the proxy does the same on its side) and the SDK writes none while refused; granting again writes the current device through. Note the bridge you own: with consent.persist: false the SDK keeps no consent cookie, but reoptProxy decides seeding from exactly that cookie — so a banner that manages consent itself must also write reopt_<writeKey>_consent (via serializeConsentCookie, single encoding) for the proxy to see it.
  • consent.persist: false when a consent banner already owns the decision: sync it with setConsent("analytics", allowed) before tracking, and the SDK keeps no cookie of its own that could disagree with the banner's. An undecided visitor is allowed (opt-out model).
  • normalizePath rewrites high-cardinality paths (/workspace/8f3…/customers/2a1… → /workspace/:id/customers/:id) and can lift the removed ids into properties for breakdowns. It runs wherever the SDK stamps a path itself — the default $pageview path, $pageleave, $web_vitals, $exception — and not on a path you pass to pageView() explicitly, so use the same function in both places. Synchronous, pure; if it throws, the raw path is used.
  • getDeviceId() returns the id this page is tracked under, so your own server call can carry it explicitly (submitForm(body, { deviceId })) and the server can record the conversion with deviceId on the event — the browser session and the server-side conversion then land on the same device without any header magic. null before init() or when analytics is disabled.
  • properties in init() sets those same global properties from the very first event — web vitals can fire the instant the client exists, before an effect gets to call register(). Give it the page context the server already knows.
  • register(properties) attaches properties to every event from then on — including the automatic ones. This is how a host puts its own breakdown axis (page_id, a tenant) on $web_vitals and $pageleave, which the SDK otherwise stamps with only a path. An event's own properties win; reset() clears them. It keeps init({ properties }) and the release, which describe the page rather than the visitor.
  • Fail-open. A missing writeKey or baseUrl logs a warning and yields a disabled client whose every call is a no-op; nothing throws. Analytics must never be why a page fails.
  • fetch injects the transport. A test can hand in a recording function and assert on the exact payload the SDK built, instead of intercepting the network.
  • observe receives local configuration, enqueue/drop, identity, consent, and pause/resume facts in lifecycle order. Its implementation loads as an opt-in chunk, buffers facts while that chunk arrives, ignores callback errors, and never receives the write key or event properties. Use it for diagnostics such as @reopt-ai/data-sdk-devtool, not application behavior.

Bootstrap and caching (Next.js)

bootstrap comes from getBootstrap() in @reopt-ai/data-sdk-server, which reads cookies(). Three ways that goes wrong:

  1. With cacheComponents: true, calling it inside a "use cache" function (or a helper that function calls) passes next build and fails at next start with next-request-in-use-cache — on dynamic routes only when the route runs, so it gets past your gate. Read it outside the cache, after await connection(), and pass the value down.
  2. Passing the cookies() promise as a prop into a cached component does not error; the build hangs or the prerender times out.
  3. Without cacheComponents (older apps, unstable_cache, fetch caching) the value really does get cached — every visitor gets the same device id. In those apps do not pass bootstrap; rely on the proxy's cookie seeding and the client's own identity.

Exceptions

$exception_list — the structured form

Alongside the flat keys, an exception carries the parsed cause chain. The flat keys still describe the outermost error and are unchanged, so anything written against them keeps working; $exception_list is what the issue page renders and what fingerprinting groups on.

| Field | Meaning | | --------------------- | ------------------------------------------------------------------------------------- | | type / value | TypeError / the message. For a non-Error throw the SDK derives both. | | mechanism.handled | false when nothing caught it. Causes are true — they were caught and re-thrown. | | mechanism.type | onerror | unhandledrejection | manual | console | nextjs_onRequestError | | mechanism.synthetic | The SDK invented the type because what was thrown was not an Error. | | stacktrace.frames[] | filename, function, lineno, colno, in_app — innermost first. |

An error event that arrives without an Error object — a cross-origin "Script error.", a ResizeObserver notice, throw null — is reported as type ErrorEvent with the browser's message (mechanism.synthetic). When the browser gave a location, that location is its one frame, so it groups and symbolicates like any other frame instead of folding every such error into one message-only issue.

{
  "$exception_type": "TypeError",
  "$exception_message": "cart.items is not a function",
  "$exception_list": [
    {
      "type": "TypeError",
      "value": "cart.items is not a function",
      "mechanism": { "handled": false, "type": "onerror" },
      "stacktrace": {
        "type": "raw",
        "frames": [
          {
            "filename": "https://shop.example.com/main.a1b2c3d4.js",
            "function": "handleCheckout",
            "lineno": 412,
            "colno": 19,
            "in_app": true
          }
        ]
      }
    }
  ]
}

Throttling

Automatic capture is throttled per exception type — a token bucket of DEFAULT_EXCEPTION_RATE_LIMIT.bucketSize with one token back every DEFAULT_EXCEPTION_RATE_LIMIT.refillSeconds (src/capture/exception-rate-limit.ts). One component stuck in a rejection loop can fire thousands of identical events a second; unthrottled that fills the visitor's network and the customer's quota with a single bug, and buries every other error in the issue list.

capture: {
  exceptions: true,
  exceptionRateLimit: { bucketSize: 20, refillSeconds: 5 }, // or `false` for no limit
}

Per type, so a runaway TypeError cannot silence a RangeError that starts later. Never applied to captureException() — a report you asked for explicitly is not noise the SDK gets to drop. When throttling kicks in, debug: true logs it once per type. The bucket is checked before the stack is parsed, so a throttled storm costs no parsing, and only an exception that is actually reported spends a token — one dropped as browser-extension noise does not.

Breadcrumbs

$exception_steps carries what happened just before the error — up to 20 steps, each with a 200-character message and at most 1 KB of serialized data. data is copied when the step is added: changing the object afterwards does not change the trail, and data that cannot be serialized (circular, a throwing getter) or is over the bound is dropped rather than cut in half.

client.addExceptionStep({ category: "fetch", message: "POST /orders", data: { status: 500 } });
// …later, any exception carries the trail

capture.exceptionSteps: true also records a navigation step on every page view. Off by default: navigation history on every error is a privacy decision the host makes, not the SDK. addExceptionStep() works either way.

The trail is copied onto an exception, not moved — a second error in the same session sees the same history rather than an empty one. One buffer per page, not per client.

A cause chain is carried outermost first, up to four links. A stack made entirely of chrome-extension:// frames is not reported — it is a bug in a visitor's extension, which the site cannot reproduce or fix.

captureException(error, options?) takes { fingerprint?, level?, properties? }. fingerprint is a suggestion — the server bounds it and decides — and is how you say "group these two apart" or "group these together". The pre-options shape captureException(error, { myProperty: 1 }) still works: an object carrying none of the three option keys is read as properties.

captureException() is the one place the list can be absent. The parsers live in the exception chunk, and a manual capture made before that chunk has loaded reports the flat keys only; the server falls back to fingerprinting those, so the event still groups. Turning on capture.exceptions loads the chunk at init, which is the usual case.

Size

The production bundle is checked on every build (scripts/check-size.mjs): each entry is bundled as a consumer would (minified, NODE_ENV=production, framework externals) and must stay under the per-entry gzip budget that script declares (BUDGET_GZIP_BYTES — tightest for the vanilla entry, looser for ./react and ./next, which add the provider, hooks and page-view component on top) and carry no zod. Opt-in features (observer formatting, tracing headers, exception capture) are separate chunks. Event validation in production is a hand-written mirror of the contract; the zod schema runs in development only and is dropped by the consumer's bundler.

What it does that you would otherwise have to remember

  • Identity in a cookie. reopt_<writeKey>_device, SameSite=Lax, Secure on https, 400 days. The server SDK and the proxy read the same cookie, so server-side events land on the same device. An id from the previous SDK generation (localStorage reopt_device_id) is migrated on first load.
  • Consent in a cookie (reopt_<writeKey>_consent), so the server stops sending when the browser does. Refusing analytics stops every category. Withdrawing a category removes its queued and persisted events and excludes them from retries; re-granting does not resurrect them. Requests already sent cannot be recalled. Pausing instead retains the queue. A refusal that is only the configured default (defaultConsent: false, before your banner applies the visitor's decision) deletes nothing either: persisted events wait and are sent once consent is granted. A decision made in the browser beats the server's bootstrap, and a write in one tab starts from the shared cookie, so a stale tab cannot re-grant what another tab withdrew.
  • reset() becomes a new device. Log out on a shared computer and the next person is not attributed to the previous one.
  • Byte-aware batches. Requests stay under the server's 512 KB cap; unload flushes stay under fetch's 64 KB keepalive limit; an event that could never fit is refused at track() with reason: "payload_too_large" instead of being dropped by the server later.
  • Offline queue in localStorage: one record per event, scoped by write key even with a custom storagePrefix (<storagePrefix>queue:v2:<eventId>:<writeKey>). Writes are immediate; opening another tab does not consume the backlog or overwrite it. Records remain until acknowledgement, permanent rejection, overflow, reset or withdrawn consent. Event ids, consent categories and the device each event was recorded under survive reloads: an event another tab or an earlier page recorded before a reset() goes out under that device, never the new visitor's. A restored record the ingest contract would refuse is discarded rather than sent, so it cannot cost the batch it would have joined. Legacy unscoped queues are not restored because their destination cannot be established safely.
  • Cross-tab delivery. Browsers with Web Locks serialize delivery for the same project/storage prefix. Another owner makes flush() return skipped; a later attempt reads the shared journal again. Without Web Locks, delivery can overlap and ingest deduplicates by event id. The page-hide/unload flush never waits for the lock — a lock is granted in a later task, after the page is gone — so its keepalive request starts at once and may repeat another tab's; ingest deduplicates those too. A beacon a runtime accepted retains its journal record because acceptance is not a server acknowledgement; the next ordinary flush sends it again. Custom storage can implement optional keys(): string[] to use the journal; stores without enumeration retain a single-writer snapshot buffer. Storage denial or quota exhaustion falls back to memory.
  • Quota without loss. When the workspace's monthly data points run out partway through a batch, ingest keeps what fits and hands the rest back; the SDK keeps those events queued and sends them with a later flush. A batch refused whole (quota_exceeded) is kept the same way. Neither is retried in a loop, and neither opens the circuit breaker: after either, the client sends nothing — no timer, explicit flush(), close() or unload — until ingest's Retry-After has passed (the engine's QUOTA_HOLD_MS when ingest names none, the same hold ingest advertises). The queue is kept for the next attempt.
  • No site cookies. Ingest requests go out with credentials: "omit": ingest authenticates with headers, and the /ingest proxy must not carry your own session cookies to a third party.
  • Says which SDK sent it. Every ingest batch carries reopt-sdk: data-sdk-client/<version> (SDK_HEADER in @reopt-ai/data-contract/identity), so reopt-data can tell you when a project still runs a release with a known defect. A proxy of your own in front of /api/track must forward it, as @reopt-ai/data-sdk-server/proxy does. Replay uploads do not carry it.
  • Refused at the call, not by the server. An event the ingest contract would reject — an identify whose email, avatar URL or name the contract refuses, say — returns reason: "validation_failed" with the field, instead of being queued and making ingest refuse the whole batch it travels in.
  • close() is final for that instance. Calls on a closed client return reason: "client_closed", and init(), the React provider and the module functions create a fresh client instead of handing the closed one out.
  • Delivery completion. flush({ drain: true }) and close() wait for active requests as well as queued batches. Failed delivery remains visible in the flush result; awaiting completion is not a promise that an unavailable server accepted the events.
  • Stable event input. Properties are snapshotted when queued. Circular values, BigInt and throwing serializers return queued: false with reason: "validation_failed", without throwing into the application.
  • Corrected clock. With a bootstrap, a device clock more than 30 s off the server's is corrected before events are stamped.
  • Event ids are UUIDv7, so they sort in creation order.

Events it sends

Names and property keys are exported from @reopt-ai/data-contract/events (AUTO_EVENT_NAMES, AUTO_EVENT_PROPERTIES); the low-cardinality subset suitable as rollup dimensions is AUTO_EVENT_ROLLUP_KEYS. Derive catalogue entries from those rather than retyping them.

| Event | Properties | | ------------- | --------------------------------------------------------------------------------------------------------------------------------- | | $pageview | path, origin, title, referrer, utm_*, search (Next, when present), plus anything normalizePath lifts | | $pageleave | path, origin, duration (s), scroll_depth (0–100) | | $web_vitals | metric_name, metric_id, value, delta, rating, navigation_type, path | | $exception | $exception_type, $exception_message, $exception_stack, $exception_source, $exception_handled, $exception_list, path |

Session replay (workspace; npm release pending)

Replay is opt-in and loads @rrweb/record only after project sampling and consent. Enable replay for the project in the Data console, then configure the browser client:

const client = init({
  writeKey: publicWriteKey,
  baseUrl: "/ingest",
  sessionReplay: { enabled: true },
});
// Call only after your consent UI receives the visitor's recording decision.
client.setConsent("replay", true);
await client.flushReplay();
client.setConsent("replay", false);

Both analytics and replay consent must be granted. Keep replay out of default-granted consent categories. consentCategory can select an existing category only when that category represents recording consent. flushReplay() attempts buffered delivery; it does not override sampling, consent, retry delay, or prove that a recording exists. Use the collector response/request ID or console to verify acceptance.

Chunks are gzipped in the browser with CompressionStream when available (Content-Encoding: gzip), which cuts the visitor's upload roughly tenfold; browsers without it send JSON. A cross-origin baseUrl needs the collector's preflight to allow Content-Encoding (current reopt-data deployments do); if a compressed chunk fails in transport, the recorder retries it uncompressed, and after that works twice it stops compressing for the page. A session the server does not sample is remembered until the analytics session changes, so an unsampled visitor sends one start request per session rather than one per minute. A spent quota (quota_exceeded on start, replay_quota_exceeded on a chunk) is not retried like a rate limit: the stream stops and the recorder asks again only after the server's Retry-After, as given — or after the recorder's own REPLAY_QUOTA_HOLD_MS when the server names none (shorter than the event engine's QUOTA_HOLD_MS: a replay that starts late loses the start of the session for good). A project with capture off (403) is asked again after CAPTURE_OFF_HOLD_MS. A stream that fails three times in a row on the same page (for example a page whose DOM never fits the event limit) stops recording until the next page load instead of retrying forever.

blockSelector adds private subtrees to the built-in blocks. data-reopt-replay-block, data-reopt-private, password fields, iframes, objects and embeds are always blocked. Inputs and visible text are masked, including CSS-generated text in snapshots, incremental style updates and constructed stylesheets (replace/replaceSync); DOM attributes are allowlisted. URLs lose queries/fragments, path segments holding an email (@ or %40) are masked, and image/font resources are excluded by default. Console/network bodies, canvas, custom recorder plugins and persistent offline DOM queues are unsupported. Never add an unmasking workaround to pass sensitive content into replay.

Explicit public images and fonts

sessionReplay.publicAssets optionally maps public static URLs to build-time data URLs:

sessionReplay: {
  enabled: true,
  publicAssets: [
    { url: "/brand/logo.webp", dataUrl: publicLogoDataUrl },
    { url: "/fonts/public.woff2", dataUrl: publicFontDataUrl },
  ],
}

Generate these values from reviewed application-owned files during your build. The example's scripts/build-replay-assets.mjs shows this pattern. Do not fetch arbitrary page URLs, rasterize the visitor's page, or include uploads, avatars, authenticated/personalized files, or images containing private text. Pixels and font metadata are not masked. Only deliberately public assets belong here.

The recorder matches exact resolved HTTP(S) URLs; configured URLs with credentials, queries or fragments are rejected. Signed variants and unlisted inline images remain excluded. PNG, JPEG, WebP and WOFF2 are supported with MIME/signature checks. At most 16 entries are inspected, each file is at most 128 KiB decoded, and accepted data URL strings total at most 256 KiB. Invalid or over-budget entries are ignored. Font loading uses captured @font-face CSS; the FontFace API, SVG, srcset selection, canvas and automatic asset discovery are not supported.

Approved files replace image src and CSS url() references in full snapshots and incremental updates. The server revalidates embedded formats. Playback needs no request to the source site: the existing sandbox/CSP permits only embedded images/fonts. Assets share the recording's transport, private storage, quotas, retention and deletion; they are not uploaded to a separate public bucket. Repeated CSS references and snapshots repeat bytes, so subset fonts and small images are preferable. Existing recordings cannot regain previously removed resources.

Each page load/visibility restart has a distinct stream linked to the server's current analytics session; a session cookie that still names the previous visit does not restart it, and the first load's pageshow is not a restart. Hiding the page lets its last upload finish before the stream ends. A stream rotates a minute before the server's REPLAY_LIMITS.streamDurationMs and records nothing past it. Tabs never share DOM mirrors. One eligible recorder owns each document when several SDK clients coexist. Pause, reset, consent withdrawal and close stop observers and cancel pending replay work. Idle pages suspend after 60 seconds; activity starts a fresh snapshot. idleTimeoutMs accepts 10–300 seconds and flushIntervalMs accepts 1–30 seconds (default 20; each flush is one upload and one stored object, and page hide/unload flushes regardless). Full DOM snapshots are retaken every five minutes.

Uploads use /api/replay/start and /api/replay/chunk under baseUrl; the server SDK's existing /ingest proxy forwards them. Replay requests omit cookies and use the write-key/device headers plus a short-lived project/device/session/stream-bound grant. A 1 MiB request, 900 KiB single event, 4 MiB queue and 32 MiB / 30-minute stream bound resource use. Oversized snapshots stop the stream; mutations never continue with a broken snapshot. The next eligible attempt starts a fresh stream.

Delivery recovery and query privacy

Transient delivery failures retain events and schedule another attempt without requiring a new event, reload, or manual flush(). An open circuit waits for its recovery timeout; quota refusals wait for Retry-After (one hour when absent). Closing the client cancels automatic recovery. Consent still gates all delivery. A permanently rejected batch is discarded; a malformed response remains buffered for an explicit retry. Optional tracing/exception chunk failures do not raise unhandled rejections into the application; manual exception capture retains its flat error fields.

Migration: automatic Next.js page views no longer copy the full query string into properties.search. Query-only navigation still creates a page view. To collect reviewed, non-sensitive keys explicitly:

<ReoptPageView searchParamsAllowList={["page", "category"]} />

Unlisted keys are omitted; repeated allowed keys are preserved. This setting controls only the automatic search property. UTM extraction and pathname normalization still run, but all event payloads pass the privacy boundary below. Do not put secrets or personal data in allowed keys or UTM parameters.

Privacy and bounded queues

Before entering memory or offline storage, every event is copied and filtered. Credential keys (such as password, token, authorization, and clientSecret) are removed recursively. URL-valued url, href, referrer, and filename fields lose credentials, queries, and fragments; HTTP(S) URLs embedded in other strings are sanitized too. This also applies to explicitly supplied properties and restored offline events. This is not a general PII detector: review custom fields.

Configure privacy.redactKeys for additional case/punctuation-insensitive property names, and privacy.beforePersist(event) to return a filtered event or null to drop it. The callback runs synchronously, including on restored events; keep it deterministic and idempotent. Only its payload is used: it cannot replace the envelope or event identity. A thrown policy drops the event safely.

maxQueueSize defaults to 10,000 events, maxQueueBytes to 2,000,000 serialized bytes, and maxEventAgeMs to seven days since enqueue. Capacity counts pending, held, unacknowledged, and in-flight events. Oldest non-flight entries are evicted first; in-flight reservations cannot be capacity-evicted. Expired entries are discarded on queue activity. Invalid numeric settings fall back to defaults with a warning containing only the option name.

Delivery state and React initialization

client.getDeliveryState() returns a payload-free snapshot; client.subscribeDelivery(listener) subscribes immediately and returns an unsubscribe function. onDeliveryStateChange accepts the same listener in client configuration. Snapshots include pending/in-flight/held/persisted counts, serialized bytes, state, next attempt time, last success/failure, drop counts, and storage failures. Observer exceptions cannot interrupt delivery. useDeliveryState() is available from /react and /next, returning null until the provider's client is ready.

The provider creates the SDK only after React commits. useReoptClient() is initially null; child effects using useReopt() buffer up to 1,000 calls in order and preserve returned event IDs. An abandoned render creates no SDK side effects. A pre-commit queued: true acknowledges admission to that temporary buffer; consent, validation and privacy are checked when the client receives it. Clients remain page-scoped across unmounts, with the first configuration retained per write key. Conflicting later options produce a one-time warning naming fields without printing their values.