SDK Reference
Agent traces
wrap(), traced(), span.log, supported AI frameworks, context inspection.
The SDK exposes three layers for recording AI calls, custom functions, and their inputs/outputs as spans:
otis.wrap(ai)— automatic tracing for AI framework calls (Vercel AI SDK, OpenAI, Anthropic, Claude Agent SDK).otis.traced(fn)— tracing for any function with automatic input/output capture and child-span nesting.span.log({...})— span enrichment inside a traced function.
For recording discrete user actions or surfacing application errors outside a traced scope, see Custom events and Errors and exceptions.
Layer 1 — otis.wrap(ai)
Wraps an AI framework module so every call produces a span tree.
import * as ai from "ai";
const { streamText, generateText, generateObject, streamObject } = otis.wrap(ai);
const { text } = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt: "Hello",
});The trace produced:
ai.generateText (parent)
└── ai.doGenerate (model call — token usage, finish reason)With tools:
ai.generateText
├── ai.doGenerate
└── ai.tool.getWeather (tool execution — input + output captured)Context
Set userId, chatId, sessionId, and other context on every span from this wrap instance:
const { streamText } = otis.wrap(ai, {
context: {
userId: session.userId,
chatId: chatThread.id,
sessionId: request.sessionId,
documentId: currentDoc.id,
groups: { company: "acme", team: "eng" },
metadata: { route: "/api/chat", tier: "pro" },
},
});All context fields propagate to child spans (model calls, tool executions).
chatId identifies a single conversation, such as one chat thread, so it takes a new value for each conversation. It is the most specific of three labels for where a span came from: serviceName names the deployment, surface names the assistant, MCP server or CLI within it, and chatId names the conversation. Sessions → Chat IDs explains how Otis groups activity by conversation.
What's captured automatically
- Model —
ai.model.id,ai.model.provider - Prompt —
ai.prompt.lastUserMessage(the last user-role message, extracted from multi-part content arrays if needed) - Turn metadata —
ai.turn.new(true for new user turns, false for tool roundtrips);ai.prompt.messages(new messages since the previous user turn) - Response —
ai.response.textorai.response.object - Tokens —
ai.usage.promptTokens,ai.usage.completionTokens, plus cache tokens for providers that support them - Finish reason —
ai.response.finish_reason - Tools —
ai.tool.name,ai.tool.input,ai.tool.output - Streaming metrics —
ai.stream.msToFirstChunk,ai.stream.msToFinish,ai.stream.outputTokensPerSecond - Errors — exception + status
Custom user-message extraction
For apps that inject context (file contents, RAG results) into user messages, override the default extraction:
import { extractUserTextContent } from "@runotis/sdk";
const { streamText } = otis.wrap(ai, {
extractUserMessage: (messages) => {
const userMsg = messages.findLast(m => m.role === "user");
if (!userMsg?.content) return undefined;
return extractUserTextContent(userMsg.content);
},
});Reshape the captured event with buildEvent
buildEvent(messages) is called once per AI call. Use it to override ai.prompt.lastUserMessage and add custom properties when messages have app-specific structure (e.g. a rendered RAG block that buries the actual user question):
const { generateText } = otis.wrap(ai, {
buildEvent: (messages) => {
const lastUser = messages.findLast(m => m.role === "user");
const userText = typeof lastUser?.content === "string" ? lastUser.content : undefined;
return {
input: userText?.split("## User question:").pop()?.trim(),
properties: {
turnCount: messages.length,
hasToolCalls: messages.some(m => m.role === "tool"),
},
};
},
});Returned input overrides ai.prompt.lastUserMessage. Returned properties merge as otis.metadata.<key> attributes.
Exceptions inside buildEvent are caught and never break the call. Hook failures degrade to default extraction.
Supported AI frameworks
otis.wrap() auto-detects the framework:
const wrapped = otis.wrap(target);Or use explicit wrappers:
const openai = otis.wrapOpenAI(new OpenAI());
const anthropic = otis.wrapAnthropic(new Anthropic());
const agent = otis.wrapClaudeAgent(agentModule);| Framework | What's wrapped | Notes |
|---|---|---|
| Vercel AI SDK (v4 – v7) | generateText, streamText, generateObject, streamObject, ToolLoopAgent | Model doGenerate / doStream intercepted; tools captured |
| OpenAI | chat.completions.create, responses.create | Streaming via AsyncIterable; token usage from usage.prompt_tokens / usage.completion_tokens; tool calls recorded (see below) |
| Anthropic | messages.create, messages.stream | Token usage from usage.input_tokens / usage.output_tokens + cache tokens; tool calls recorded (see below) |
| Claude Agent SDK (verified on 0.3.280) | query() AsyncGenerator | One span per tool call, subagent calls nested under the Task call; one span per turn for streaming input; usage from modelUsage, including subagents |
All wrappers accept the same { context, nested, buildEvent, extractUserMessage } options.
Tool calls with the OpenAI and Anthropic clients
With wrapOpenAI or wrapAnthropic, your code runs each tool the model asks for. Otis records each call as an ai.tool.<name> span without any change on your side: it matches the tool call in one response to the tool result you send back in the next request, by the call's id. That span's duration also covers your own code between the two model calls.
To time the tool itself, and to make anything the tool does a child of it, run the call through otis.tracedTool. Pass the model's own tool-call object (an Anthropic tool_use block, an OpenAI tool_calls entry or a Responses function_call item) so Otis can match it and record the call once:
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const output = await otis.tracedTool(block, () => tools[block.name](block.input));
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output });
}If a tool run through tracedTool handles its own failure and returns the error to the model, call otis.currentSpan()?.recordError(err) inside the callback. The call is then recorded as failed rather than as a success. Without tracedTool, the tool call's span does not exist while the tool runs, so Otis marks the call failed only when the Anthropic tool_result you send back sets is_error: true. The OpenAI clients have no equivalent field, so use tracedTool there.
A tool call that never gets a result sent back records nothing. That covers forced tool_choice used for structured output, where no result is ever sent. Pass context: { userId, chatId } to wrapOpenAI or wrapAnthropic so calls from different conversations are never matched to each other; some OpenAI-compatible providers reuse ids such as call_0.
ToolLoopAgent (Vercel AI SDK v6+)
import { eventMetadata } from "@runotis/sdk";
const wrapped = otis.wrap(ai);
const agent = new wrapped.ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
tools: { getWeather: weatherTool },
stopWhen: ai.stepCountIs(5),
});
const result = await agent.generate({
prompt: "What's the weather in SF?",
metadata: eventMetadata({ userId: "user-123", chatId: "chat-abc" }),
});Emits an ai.toolLoopAgent.generate (or .stream) parent span that groups all step/tool spans.
Native telemetry mode (Vercel AI SDK v6+)
const { generateText } = otis.wrap(ai, {
context: { userId: session.userId },
nativeTelemetry: true,
});Uses the AI SDK's sanctioned TelemetryIntegration interface instead of model wrapping. The resulting spans are identical. Use this if you want to compose with another AI SDK telemetry integration, or if you prefer the integration contract over a Proxy wrapper.
Not available on AI SDK v4 or v5; those versions don't expose bindTelemetryIntegration.
Composing with another tracing tool (Braintrust, LangSmith)
Otis runs alongside other AI SDK observability tools — you don't have to pick one. Both backends see every call.
For tools that wrap the ai module (Braintrust's wrapAISDK, or LangSmith's wrapAISDK from langsmith/experimental/vercel), wrap with Otis innermost:
import * as ai from "ai";
import { wrapAISDK } from "langsmith/experimental/vercel"; // or "braintrust"
// The other tool on the outside, Otis on the inside.
const { streamText, generateText } = wrapAISDK(otis.wrap(ai));The reverse order — otis.wrap(wrapAISDK(ai)) — works too; Otis tolerates whatever shape the other wrapper returns.
For LangSmith on AI SDK v7, which registers a global telemetry integration instead of wrapping, pair it with Otis's native telemetry mode — the AI SDK runs both integrations, no ordering required:
import { registerTelemetry } from "ai";
import { LangSmithTelemetry } from "langsmith/experimental/vercel";
registerTelemetry(LangSmithTelemetry()); // LangSmith, global
const { streamText } = otis.wrap(ai, { nativeTelemetry: true }); // Otis, per-callNested AI calls — auto-detected
When a tool's execute() makes its own AI call, the inner call is automatically tagged ai.nested = true. You don't need to pass { nested: true }:
const tools = {
summarize: {
execute: async ({ text }) => {
// Inner call is auto-tagged ai.nested = true
const { text: summary } = await generateText({
model: anthropic("claude-haiku-4-5"),
prompt: `Summarize: ${text}`,
});
return summary;
},
},
};
await streamText({ model, prompt: "...", tools });You don't need to tag inner calls
Top-level user chat interactions are identified as those with ai.nested = false. Auto-detection means you can't accidentally miscount interactions by forgetting to tag an inner call.
ai.nested is about interactions, not about tokens
A nested call's tokens are real spend that appears on no other span — ai.nested says the call wasn't a top-level user interaction, not that its usage is a duplicate of something else. The separate ai.usage.skip attribute is what marks a span whose tokens are already counted on an ancestor. You don't need to set it when using wrap(); it matters if you also emit your own OpenTelemetry spans — see OpenTelemetry.
Override explicitly when needed:
// Always treated as nested — e.g. background enrichment pipelines
const bgEnricher = otis.wrap(ai, { nested: true });
// Always treated as top-level — rare
const surfacedCall = otis.wrap(ai, { nested: false });Layer 2 — otis.traced(fn)
Wrap any function for automatic span creation, I/O capture, and context nesting. The wrapped function receives an OtisSpan as its last argument.
const fetchContext = otis.traced(async function fetchContext(userId: string, span) {
span.log({ metadata: { source: "vector-store" } });
return await vectorStore.search(userId);
});
const handleChat = otis.traced(async function handleChat(msg: string, userId: string, span) {
span.log({ metadata: { userId, route: "/api/chat" } });
const context = await fetchContext(userId); // child span (auto-nested)
const { text } = await streamText({...}); // child span (AI, automatic)
return text;
});Produces:
handleChat
├── fetchContext
└── ai.streamText
└── ai.doStreamWhen to use traced() vs sendEvent()
traced(fn)— for operations with duration and child spans. The function's execution time, inputs, outputs, and any nested AI calls or traced functions are captured as a span tree. Use for: RAG retrieval, document processing, multi-step pipelines, tool execution, anything where "how long did this take and what happened inside" matters.sendEvent(name, attrs)— for discrete, instantaneous user actions. No duration, no nesting. Use for: button clicks, navigation, feature toggles, shares, deploys — anything that's a point-in-time fact, not an operation.
Real-world examples
RAG retrieval pipeline — trace the retrieval so you can see latency, document count, and which step is slow:
const retrieveContext = otis.traced(
async function retrieveContext(query: string, projectId: string, span) {
span.log({ metadata: { projectId } });
// Each step becomes a child span automatically
const embedding = await generateEmbedding(query);
const docs = await vectorStore.similaritySearch(embedding, { projectId, limit: 5 });
const reranked = await reranker.rerank(query, docs);
span.log({ metadata: { docCount: reranked.length } });
return reranked.map(d => d.content).join("\n\n");
},
);
// Use inside a chat handler — nests under the parent withContext span
const context = await retrieveContext(userMessage, projectId);
const result = await streamText({ model, messages: [
{ role: "system", content: context },
{ role: "user", content: userMessage },
]});Document processing — trace a multi-step transformation:
const processDocument = otis.traced(
async function processDocument(file: File, span) {
span.log({ input: file.name, metadata: { size: file.size, type: file.type } });
const text = await extractText(file); // child span
const chunks = await splitIntoChunks(text); // child span
const embeddings = await embedChunks(chunks); // child span
await storeInVectorDB(embeddings); // child span
span.log({ metadata: { chunks: chunks.length } });
return { chunks: chunks.length };
},
);Disable I/O capture for sensitive functions — captureInput: false prevents PII from being recorded even before the redactor runs:
const processPayment = otis.traced(
async function processPayment(card: string, amount: number, span) {
span.log({ metadata: { amount } }); // log amount but not card
return await gateway.charge(card, amount);
},
{ captureInput: false },
);Callers never pass the span. It's injected by the wrapper.
await myFn("hello", 42); // NOT myFn("hello", 42, span)What's captured
- Input arguments (JSON-serialized)
- Return value (JSON-serialized)
- Errors (exception + status)
Streams and async iterables are detected and skipped for output capture. Use span.log({ output }) manually when the stream completes.
Disable I/O capture
For functions handling sensitive data:
const processPayment = otis.traced(
async function processPayment(card: string, span) {
return { success: true };
},
{ captureInput: false, captureOutput: false },
);Layer 3 — span.log()
Ergonomic batched enrichment inside traced functions:
const handleChat = otis.traced(async function handleChat(msg: string, span) {
span.log({
input: msg,
metadata: { userId: "user-123", tier: "pro", route: "/api/chat" },
tags: ["chat", "support"],
});
const result = await generateText({ ... });
span.log({ output: result.text });
return result.text;
});| Field | Attribute | Description |
|---|---|---|
input | otis.input | Operation input (auto-captured from args; can override) |
output | otis.output | Operation output (auto-captured from return; can override) |
metadata | otis.metadata.* | Key-value pairs for filtering and segmentation |
tags | otis.tags | String array for categorization |
Multiple log() calls are additive. metadata merges; input and output overwrite.
Other OtisSpan methods
span.setAttribute("custom.key", "value");
span.recordError(new Error("something failed"));recordError marks the span as failed, and the span stays failed when the function then returns normally. A tool handler can therefore record its failure and still return an error result to the model:
const applyEdit = otis.traced(async function applyEdit(edit: Edit, span) {
const result = await apply(edit);
if (!result.ok) {
span.recordError(new Error(result.reason));
return { status: "error", reason: result.reason };
}
return { status: "ok" };
});Context propagation
Context flows through three mechanisms:
withContext({ userId, sessionId }) ← session-level, applies to ALL spans in scope
└── wrap(ai, { context: { chatId } }) ← per-chat, applies to AI spans
└── traced(fn) ← auto-nests via AsyncLocalStorage| Mechanism | Scope | Fields set | Server | Browser |
|---|---|---|---|---|
withContext() | All spans in callback | userId, sessionId, chatId, documentId, surface, groups, metadata | Yes | No |
wrap(ai, { context }) | AI spans from this wrap instance | Same fields | Yes | No |
| Event helpers | Single event | Inherit from withContext if active | Yes | Yes |
Nested withContext() calls merge: inner values override outer on conflicts. metadata and groups are deep-merged.
userId, sessionId, artifactId and groups are pseudonymised on the way out — you pass your own ids and Otis stores a salted HMAC, never the plaintext. Group ids land on group.<type> and carry the same value identifyUser() writes for that group, so a span and a group membership resolve to one account. metadata and documentId are not identifiers and are stored as given.
Per-call metadata override
Set stable defaults in wrap(), override per call with eventMetadata():
import { eventMetadata } from "@runotis/sdk";
const { generateText } = otis.wrap(ai, {
context: { userId: "anon", metadata: { app: "chef" } }, // defaults
});
await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt: userMessage,
experimental_telemetry: {
metadata: eventMetadata({
userId: session.userId, // override default
chatId: thread.id, // add per-call
sessionId: request.sessionId,
metadata: { route: "/api/chat" },
}),
},
});Precedence (lowest → highest): inherited withContext() < wrap-level defaults < per-call eventMetadata().
Context inspection
For custom correlation, manual span re-entry, or debugging:
import { currentSpan, withCurrent } from "@runotis/sdk";
// Read: what span is active in the current async context?
const span = currentSpan(); // OtisSpan | undefined
if (span) {
span.log({ metadata: { route: "/api/chat" } });
}
// Write: run code with `span` as the active parent
await withCurrent(parentSpan, async () => {
await generateText({ model, prompt }); // child of parentSpan
});Also available on the instance: otis.currentSpan(), otis.withCurrent(span, fn), otis.isInToolScope().
In the browser entry, currentSpan() returns undefined and withCurrent(span, fn) simply invokes fn without scoping, since browsers don't have AsyncLocalStorage. For cross-async correlation in the browser, pass explicit eventMetadata({ chatId }) with stable IDs.
Runtime support
| Runtime | Available |
|---|---|
| Node.js 18+ | Everything |
| Deno | Everything (AsyncLocalStorage available) |
Cloudflare Workers with nodejs_compat | Everything |
| Cloudflare Workers (edge entry) | Event helpers only |
| Browser | Event helpers only |
wrap(), traced(), and withContext() require AsyncLocalStorage. The browser and edge entries export no-ops for these where not applicable.