Reference
Sessions
Session and chat IDs — grouping spans into sittings and conversations — plus auth-provider pass-through.
Two context IDs group a user's spans into the units product analytics cares about:
sessionId— everything a user did in one sitting. Powers session duration, events-per-session, in-session funnels, and return-rate metrics.chatId— which conversation a span belongs to. A session can hold several chats at once (think two chat panels in different parts of the UI); analytics groups by (sessionId,chatId) so they stay separate.
Getting these onto every span is one of the most impactful — and most commonly missed — parts of instrumentation. For user and group identity (identifyUser, properties), see Identity.
What counts as a session
An Otis session is a working session: a bounded period of related activity, the way a product analyst means "session." The browser auto-session reflects this — it rolls over after 30 minutes idle or 24 hours maximum.
This is deliberately not the same as your auth provider's session. An auth session is a credential's validity window — it can stay valid for days or weeks across many separate visits. The two concepts are easy to conflate because both are called "session," but they answer different questions:
| Otis session | Auth session | |
|---|---|---|
| Answers | "What did the user do in this sitting?" | "Is this request authenticated?" |
| Typical span | Minutes to hours | Days to weeks |
| Good for | Session metrics, funnels, return analysis | Authorization |
When you supply a sessionId, aim for working-session grain. An auth session ID still works and is safe to use (see Choosing what to use), but it's coarser — its metrics read closer to per-user.
Always set a sessionId
Don't leave sessionId unset
If sessionId is never set, the SessionId column is empty and every per-session metric — session duration, events-per-session, return rate, in-session funnels — is unavailable. userId attribution still works, but session analytics silently produce nothing.
There is almost always a session available to use: the browser auto-session if you have a UI, or your auth provider's session on the server. Pick one of those — don't ship spans with no session.
Where the session comes from
Most apps have both a browser and a server, and you want one session shared across both: the click in the UI and the AI call it triggers should land in the same session. The session is established once — usually in the browser — and then flows to the server. You don't generate it in two places.
| Where your code runs | How the session is established |
|---|---|
| Browser (your UI) | OtisProvider writes the __otis_session cookie automatically |
| Server, same domain as the UI | Pass the request — contextFromChatRequest(body, req) reads that cookie, so the server inherits the same session |
| Server, different domain from the UI | Browser sends the X-Otis-Session-Id header; the server auto-reads it (see Cross-domain APIs) |
| Server with no browser in the loop (backend agent, API, cron) | Supply sessionId explicitly from your auth provider (see Auth provider pass-through) |
So the common case — a browser UI calling your own server routes — is automatic: OtisProvider creates the session, and every server route that passes req to contextFromChatRequest joins it. Under GDPR, "automatic" starts at consent — see the callout below.
Under GDPR the cookie only exists after consent
When consent.mode is "required" (the default, for EU users), OtisProvider does not write the __otis_session cookie until consentGiven() fires. Before consent it uses an in-memory sessionId that never leaves the browser — so during the pre-consent window contextFromChatRequest(body, req) finds no cookie and server spans carry no sessionId.
- In
consent.mode: "granted"(non-EU or app-gated) the cookie is written on init, so the inherit-by-cookie path works immediately. - Authenticated requests can still carry a session pre-consent: derive
sessionIdserver-side from the auth session (auth-provider pass-through). Reading an identifier off the already-authenticated request isn't device storage, so it doesn't depend on the consent-gated cookie. - Anonymous pre-consent requests have no stable per-user signal, and the empty
sessionIdis intentional — don't fabricate one. A per-server-instance fallback (the trick a single-user MCP/stdio process can use, where one process = one connection = one user) is wrong here: a web server multiplexes many users per instance, so a shared id would merge unrelated users into one session — worse than empty.
Recording unconsented analytics at all is a consent decision, not just a technical one; see Browser & consent.
An explicit sessionId overrides the cookie — use it deliberately
Passing { sessionId } in options takes precedence over the __otis_session cookie. If a route both reads the cookie and passes an auth-session ID, the auth session wins and that route's sessions become coarser than the rest of your app. Derive sessionId the same way across all routes for a user — otherwise their activity fragments across mismatched session IDs.
contextFromChatRequest
contextFromChatRequest builds the request-scoped context you hand to withContext. It derives a stable chatId from a Vercel AI SDK useChat request body — so every POST in a conversation, including tool roundtrips, shares one chatId — and resolves userId / sessionId.
// Body only — chatId
contextFromChatRequest(body): WrapContext
// Body + options (auth provider pass-through)
contextFromChatRequest(body, { userId, sessionId, metadata }): WrapContext
// Body + Request + options (reads cookies/headers automatically)
contextFromChatRequest(body, req: Request, { userId, sessionId, metadata }?): WrapContextWhen a Request is passed, each field resolves by priority:
userId— explicit options >__otis_uidcookie > undefinedsessionId— explicit options >__otis_sessioncookie >X-Otis-Session-Idheader > undefined
Both are hashed via HMAC automatically when identifier hashing is enabled (the default). Already-hashed usr_v1_ / ses_v1_ values pass through.
Not using useChat? You don't need this function
contextFromChatRequest exists to derive chatId from a useChat body. On a non-AI-SDK endpoint — a plain route handler, a server action, a backend job — just build the context yourself and pass it to withContext:
otis.withContext({ userId, sessionId }, () => { /* ... */ });Chat IDs
chatId identifies a single conversation. A session can contain more than one — two chat surfaces open in different parts of the UI, or several separate conversations over a sitting — and chatId is what keeps them apart. Activity is grouped by (sessionId, chatId), so concurrent conversations don't bleed together. When chatId is absent, everything in the session falls into one conversation group.
For useChat, contextFromChatRequest derives chatId from body.id (the chat's stable ID) automatically — every POST in that conversation, including tool roundtrips, carries the same chatId and groups under the one conversation.
If you're not using useChat, set chatId yourself to distinguish conversations within a session:
otis.withContext({ userId, sessionId, chatId: conversation.id }, () => { /* ... */ });chatId is advisory — instrumentation works without it — but it enables conversation-level grouping. The trace-enricher reads it into the ChatId column, alongside SessionId and UserId.
Auth provider pass-through
Most apps already have an auth provider with a session concept. Pass userId and sessionId from your auth helper into contextFromChatRequest (or directly into withContext).
Only forward opaque IDs — never tokens
Pass only opaque session IDs, never session tokens or cookie values. Session tokens are auth credentials; shipping them as span attributes would leak credentials into analytics storage. Each section below points to the safe field.
JWT in request body or header (Convex, Supabase, Firebase, custom)
Many apps pass a JWT from the client to the server — in the request body (e.g. body.token for Convex), the Authorization header, or a cookie. Use identityFromJWT to pull userId and sessionId out of the token in one call:
import { contextFromChatRequest, identityFromJWT } from "@runotis/sdk";
const body = await req.json();
let userId: string | undefined;
let sessionId: string | undefined;
try {
const token = body.token ?? req.headers.get("authorization");
if (token) ({ userId, sessionId } = identityFromJWT(token));
} catch { /* malformed token — leave both undefined */ }
const ctx = contextFromChatRequest(body, { userId, sessionId });
return otis.withContext(ctx, () => streamText({ ... }));identityFromJWT auto-extracts sub → userId, sid (or session_id for Supabase) → sessionId, and org_id (or organization_id) → orgId. It strips a leading Bearer prefix automatically. Returns claims with the raw decoded payload for provider-specific fields (Auth0 namespaced custom claims, firebase.identities, etc.).
Decode-only — verification is your auth provider's job
identityFromJWT does NOT verify the signature. It trusts that the auth provider's middleware (Clerk, WorkOS, Auth0 SDK, your own JWT verifier) has already verified the token upstream. Never use it as a substitute for proper verification.
Supabase
Supabase issues a JWT access token whose claims carry both sub (the user UUID) and session_id (the auth session). On the server, read the access token from the Supabase client and decode it with identityFromJWT — one call gives you both userId and sessionId:
import { contextFromChatRequest, identityFromJWT } from "@runotis/sdk";
import { createClient } from "@/utils/supabase/server"; // your @supabase/ssr helper
const supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
let userId: string | undefined;
let sessionId: string | undefined;
if (session?.access_token) {
({ userId, sessionId } = identityFromJWT(session.access_token));
// userId = sub (user UUID)
// sessionId = session_id (the Supabase auth session)
}
const ctx = contextFromChatRequest(body, { userId, sessionId });
return otis.withContext(ctx, () => streamText({ ... }));Use the session_id claim, not the token
The session_id claim is an opaque UUID that references the auth session — it is not a credential, and Otis HMAC-hashes it before storage. It's safe to send. Never send the access_token, the refresh token, or the sb-…-auth-token cookie; those authenticate the user.
Supabase sessions are login-grained
A Supabase session persists across token refreshes and, by default, until sign-out — so it can span days. Used as your sessionId, all of a user's activity over that span collapses into one session. That's a valid choice if you want login-grained sessions, but if you care about visit-level metrics, prefer the browser auto-session for working-session grain. See Choosing what to use.
Clerk
import { auth } from "@clerk/nextjs/server";
const { sessionId, userId } = await auth();
const ctx = contextFromChatRequest(body, {
sessionId: sessionId!,
userId: userId!,
});Use auth().sessionId, not the session token
auth().sessionId is an opaque identifier and safe to use. Never pass the Clerk session token or the __session cookie value; those are auth credentials.
WorkOS AuthKit
import { withAuth } from "@workos-inc/authkit-nextjs";
import { contextFromChatRequest } from "@runotis/sdk";
const { sessionId, user } = await withAuth();
const ctx = contextFromChatRequest(body, { sessionId, userId: user?.id });
return otis.withContext(ctx, () => streamText({ ... }));Use withAuth().sessionId, not the session token
withAuth().sessionId is an opaque identifier and safe to use. Never pass the WorkOS session token or the wos-session cookie value; those are auth credentials.
Stytch
const { session } = await stytchClient.sessions.authenticate({ session_token });
const ctx = contextFromChatRequest(body, {
sessionId: session.session_id,
userId: session.user_id,
});Use session.session_id, not session_token
session.session_id is an opaque identifier and safe to use. Never pass session_token or session_jwt; those are auth credentials.
Auth.js (v5)
Auth.js doesn't expose a safe session ID, so get the sessionId from the browser auto-session (recommended — pass req so the cookie is read) or mint a stable one server-side like the Express example below. Pass userId from the session either way:
const session = await auth();
// sessionId comes from the __otis_session cookie via the Request:
const ctx = contextFromChatRequest(body, req, { userId: session?.user?.id });Express + express-session
req.sessionID is the value that authenticates the user's session cookie — never send it as a span attribute. Mint a separate, stable ID instead:
const sessionId = req.session.otisSessionId ??= crypto.randomUUID();
const ctx = contextFromChatRequest(req.body, { sessionId, userId: req.user?.id });Choosing what to use as your sessionId
In order of preference:
- Best (product analytics): the browser ephemeral working-session (
__otis_session, set byOtisProvider). Purpose-built for session metrics — 30-min idle / 24-hr max. Have the server inherit it by passingreq. - Acceptable: your auth provider's opaque session ID (Clerk
auth().sessionId, WorkOSsessionId, Supabasesession_idclaim, or a minted per-session ID). Safe and stable, but coarser — one session ≈ one login, which can span days. Use when there's no browser in the loop, or you specifically want login-grained sessions. - Never: session tokens, cookies, or raw JWTs — those are credentials, and sending them leaks auth material into analytics.
Worked example: useChat + JWT-in-body
The full pattern for an app where the browser uses useChat and forwards a JWT (Convex, Supabase, Firebase, or any custom auth setup that hands the client a JWT) to the server.
Client — sends the JWT in the request body:
"use client";
import { useChat } from "ai/react";
import { useAuthToken } from "./your-auth-hook";
export function Chat() {
const token = useAuthToken(); // however your app exposes the JWT
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat",
body: { token }, // forwarded into req.json() server-side
});
return (/* ...your chat UI... */);
}Server — one call extracts identity, one call wires it onto every span:
import { contextFromChatRequest, identityFromJWT } from "@runotis/sdk";
import { getServerOtis } from "@runotis/sdk/next/server";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const otis = getServerOtis()!;
const body = await req.json();
// 1. Identity — pulled from the JWT in the request body.
// `identityFromJWT` decodes only; signature verification is your
// auth provider's job upstream. Returns undefined fields if the
// token is missing or malformed.
let userId: string | undefined;
let sessionId: string | undefined;
try {
if (body.token) ({ userId, sessionId } = identityFromJWT(body.token));
} catch { /* malformed token — leave both undefined */ }
// 2. Context — `contextFromChatRequest` derives a stable chatId from the
// useChat body so every POST in the conversation (including tool
// roundtrips) groups under one conversation. Passing the Request also
// picks up __otis_uid / __otis_session cookies automatically (set by
// OtisProvider in the browser).
const ctx = contextFromChatRequest(body, req, { userId, sessionId });
return otis.withContext(ctx, async () => {
const { streamText: tracedStreamText } = otis.wrap(ai);
const result = await tracedStreamText({
model: anthropic("claude-sonnet-4-6"),
messages: body.messages,
});
waitUntil(otis.flush()); // serverless: keep alive until spans flush
return result.toTextStreamResponse();
});
}Every span produced inside the callback — the AI call, every tool invocation, and any sendEvent calls — carries userId, sessionId, and chatId. Every POST in the conversation shares the same chatId (from body.id), so all their spans group under one conversation; concurrent chats in the same session stay separate.
Don't mix-and-match
Don't pass the JWT directly into withContext (that would attach the credential to every span — a leak). And don't extract userId by hand-rolling JSON.parse(atob(token.split(".")[1])) — identityFromJWT handles base64url, UTF-8, missing claims, and the Bearer prefix correctly across all common auth providers.
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 ctx = contextFromChatRequest(body, req); // reads X-Otis-Session-Id automaticallycontextFromChatRequest reads X-Otis-Session-Id automatically after cookies. See Browser & consent for the browser side.
Browser auto-session
In a browser app, OtisProvider manages the __otis_session cookie for you — including the consent gating required under GDPR/ePrivacy. Under consent.mode: "required", the cookie — and therefore the server-side sessionId inherited via contextFromChatRequest — is absent until the user grants consent (see the callout in Where the session comes from). The full cookie model, lifetimes, consent flow, and CMP adapters are in Browser & consent. To read the current sessionId inside a client component, use the useOtis() hook.