SDK 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 and properties.
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. If the page's main interaction is a plain fetch (a chat POST, a form submit), also forward the header — see the callout below |
| 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) |
| A conversational surface with no browser at all (MCP, Slack, a chat bot) | Derive it from the conversation — see Conversational surfaces |
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.
If your main interaction is a plain fetch, forward the session in a header too
The __otis_session cookie is written with max-age equal to the idle timeout (30 min by default), and it's only refreshed when the SDK itself emits a span. Activity the SDK never sees — a chat POST, a form submit, any bare fetch — doesn't refresh it.
So a page whose only activity is that kind of request crosses the idle boundary, the browser drops the cookie, and every request after that reaches your server with no session. It doesn't recover until something re-initializes the SDK (typically a navigation). The long-lived __otis_uid cookie survives, so the result looks like "a user with no session" rather than a dropped cookie.
Fix it from the client — attach the session as a header on the request:
import { OTIS_SESSION_HEADER } from "@runotis/sdk";
import { useOtis } from "@runotis/sdk/next";
const { getSessionId } = useOtis();
// in your fetch / transport
const sessionId = await getSessionId();
// Build a Headers rather than spreading: an existing `headers` may be a
// Headers instance (or an entries array), and spreading either yields {} —
// silently dropping your own headers, Content-Type included.
const headers = new Headers(init?.headers);
if (sessionId) headers.set(OTIS_SESSION_HEADER, sessionId);
await fetch(url, { ...init, headers });getSessionId() marks the activity and rotates first if the session genuinely went idle, so the value is correct for right now. contextFromChatRequest(body, req) already reads this header after the cookie, so the server needs no change.
Use getSessionId() rather than the reactive sessionId from useOtis() for outbound requests: that field is a snapshot and applies no rotation, so after an idle gap it names a session that has already ended — a wrong value instead of a missing one.
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 and 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
import { OTIS_SESSION_HEADER } from "@runotis/sdk";
import { useOtis } from "@runotis/sdk/next";
const { getSessionId } = useOtis();
const sessionId = await getSessionId();
const headers = new Headers();
if (sessionId) headers.set(OTIS_SESSION_HEADER, sessionId);
await fetch("https://api.example.com/chat", { headers /* ... */ });
// Server
const ctx = contextFromChatRequest(body, req); // reads X-Otis-Session-Id automaticallycontextFromChatRequest reads X-Otis-Session-Id automatically after cookies. See Browser and consent for the browser side.
The same header is also worth sending same-domain when the page's main interaction is a plain fetch, because the cookie can lapse mid-use — see the callout in Where the session comes from.
Conversational surfaces (MCP, Slack, bots)
An MCP server, a Slack bot, or any chat surface reached without a browser has no __otis_session cookie and no auth-provider web session. Leaving sessionId unset there means every turn on that surface is session-less and drops out of all per-session analytics.
Derive it from the conversation. One thread or conversation is exactly the unit of activity a session describes, so it maps cleanly:
// One Slack thread / MCP conversation = one session
otis.withContext(
{ userId, chatId: conversation.id, sessionId: `slack:${conversation.id}` },
() => { /* ... */ },
);Two rules that keep this honest:
- Prefix by surface (
slack:,mcp:) so a conversation id can't collide with a browser session id. - Don't fabricate one when you have nothing to derive from. If there's no conversation or thread identifier, leave
sessionIdunset. A shared constant would merge unrelated users into a single session, which is worse than empty — see Choosing what to use as your sessionId.
MCP tool spans resolve sessionId through their own chain rather than from withContext — see MCP for resolveSession.
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 and consent. To read the current sessionId inside a client component, use the useOtis() hook.
Recording facts about a session
A session can carry its own properties: facts about that one visit that aren't true of the user in general, such as how the visit started. Set them with setSessionProperties:
import { setSessionProperties } from "@runotis/sdk";
setSessionProperties({ entry_point: "shared_link" });The properties attach to the same session as your other spans. The SDK finds it the same way: the sessionId you pass, then the browser auto-session, then the sessionId from withContext. The call doesn't need an identified user. If no session is available, the call does nothing, which is one more reason to always set a sessionId.