OOtis Docs

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.

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

CookieLifetimeContents
__otis_uid1 year, refreshed on activityanon_<uuid> or hashed user ID
__otis_session30-min idle / 24-hr max, refreshed on activitysess_<uuid>
__otis_consentStrictly-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.

KeySourceNotes
_uanavigator.userAgentFull user agent string
_langnavigator.language2-letter lowercase code (e.g. en, de)
utm_source?utm_source=... in the URLCapped at 256 chars
utm_medium?utm_medium=... in the URLCapped at 256 chars
utm_campaign?utm_campaign=... in the URLCapped 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.

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",
    },
  },
});
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 adapter

Rules:

  • 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()


                       unknown

CMP 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 CookiebotOnConsentReady and CookiebotOnAccept
  • Treats CookiebotOnDecline as 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();
});

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

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.

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.

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_consent is 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.

On this page