@reopt-ai/data-sdk-client
v0.6.1
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.
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
globalThis.__REOPT_RELEASE__, which is where a build step writes it:
// 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.- 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. |
{
"$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 10 with
one token back every 10s. 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.
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.
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 everything; a decision made in the browser beats the server's bootstrap. 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, restored once by the next page. - 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 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 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 and incremental style updates; DOM attributes are allowlisted. URLs lose queries/fragments 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. 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.
