OtisDocs

SDK Reference

Customization

Configuration, surface, 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: "support-copilot",    // Named origin of flows (multi-surface 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,       // HMAC-SHA256 hashing for all identifiers (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——Named origin of flows from this instance (which copilot / MCP server / CLI). Only needed when one service hosts multiple agentic surfaces — see Surface
endpointOTIS_ENDPOINThttps://ingest.runotis.comCollector URL
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 the ingest endpoint declined (429 / 503 / 5xx / 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, then re-raise so the process still terminates (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—trueHMAC identifier pseudonymization, seeded from the API key (see Privacy)
identifierHashKeyOTIS_IDENTIFIER_HASH_KEYthe API 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)

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 the origin of a flow — which copilot, MCP server, or CLI produced it. It's a separate dimension from serviceName:

DimensionAnswers
serviceNameWhich deployment unit / process emitted this?
surfaceWhich named assistant / server / CLI within that service?

Set it when one service hosts more than one agentic surface and you want to segment analytics by which one: two copilots in the same app, a web copilot alongside an MCP server, or several CLIs. A single-assistant app 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: "support-copilot" });

// 2. Per wrap / withContext — overrides the instance default for these spans
otis.wrap(ai, { context: { surface: "onboarding-copilot" } });
otis.withContext({ surface: "onboarding-copilot" }, 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.

beforeSend hook

Full control over every span before export. 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 required attributes to every span
    span.attributes = {
      ...span.attributes,
      "deploy.env": "production",
      "app.version": "1.2.3",
    };

    return span;
  },
});

Filter to AI spans only

import { initOtis, isAISpan } from "@runotis/sdk";

const otis = initOtis({
  serviceName: "my-app",
  beforeSend: (span) => isAISpan(span) ? span : null,
});

isAISpan() recognizes these prefixes:

PrefixSource
ai.*otis.wrap() spans (Vercel AI SDK)
gen_ai.*OpenAI, Anthropic direct SDK instrumentations
llm.*LangChain, LlamaIndex instrumentations
otis.*Otis-namespaced spans

Event spans bypass beforeSend

Event spans bypass beforeSend

Spans produced by identifyUser, setUserProperties, setGroupProperties, sendEvent, and sendFeedbackSignal are always forwarded; they bypass beforeSend entirely. These carry user identity and feedback data that's needed regardless of filtering. You can safely return null for everything in beforeSend without losing identity or feedback data.

Debug logging

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
sendEventevent name, attribute count
sendFeedbackSignalevent ID, signal name
tracedfunction name, arg count
wrapwrapped function name
filterspan name, kept/dropped, reason
exportbatch size; verbose: span names

Output format: console.debug("[otis:<type>]", summary, 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, errors during span export are logged to console.error. Register a listener for programmatic handling:

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,
  sendEvent,
  sendFeedbackSignal,

  // Context inspection
  currentSpan,
  withCurrent,

  // Application errors
  sendException,

  // 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";

// 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