OOtis Docs

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:

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

  • Modelai.model.id, ai.model.provider
  • Promptai.prompt.lastUserMessage (the last user-role message, extracted from multi-part content arrays if needed)
  • Turn metadataai.turn.new (true for new user turns, false for tool roundtrips); ai.prompt.messages (new messages since the previous user turn)
  • Responseai.response.text or ai.response.object
  • Tokensai.usage.promptTokens, ai.usage.completionTokens, plus cache tokens for providers that support them
  • Finish reasonai.response.finish_reason
  • Toolsai.tool.name, ai.tool.input, ai.tool.output
  • Streaming metricsai.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
Anthropicmessages.create, messages.streamToken usage from usage.input_tokens / usage.output_tokens + cache tokens
Claude Agent SDKquery() AsyncGeneratorProcesses 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.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 functionscaptureInput: 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"));

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

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

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