SDK Reference
Customization
Configuration, surface and agent, release and environment, beforeSend, debug logging, error handling, and SDK exports.
Configuration reference
const otis = initOtis({
apiKey: "sk-otis-xxx", // Required (or OTIS_API_KEY env var on server)
serviceName: "my-app", // Required
surface: "web-app", // Where users work: web app, Slack bot, MCP server (optional)
agent: "support-assistant", // Workflow that handles requests (single-agent services — optional)
disabled: false, // Silently drop all spans (default: false)
endpoint: "https://...", // Default: https://ingest.runotis.com
serverless: true, // Flush immediately (Lambda, Vercel, Workers)
beforeSend: (span) => span, // Pre-send hook for filtering / enrichment
identifierHashing: true, // Pseudonymize user, session and group IDs (default: true)
setOnceKeys: ["signup_channel"], // User property keys that keep their first value
browser: { // Browser identity via cookies
autoAnonymousUserId: true,
autoSessionId: true,
},
debug: true, // Debug logging
piiRedaction: { // PII redaction (enabled by default)
enabled: true,
disabledPatterns: ["ipv4"],
},
});| Option | Env var | Default | Description |
|---|---|---|---|
apiKey | OTIS_API_KEY | required unless disabled | Authentication token |
serviceName | — | required unless disabled | Service name for span attribution |
surface | — | — | Where and how a user, or the user's own agent, works with your product through this instance, such as your web app, a Slack bot or an MCP server. Only needed when one service hosts more than one surface. See Surface |
agent | — | — | The workflow every span from this instance belongs to. Set it here only when the service runs one agent — see Agent |
release | — | detected from your platform | The build that produced the span, such as a commit SHA or a version number. Sent as service.version. See Release and environment |
environment | — | detected from your platform | Where the service runs, such as production or preview. Sent as deployment.environment |
endpoint | OTIS_ENDPOINT | https://ingest.runotis.com | The URL the SDK sends spans to |
disabled | — | false | All spans silently dropped, no network requests |
serverless | — | false | Immediate flush for Lambda / Vercel / Workers |
beforeSend | — | — | Pre-send hook for filtering / enrichment |
retry | — | { maxAttempts: 3 } | Retry an export that failed on a rate limit (429), a server error (most 5xx) or the network. { maxAttempts: 1 } disables. Retries run inside batchConfig.exportTimeoutMillis, so an export never takes longer than it already could |
compression | — | "gzip" | Compress the export body. "none" disables. Falls back to uncompressed where the runtime has no CompressionStream |
flushOnSignals | — | true | Flush on SIGTERM / SIGINT. The process then exits as it would without the SDK. If your app has its own handler for the signal, that handler decides when to exit (see Node → Graceful shutdown) |
signalFlushTimeoutMillis | — | 2000 | How long that flush may take before the process is allowed to exit |
batchConfig | — | see below | Queue and batch sizing: maxQueueSize (2048), maxExportBatchSize (512; browser 50), scheduledDelayMillis (5000; browser 60000; 0 disables the timer), exportTimeoutMillis (30000), maxConcurrentExports (4) |
debug | — | false | Debug logging |
identifierHashing | — | true | Replace user, session and group IDs with pseudonyms before they leave your app (see Privacy) |
identifierHashKey | OTIS_IDENTIFIER_HASH_KEY | the Ingest Key | Hash identifiers with this secret instead: an earlier key whose identities you want to keep, or a secret of your own. Must be the same in every instance (see Privacy) |
setOnceKeys | — | — | User property keys that keep the first value Otis receives for each user. Adds to a built-in set (see Identity and properties → Set-once properties) |
browser | — | — | Browser identity config |
piiRedaction | — | { enabled: true } | Client-side PII redaction (see Privacy) |
captureSystemPrompt | — | true | Send each AI call's system prompt text as ai.prompt.system. Off, the text isn't sent, and system and developer messages are left out of the message history the SDK records (see Privacy) |
captureToolDefinitions | — | false | Send each AI call's tool definitions (name, description, input schema) as ai.prompt.tools |
captureReasoning | — | false | Send what a model wrote while it reasoned (Anthropic thinking, OpenAI Responses reasoning, OpenRouter reasoning, Vercel AI SDK reasoning) as ai.response.reasoning on the AI call's span. Off, only reasoning-token counts are sent, and reasoning is removed from the message history the SDK records (see Privacy) |
captureArtifactText | — | — | Deprecated. Sets whichever of captureSystemPrompt and captureToolDefinitions is not set |
Browser note: OTIS_API_KEY, OTIS_ENDPOINT and OTIS_IDENTIFIER_HASH_KEY env vars are not available in browsers. Pass them explicitly. The browser initOtis batches spans (50 per request, flushed every 60s) and flushes whatever is pending when the page is hidden. Override either with batchConfig — { maxExportBatchSize: 1 } sends each span as it is produced.
Surface
surface names where and how a user, or the user's own agent, performs a task. A web app, a Slack bot, an MCP server and a command-line tool are each a . It's a separate dimension from serviceName and from agent:
| Dimension | Answers |
|---|---|
serviceName | Which deployment unit or process emitted this? |
surface | Where and how did the user, or the user's own agent, perform the task? |
agent | Which workflow handled the request? |
Set it when one service hosts more than one surface and you want to segment analytics by which one: a web app and a Slack bot served from one backend, a web app alongside an MCP server, or several CLIs. An app with a single surface is already identified by serviceName, so surface is optional there.
It is recorded as otis.surface on every span. Set it at three levels — most specific wins:
// 1. Instance default — every span from this Otis instance
initOtis({ serviceName: "my-app", surface: "web-app" });
// 2. Per wrap / withContext — overrides the instance default for these spans
otis.wrap(ai, { context: { surface: "slack-bot" } });
otis.withContext({ surface: "slack-bot" }, async () => { /* ... */ });
// 3. Per integration
instrumentMcpServer(server, { zod: z, surface: "docs-mcp" }); // see MCP options
runInstrumentedCli(fn, { name: "my-cli" }); // CLI defaults surface to `name`Keep it stable and low-cardinality. It is not hashed, and it is not per-conversation: a conversation is identified by chatId.
A surface changes how Otis finds tasks. Otis finds tasks within each surface separately, so a task belongs to one surface. A conversation with a chat ID is the one exception: it stays together even if its messages carry different surfaces. Surfaces describes every effect.
A surface longer than 128 characters is ignored, and the SDK logs one warning. The span then takes the surface from the next level out, or none. For runInstrumentedCli the next level is the CLI name. Put a request id or a user id in metadata, not in the surface.
Agent
agent names the workflow that handles a request, such as a support assistant, a research workflow or a nightly summarizer. An is the entry point of such a workflow. It may answer the user directly or work in the background. Each execution of an agent, from the input it receives to its final output, is an .
A surface and an agent are independent. One surface can host several agents, such as a router that hands each request to one of three specialists. One agent can serve several surfaces, such as an assistant reached from your web app and from Slack.
An agent doesn't change how Otis finds tasks. A task that several agents took part in is still one task, and lists each of them.
On an MCP server or a command-line tool, agent names a workflow your own code runs. It never names the coding agent that calls your server or runs your tool. Otis records that coding agent separately, as the calling host of an MCP call or the host agent of a CLI call.
Set it where the workflow starts. Every span started inside that call carries it, including spans from a wrapped call made inside one of its tools:
// 1. Per workflow — name it at its entry point
await otis.withContext({ agent: "research" }, async () => {
await runResearch(question);
});
// 2. Per wrap — every call from this wrap instance belongs to one agent
const { generateText } = otis.wrap(ai, { context: { agent: "support-triage" } });
// 3. Instance default — a service that runs a single agent
initOtis({ serviceName: "support-api", agent: "support-assistant" });The value nearest the call wins, in this order:
- A value set on the call itself, with
eventMetadata({ agent }). - The scope the call runs in: an enclosing
withContext(), or the agent run the call was made from. - The default on the call's wrap.
- The instance default.
A name longer than 128 characters is ignored at each of these, and the SDK logs one warning. The call then takes the next name in the order, or none. Put a request id or a user id in metadata, not in the name.
A wrap's default names the agent runs that start through that wrap. A call made inside another agent run keeps that agent run's agent, whichever wrap it goes through, so one agent run has one agent. To give part of an agent run its own agent, enclose that part in withContext({ agent }).
It is recorded as otis.agent on every span. Keep it to a small, stable set of names. It is not hashed.
In the data browser, the span list and the task list each have an Agent column and a filter.
Names taken from the Vercel AI SDK
When nothing names an agent, the SDK uses a name your code already gave the Vercel AI SDK:
- The
idof aToolLoopAgent. - The
experimental_telemetry.functionIdof a call.
A name you set yourself always wins. That includes the instance default, and the default on a wrap that a call inside the agent run goes through. A functionId on a call inside an agent run does not rename that agent run. A name longer than 128 characters is not used.
Release and environment
Each span carries its release (the build that produced it) and the environment it ran in. Otis records each new release of a service as a deploy on its timeline of changes. If your spans carry no release, Otis can't detect deploys from your telemetry.
On Vercel, Netlify, Railway, Render, Heroku, Fly.io, Cloud Run and GitHub Actions, the SDK reads the release from the platform's own environment variables. On Vercel, Netlify and Railway it reads the environment too. On Heroku, the release is available only when dyno metadata is turned on (heroku labs:enable runtime-dyno-metadata). Elsewhere, or to override what is detected, pass them to initOtis:
initOtis({ serviceName: "web", release: process.env.BUILD_SHA, environment: "production" });A value you pass wins over the platform's. The platform's wins over service.version and deployment.environment in OTEL_RESOURCE_ATTRIBUTES. The release and the environment are worked out separately, so you can set environment yourself and let the platform supply the release. Browsers have no platform variables, so pass both explicitly in browser code, for example from a NEXT_PUBLIC_ variable.
beforeSend hook
beforeSend receives each span before the SDK sends it, except the spans listed in Spans that skip beforeSend. It runs after PII redaction, so the span you receive has already been scrubbed.
Return the span (optionally modified) to send it, or null to drop it.
const otis = initOtis({
apiKey: "sk-otis-xxx",
serviceName: "my-app",
beforeSend: (span) => {
// Drop noisy spans
if (span.name.startsWith("internal.healthcheck")) return null;
// Add an attribute to every span
span.attributes = {
...span.attributes,
"app.region": "eu-west-1",
};
return span;
},
});Filter to AI spans only
import { initOtis, isAISpan } from "@runotis/sdk";
// The names your app passes to sendEvent()
const events = new Set(["checkout_completed"]);
const otis = initOtis({
serviceName: "my-app",
beforeSend: (span) =>
isAISpan(span) || span.name.startsWith("feedback.") || events.has(span.name) ? span : null,
});isAISpan() keeps a span whose name, or any of whose attribute keys, starts with one of these prefixes:
| Prefix | Source |
|---|---|
ai.* | otis.wrap() spans |
gen_ai.* | OpenTelemetry GenAI instrumentations, Mastra, Google ADK |
llm.* | OpenInference AI-call spans |
otis.* | Otis-namespaced spans |
Spans from sendEvent and sendFeedbackSignal usually carry none of these prefixes, so isAISpan() on its own drops them. The example keeps them by name. A feedback span is named feedback. followed by the signal name. An event span takes the name you pass to sendEvent, and OtisPageView sends each page view as a $pageview event.
OpenInference's tool, agent and chain spans carry none of these prefixes either, so isAISpan() drops them. Otis then can't build their agent runs. Don't filter OpenInference spans with isAISpan().
Spans that skip beforeSend
Spans from identifyUser, setUserProperties, setGroupProperties, setSessionProperties, setDocumentProperties, sendArtifactEvent, sendLifecycleEvent and sendException skip beforeSend and are always sent. Your hook can't drop or change them, so they are still sent when it returns null for every span. PII redaction applies to them as usual.
Spans from sendEvent and sendFeedbackSignal go through beforeSend like any other span.
Debug logging
Set debug to log the SDK's activity with console.debug. Pass a comma-separated list of types to log only those.
initOtis({ serviceName: "my-app", debug: true }); // all activity
initOtis({ serviceName: "my-app", debug: "traced,wrap" }); // specific types
initOtis({ serviceName: "my-app", debug: "verbose" }); // full payloads
initOtis({ serviceName: "my-app", debug: "verbose:filter" }); // verbose for specific types| Type | What it logs |
|---|---|
identifyUser | user ID, group count |
setUserProperties | user ID, property count |
setGroupProperties | group type, group ID, property count |
setSessionProperties | property count, or a call skipped because there was no session ID |
setDocumentProperties | property count, or a call skipped because there was no document ID |
sendEvent | event name, attribute count |
sendFeedbackSignal | event ID, signal name, span name |
sendArtifactEvent | artifact ID, stage |
sendLifecycleEvent | plan, previous plan, group type |
sendException | exception type |
traced | function name, arg count |
wrap | the client otis.wrap() recognized, such as vercel-ai, openai or anthropic |
vercelai | the roles and lengths of the messages a wrapped Vercel AI SDK call receives |
artifact | a system prompt or set of tool definitions longer than 60 KiB, which the SDK cuts: its size in bytes, and how many tool definitions were left out. Logged once for each distinct prompt or tool set in each process |
filter | each span beforeSend kept or dropped, and why; each attribute PII redaction dropped because of its key; each otis_dont_redact_ key it let through |
export | each batch sent and its size (verbose: span names), each retry, and spans dropped because the queue was full |
Each line starts with [otis:<type>]. In verbose mode, the full payload follows on a second line that starts with [otis:<type>:payload].
Sample output when using beforeSend with debug: "filter":
[otis:filter] { span: 'ai.generateText', kept: true, reason: 'beforeSend' }
[otis:filter] { span: 'http.request', kept: false, reason: 'beforeSend_drop' }
[otis:filter] { span: 'otis_identifyUser', kept: true, reason: 'otis_event' }Error handling
SDK export errors
By default, the SDK logs errors during span export to console.error. A listener you register receives them instead:
otis.on("error", (err) => {
myErrorReporter.captureException(err);
});Application errors
See sendException() for surfacing app errors as their own span.
Already using OpenTelemetry?
If your app already has OpenTelemetry instrumentation you want to keep, or you're emitting OTel spans from a non-wrapped AI library, see the OpenTelemetry integration guide. It covers OtisSpanProcessor, OtisExporter, the GenAI attribute conventions Otis recognizes, and how otis.wrap() coexists with an existing tracer provider.
Key types & exports
import {
// Initialization
initOtis,
getOtisInstance,
shutdownOtis,
// Core class
Otis,
// Wrapping
type WrapContext,
type WrapOptions,
type OtisOptions,
extractUserTextContent,
// Chat request helpers
contextFromChatRequest,
type ChatRequestContextOptions,
OTIS_SESSION_COOKIE, // "__otis_session"
OTIS_SESSION_HEADER, // "x-otis-session-id"
// Span
OtisSpan,
getSpanId,
// Event helpers
identifyUser,
setUserProperties,
setGroupProperties,
setSessionProperties,
setDocumentProperties,
sendEvent,
sendFeedbackSignal,
sendArtifactEvent,
sendLifecycleEvent,
// Context inspection
currentSpan,
withCurrent,
withArtifactContext,
type ToolCallRef, // the tool-call object otis.tracedTool() takes
// Application errors
sendException,
// Identifier processing for hand-built OpenTelemetry spans
processUserId,
processSessionId,
processArtifactId,
// Per-call metadata
eventMetadata,
// Browser identity
CookieIdentityManager,
type BrowserIdentityConfig,
// Hashing
isHashedUserId,
isHashedSessionId,
isHashedGroupId,
// Filtering
isAISpan,
// PII redaction defaults
DEFAULT_SCAN_ATTRIBUTES,
DEFAULT_SCAN_ATTRIBUTE_PREFIXES,
// Escape hatch
OtisSpanProcessor,
OtisExporter,
} from "@runotis/sdk";
// Browser only
import { setEventGroups } from "@runotis/sdk/browser";
// Next.js client
import {
OtisProvider,
useOtis,
OtisPageView,
withOtisConfig,
} from "@runotis/sdk/next";
// Next.js server
import {
createOtisInstrumentation,
getServerOtis,
} from "@runotis/sdk/next/server";