OtisDocs

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"],
  },
});
OptionEnv varDefaultDescription
apiKeyOTIS_API_KEYrequired unless disabledAuthentication token
serviceName—required unless disabledService 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 platformThe 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 platformWhere the service runs, such as production or preview. Sent as deployment.environment
endpointOTIS_ENDPOINThttps://ingest.runotis.comThe URL the SDK sends spans to
disabled—falseAll spans silently dropped, no network requests
serverless—falseImmediate 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—trueFlush 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—2000How long that flush may take before the process is allowed to exit
batchConfig—see belowQueue and batch sizing: maxQueueSize (2048), maxExportBatchSize (512; browser 50), scheduledDelayMillis (5000; browser 60000; 0 disables the timer), exportTimeoutMillis (30000), maxConcurrentExports (4)
debug—falseDebug logging
identifierHashing—trueReplace user, session and group IDs with pseudonyms before they leave your app (see Privacy)
identifierHashKeyOTIS_IDENTIFIER_HASH_KEYthe Ingest KeyHash 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—trueSend 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—falseSend each AI call's tool definitions (name, description, input schema) as ai.prompt.tools
captureReasoning—falseSend 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:

DimensionAnswers
serviceNameWhich deployment unit or process emitted this?
surfaceWhere and how did the user, or the user's own agent, perform the task?
agentWhich 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:

  1. A value set on the call itself, with eventMetadata({ agent }).
  2. The scope the call runs in: an enclosing withContext(), or the agent run the call was made from.
  3. The default on the call's wrap.
  4. 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 id of a ToolLoopAgent.
  • The experimental_telemetry.functionId of 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:

PrefixSource
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
TypeWhat it logs
identifyUseruser ID, group count
setUserPropertiesuser ID, property count
setGroupPropertiesgroup type, group ID, property count
setSessionPropertiesproperty count, or a call skipped because there was no session ID
setDocumentPropertiesproperty count, or a call skipped because there was no document ID
sendEventevent name, attribute count
sendFeedbackSignalevent ID, signal name, span name
sendArtifactEventartifact ID, stage
sendLifecycleEventplan, previous plan, group type
sendExceptionexception type
tracedfunction name, arg count
wrapthe client otis.wrap() recognized, such as vercel-ai, openai or anthropic
vercelaithe roles and lengths of the messages a wrapped Vercel AI SDK call receives
artifacta 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
filtereach span beforeSend kept or dropped, and why; each attribute PII redaction dropped because of its key; each otis_dont_redact_ key it let through
exporteach 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";

On this page