Reference
Browser & consent
Cookie identity, GDPR/ePrivacy consent, CMP adapters, and cross-domain session handling.
This page covers everything the browser SDK does: managing user and session identity via cookies, gating cookies behind consent for GDPR/ePrivacy, integrating with a Consent Management Platform (CMP), and passing identity across domains.
Runtime capability
In the browser, the SDK supports event helpers only:
import { initOtis, identifyUser, sendEvent, sendFeedbackSignal } from "@runotis/sdk";wrap(), traced(), and withContext() are not available: they require AsyncLocalStorage. The browser build flushes spans immediately and registers a beforeunload handler to flush on page unload.
Cookie-based identity
const otis = initOtis({
apiKey: "sk-otis-xxx",
serviceName: "my-app",
browser: {
autoAnonymousUserId: true, // persistent __otis_uid cookie
autoSessionId: true, // ephemeral __otis_session cookie
// idleTimeout: 30 * 60 * 1000, // default: 30 minutes
// maxDuration: 24 * 60 * 60 * 1000, // default: 24 hours
// cookieDomain: ".example.com", // optional: cross-subdomain
},
});Cookies
| Cookie | Lifetime | Contents |
|---|---|---|
__otis_uid | 1 year, refreshed on activity | anon_<uuid> or hashed user ID |
__otis_session | 30-min idle / 24-hr max, refreshed on activity | sess_<uuid> |
__otis_consent | Strictly-necessary (always set) | granted / denied + ISO timestamp |
All cookies: SameSite=Lax; Secure (Secure omitted on localhost). Not httpOnly, since the browser SDK must read and write them.
Multiple tabs share the same session (same-origin cookies).
Server-side — auto-read cookies
contextFromChatRequest(body, req) reads both identity cookies automatically:
export async function POST(req: Request) {
const body = await req.json();
const ctx = contextFromChatRequest(body, req);
return otis.withContext(ctx, () => streamText({ ... }));
}Cross-domain APIs
If your API is on a different host from your browser app, SameSite=Lax cookies won't propagate. Pass the session ID in a header instead:
// Client
await fetch("https://api.example.com/chat", {
headers: { "X-Otis-Session-Id": sessionId },
// ...
});
// Server
const sessionId = req.headers.get("x-otis-session-id");contextFromChatRequest reads X-Otis-Session-Id automatically after cookies.
Auto-captured session properties
When you call identifyUser in the browser, the SDK attaches a small set of session properties automatically. These are scoped to the current session (not persisted to the user) and are scanned for PII like any other property.
| Key | Source | Notes |
|---|---|---|
_ua | navigator.userAgent | Full user agent string |
_lang | navigator.language | 2-letter lowercase code (e.g. en, de) |
utm_source | ?utm_source=... in the URL | Capped at 256 chars |
utm_medium | ?utm_medium=... in the URL | Capped at 256 chars |
utm_campaign | ?utm_campaign=... in the URL | Capped at 256 chars |
UTM parameters are read from window.location.search at the time identifyUser is called, so they reflect the landing page that triggered identification (typically the first authenticated page load).
All five keys are prefixed _ or utm_ to avoid collision with your own session properties. These are separate from anything you set via setUserProperties and don't consume the user-property slots.
Server-side calls to identifyUser don't capture any of these. There's no navigator or window on the server.
Location
When the browser SDK calls identifyUser, a country session property is attached automatically: a 2-letter ISO country code (e.g. US, DE, GB) derived from the request's client IP.
Location is only captured when _ua is present, so it's scoped to real browser calls. Server-side identifyUser calls don't capture location; the IP reaching Otis in that case would be your application server's, not the end user's, so country wouldn't be meaningful.
IPs are treated as PII and never stored
The client IP is used in-memory only to resolve the country code, then discarded. Only the country survives to analytics storage. No raw IP address ever reaches any span, property, or other queryable store.
Location resolution is best-effort. If the IP is unknown, malformed, or can't be resolved, the country property is simply omitted.
Cookie consent (GDPR / ePrivacy)
Cookies wait for consent
Default behaviour: cookies are not written until otis.consentGiven() is called. If your integration doesn't wire up consent, identity won't propagate.
Before consent, a short-lived in-memory sessionId is used so same-page spans correlate. Nothing is written to cookies, localStorage, or sessionStorage.
After consent, __otis_uid and __otis_session cookies are written and identity propagates to the server via contextFromChatRequest.
Why
Under GDPR + the ePrivacy Directive, analytics cookies are not "strictly necessary" and require user consent before being set. This applies to any user in the EU regardless of where the app is hosted. __otis_uid and __otis_session pseudonymize personal data (a unique identifier linked to user activity), so consent is required.
ePrivacy regulates storage on the user's device. An in-memory sessionId (no cookie, no localStorage, no sessionStorage) doesn't touch device storage, so no ePrivacy consent is required. This is why the pre-consent fallback is memory-only: localStorage and sessionStorage would still require consent.
Config
const otis = initOtis({
apiKey: "sk-otis-xxx",
serviceName: "my-app",
browser: {
autoAnonymousUserId: true,
autoSessionId: true,
consent: {
// "required" (default): wait for otis.consentGiven() — GDPR-safe.
// "granted": assume consent — cookies set immediately on init.
// Only use when SDK init is already gated behind the app's
// own consent check, or when operating entirely outside GDPR.
mode: "required",
// Behavior before consent with no prior cookie:
// "memory" (default): in-memory sessionId; same-page correlation works.
// "off": no sessionId at all.
preConsent: "memory",
},
},
});Consent API
otis.consentGiven(): Promise<void> // grant — writes cookies
otis.consentRevoked(): Promise<void> // revoke — clears cookies, falls back to memory
otis.consentReset(): Promise<void> // reset — clears all consent cookies
otis.getConsentState(): "unknown" | "granted" | "denied"
otis.onConsentChange(listener): () => void
otis.attachConsent(opts): () => void // attach a CMP adapterRules:
consentGiven()is idempotent. The first call activates cookies; subsequent calls are no-ops.consentRevoked()from any state moves to denied and clears cookies.- On grant, a fresh sessionId is generated.
Session IDs don't persist across consent
The pre-consent memory sessionId is not promoted to a cookie on grant. This is intentional: it keeps a clean privacy boundary so unconsented activity never inherits a consented identifier.
State transitions
┌────────────────┐
│ unknown │ (initial, no consent cookie)
│ memory mode │
└───┬────────┬───┘
grant() │ │ revoke()
▼ ▼
┌─────────────┐ ┌─────────────┐
│ granted │ │ denied │
│ cookie mode │ │ memory mode │
└──────┬──────┘ └──────┬──────┘
revoke│ │grant()
└────────┬───────┘
│
reset()
│
▼
unknownCMP integrations
Usercentrics
Register "Otis Analytics" as a Data Processing Service (DPS) in the Usercentrics admin console, then attach:
const detach = otis.attachConsent({
provider: "usercentrics",
serviceId: "your-dps-template-id", // from Usercentrics admin
serviceName: "Otis Analytics", // fallback if serviceId isn't known
});The adapter listens to Usercentrics SDK events (UC_UI_INITIALIZED, UC_CONSENT), reads the Otis service's consent status, and calls consentGiven() / consentRevoked() automatically.
Cookiebot
Cookiebot uses category-based consent. Analytics SDKs conventionally fall under statistics:
const detach = otis.attachConsent({
provider: "cookiebot",
category: "statistics", // default — analytics convention
// category: "marketing", // or "preferences" depending on your categorization
});The adapter:
- Reads
window.Cookiebot.consent[category]immediately for returning visitors - Listens to
CookiebotOnConsentReadyandCookiebotOnAccept - Treats
CookiebotOnDeclineas revoke regardless of category - Handles partial accepts correctly: only acts on the configured category's flag
OneTrust
OneTrust uses a global OptanonWrapper callback and category IDs (e.g. C0002 for analytics):
declare global {
interface Window {
OptanonWrapper?: () => void;
OnetrustActiveGroups?: string;
}
}
window.OptanonWrapper = () => {
const groups = window.OnetrustActiveGroups ?? "";
if (groups.includes("C0002")) {
otis.consentGiven();
} else {
otis.consentRevoked();
}
};Any other CMP
Any CMP that exposes "consent changed" callbacks works:
onCMPConsentChanged((analyticsConsented: boolean) => {
if (analyticsConsented) otis.consentGiven();
else otis.consentRevoked();
});Next.js — consent banner
useOtis() exposes the current consent state:
import { useOtis } from "@runotis/sdk/next";
export function ConsentBanner() {
const { otis, consent } = useOtis();
if (consent !== "unknown") return null; // user has decided; don't re-prompt
return (
<div className="consent-banner">
<p>Allow analytics cookies to help us improve the product?</p>
<button onClick={() => otis?.consentGiven()}>Allow</button>
<button onClick={() => otis?.consentRevoked()}>Deny</button>
</div>
);
}Identity fields (userId, sessionId, isAnonymous) update live when consent is granted or revoked.
Server side is unaffected
Server-side code reads whatever cookies are present on the incoming request. If the browser hasn't set any (no consent), contextFromChatRequest(body, req) returns no auto-detected IDs; supply them explicitly via the auth provider pass-through pattern instead.
Common patterns
Skip consent entirely (non-EU or app-gated)
If the app already gates SDK init behind its own consent mechanism (e.g., only mounts OtisProvider after the user consents), set consent.mode: "granted":
browser: {
autoAnonymousUserId: true,
autoSessionId: true,
consent: { mode: "granted" },
}Only sessionId, no anonymous user ID
Leave autoAnonymousUserId: false. Same consent flow applies to __otis_session.
Strict "no analytics before consent"
No in-memory sessionId; no correlation until consent:
browser: { autoSessionId: true, consent: { preConsent: "off" } }Before consentGiven(), useOtis() returns { sessionId: undefined } and spans fire without a sessionId.
Testing consent flows
import { ConsentManager, readConsentStateFromCookie } from "@runotis/sdk";
// Inspect the consent cookie directly
expect(readConsentStateFromCookie()).toBe("granted");
// Or construct a ConsentManager directly for unit tests of custom CMP adapters
const mgr = new ConsentManager({ browser: { autoSessionId: true } });
await mgr.init();
await mgr.grant();
expect(mgr.getState()).toBe("granted");For Playwright end-to-end tests, clear the consent cookie to simulate a first-time visitor:
await context.clearCookies({ name: "__otis_consent" });What not to do
Don't bypass the consent gate. Setting mode: "granted" to "make analytics work" is a GDPR violation unless SDK init is gated elsewhere.
- Don't write identity to localStorage pre-consent. localStorage IS device storage under ePrivacy. The SDK uses memory-only intentionally.
- Don't call
consentGiven()from SDK init code. Consent must come from the user (or a CMP reflecting the user's choice). - Don't re-prompt after a decision. Check
consent !== "unknown"before showing a banner. The consent cookie is persisted so users don't see a banner on every page load.
Security & privacy notes
Identifier hashing protects browser cookies
Identifier hashing is enabled by default. The __otis_uid cookie is not httpOnly (the browser SDK needs to read it), so any stored value is XSS-reachable. HMAC hashing converts identifiers like emails to opaque usr_v1_<base64> identifiers automatically.
__otis_consentis strictly-necessary under common regulatory interpretation (CNIL, ICO): it exists solely to remember the consent choice, is not used for tracking, and without it every page load would require re-consent. Set regardless of consent state.- The consent cookie doesn't expire identity cookies. A user can grant, revoke, and re-grant within a session; each grant generates a fresh sessionId.
Migration from enableAnonymousId
The old enableAnonymousId config is replaced by browser.autoAnonymousUserId. Anonymous IDs move from localStorage to cookies; existing localStorage IDs are migrated automatically on first load.