Reference
Tracing
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 Events 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).
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 |
| Anthropic | messages.create, messages.stream | Token usage from usage.input_tokens / usage.output_tokens + cache tokens |
| Claude Agent SDK | query() AsyncGenerator | Processes SDKMessage events for tool use and metrics |
All wrappers accept the same { context, nested, buildEvent, extractUserMessage } options.
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.
Nested 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 double-count by forgetting to tag an inner call.
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"));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, 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.
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({ eventId }) 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.