@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-clientNext.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>
);
}ReoptPageViewsends$pageviewon every navigation — pathname and query string — and a$pageleavewith 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.ReoptWebVitalsforwards Next's Core Web Vitals as$web_vitals.baseUrl: "/ingest"assumes thereoptProxyfrom@reopt-ai/data-sdk-server/proxyrewrites/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.bootstrapis 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 navigationConfiguration
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: falseand callpageView({ 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, andinit()is idempotent per write key, so a remount or a soft navigation never creates a second client.- Opt-out removes identity. Refusing
analyticsdeletes 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: withconsent.persist: falsethe SDK keeps no consent cookie, butreoptProxydecides seeding from exactly that cookie — so a banner that manages consent itself must also writereopt_<writeKey>_consent(viaserializeConsentCookie, single encoding) for the proxy to see it. consent.persist: falsewhen a consent banner already owns the decision: sync it withsetConsent("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).normalizePathrewrites 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$pageviewpath,$pageleave,$web_vitals,$exception— and not on apathyou pass topageView()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 withdeviceIdon the event — the browser session and the server-side conversion then land on the same device without any header magic.nullbeforeinit()or when analytics is disabled.propertiesininit()sets those same global properties from the very first event — web vitals can fire the instant the client exists, before an effect gets to callregister(). 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_vitalsand$pageleave, which the SDK otherwise stamps with only a path. An event's own properties win;reset()clears them. It keepsinit({ properties })and the release, which describe the page rather than the visitor.- Fail-open. A missing
writeKeyorbaseUrllogs a warning and yields a disabled client whose every call is a no-op; nothing throws. Analytics must never be why a page fails. fetchinjects the transport. A test can hand in a recording function and assert on the exact payload the SDK built, instead of intercepting the network.observereceives 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:
- With
cacheComponents: true, calling it inside a"use cache"function (or a helper that function calls) passesnext buildand fails atnext startwithnext-request-in-use-cache— on dynamic routes only when the route runs, so it gets past your gate. Read it outside the cache, afterawait connection(), and pass the value down. - Passing the
cookies()promise as a prop into a cached component does not error; the build hangs or the prerender times out. - 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 passbootstrap; 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 trailcapture.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,Secureon 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 (localStoragereopt_device_id) is migrated on first load. - Consent in a cookie (
reopt_<writeKey>_consent), so the server stops sending when the browser does. Refusinganalyticsstops 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()withreason: "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 customstoragePrefix(<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 areset()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()returnskipped; 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 optionalkeys(): 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, explicitflush(),close()or unload — until ingest'sRetry-Afterhas passed (the engine'sQUOTA_HOLD_MSwhen 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/ingestproxy 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_HEADERin@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/trackmust forward it, as@reopt-ai/data-sdk-server/proxydoes. Replay uploads do not carry it. - Refused at the call, not by the server. An event the ingest contract would reject — an
identifywhoseemail,avatarURL or name the contract refuses, say — returnsreason: "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 returnreason: "client_closed", andinit(), the React provider and the module functions create a fresh client instead of handing the closed one out.- Delivery completion.
flush({ drain: true })andclose()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,
BigIntand throwing serializers returnqueued: falsewithreason: "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.
