OtisDocs

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:

  1. otis.wrap(ai) — automatic tracing for AI framework calls (Vercel AI SDK, OpenAI, Anthropic, Claude Agent SDK).
  2. otis.traced(fn) — tracing for any function with automatic input/output capture and child-span nesting.
  3. 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.text or ai.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);
FrameworkWhat's wrappedNotes
Vercel AI SDK (v4 – v7)generateText, streamText, generateObject, streamObject, ToolLoopAgentModel doGenerate / doStream intercepted; tools captured
OpenAIchat.completions.create, responses.createStreaming via AsyncIterable; token usage from usage.prompt_tokens / usage.completion_tokens; tool calls recorded (see below)
Anthropicmessages.create, messages.streamToken usage from usage.input_tokens / usage.output_tokens + cache tokens; tool calls recorded (see below)
Claude Agent SDK (verified on 0.3.280)query() AsyncGeneratorOne 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-call

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 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.doStream

When 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;
});
FieldAttributeDescription
inputotis.inputOperation input (auto-captured from args; can override)
outputotis.outputOperation output (auto-captured from return; can override)
metadataotis.metadata.*Key-value pairs for filtering and segmentation
tagsotis.tagsString 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
MechanismScopeFields setServerBrowser
withContext()All spans in callbackuserId, sessionId, chatId, documentId, surface, groups, metadataYesNo
wrap(ai, { context })AI spans from this wrap instanceSame fieldsYesNo
Event helpersSingle eventInherit from withContext if activeYesYes

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

RuntimeAvailable
Node.js 18+Everything
DenoEverything (AsyncLocalStorage available)
Cloudflare Workers with nodejs_compatEverything
Cloudflare Workers (edge entry)Event helpers only
BrowserEvent helpers only

wrap(), traced(), and withContext() require AsyncLocalStorage. The browser and edge entries export no-ops for these where not applicable.

On this page