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"],
},
});| Option | Env var | Default | Description |
|---|---|---|---|
apiKey | OTIS_API_KEY | required unless disabled | Authentication token |
serviceName | — | required unless disabled | Service 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 |
endpoint | OTIS_ENDPOINT | https://ingest.runotis.com | Collector URL |
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 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 | — | true | Flush on SIGTERM / SIGINT, then re-raise so the process still terminates (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 | HMAC identifier pseudonymization, seeded from the API key (see Privacy) |
identifierHashKey | OTIS_IDENTIFIER_HASH_KEY | the API 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) |
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:
| Dimension | Answers |
|---|---|
serviceName | Which deployment unit / process emitted this? |
surface | Which 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:
| Prefix | Source |
|---|---|
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| Type | What it logs |
|---|---|
identifyUser | user ID, group count |
setUserProperties | user ID, property count |
setGroupProperties | group type, group ID, property count |
sendEvent | event name, attribute count |
sendFeedbackSignal | event ID, signal name |
traced | function name, arg count |
wrap | wrapped function name |
filter | span name, kept/dropped, reason |
export | batch 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";