SDK Reference
Agent traces
wrap(), traced(), span.log, supported AI frameworks, context inspection.
An is one request your product makes to an AI model, with the model's response. 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, OpenRouter, TypeSafe, 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 (AI 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 (AI calls and tool calls).
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 where and how the user, or the user's own agent, worked, such as your web app or a Slack bot, and chatId names the conversation. A separate label, agent, names the workflow that handled the request. 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 text of the last user message. A tool result is never read as the user's message. To have the user's words recorded, send them as a user message:- On the Vercel AI SDK, pass them in
messages. Apromptstring is recorded asai.promptand starts no turn, because apps also pass one for their own calls, such as writing a title. - On OpenAI's
responses.create, passinputas a list. A stringinputis recorded asai.prompt. A request chained withprevious_response_idthat sends only a tool's output has no user message.
- On the Vercel AI SDK, pass them in
- Turn metadata —
ai.turn.new, true when the call starts a user's turn and false when it continues one after a tool call.ai.prompt.messagesholds the messages that are new since the previous user message, not the whole conversation. On a call that returns tool results, it holds the tool calls and their results. - Response —
ai.response.textorai.response.object, streamed or not. A stream that stops part way records the text it delivered. - Tokens —
ai.usage.promptTokensandai.usage.completionTokens.ai.usage.promptTokensis the whole input, cached tokens included, on every provider. The cache attributes (ai.usage.cacheReadTokens,ai.usage.cacheCreationTokens, andai.usage.cacheCreation1hTokensfor writes to Anthropic's 1-hour cache) are parts of it, never additions to it.ai.usage.reasoningTokensis the part ofai.usage.completionTokensthe model spent reasoning, sent by the OpenAI, OpenRouter, Anthropic and Vercel AI SDK wrappers when the provider reports one. A missing value means the count was not reported, and a reported zero is sent as0. - Billed cost —
ai.usage.reportedCostUSD, only when the call goes through a service that reports what it billed: OpenRouter, or Vercel AI Gateway. - System prompt —
ai.prompt.system. See System prompts and tool definitions to turn the text off or the tool definitions on. - Reasoning —
ai.response.reasoning, only withcaptureReasoning: true. See Reasoning. - 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 openrouter = otis.wrapOpenRouter(new OpenRouter());
const typesafe = otis.wrapTypeSafe(new TypeSafeClient());
const anthropic = otis.wrapAnthropic(new Anthropic());
const agent = otis.wrapClaudeAgent(agentModule);A client otis.wrap() doesn't recognize, such as @google/genai's GoogleGenAI, is returned unchanged, and the SDK logs one warning for it. Its calls work as before and are not traced. To trace them, run them inside otis.traced(), or send the client's own OpenTelemetry spans to Otis (see OpenTelemetry).
| Framework | What's wrapped | Notes |
|---|---|---|
| Vercel AI SDK (v4 – v7) | generateText, streamText, generateObject, streamObject, ToolLoopAgent; on v7, experimental_decide | Each AI call and each tool call is recorded. experimental_decide is a decision model call (see below) |
| OpenAI | chat.completions.create, responses.create, and the helpers that call them (parse, stream, runTools); decisions.create | Streamed replies record their text; tool calls recorded (see below). decisions.create is a decision model call (see below) |
TypeSafe (@typesafe-ai/sdk) | systemOne | A decision model call (see below), against whichever System One host the client points at |
OpenRouter (@openrouter/sdk) | chat.send, responses.send, beta.responses.send, and each request callModel sends to the model; systemOne.create, alpha.decisions.create | Returns a traced copy of the client. The routed model, the billed cost and the generation id are recorded (details). The two decision methods are decision model calls (see below) |
| Anthropic | messages.create, messages.stream, the same on beta.messages, and the helpers that call them (parse, toolRunner, and on 1.x a beta.messages.thread()'s send() and stream()) | Cache tokens recorded; streamed replies record their text; tool calls recorded (see below). On 1.x each thread or tool-runner turn, streamed or not, is one anthropic.beta.messages.create span |
| Claude Agent SDK (tested on 0.3.280) | query() | One span per AI call and per tool call, with a subagent's calls nested under the tool call that started it; one span per turn for streaming input; a marker where the agent's context was compacted; usage includes subagents (details) |
A wrapped OpenAI, Anthropic or TypeSafe client is otherwise the client: a request returns the client's own promise, so withResponse() and asResponse() work, and on OpenAI and Anthropic withOptions() returns a wrapped client.
On @anthropic-ai/sdk 1.x, a thread can send a request again after the server rejects it. The rejected attempt is recorded as a failed span, and only the attempt sent again starts the turn, so the turn is counted once. getSpanId() returns undefined for a streamed thread turn, because the stream belongs to the thread (see Feedback signals).
From @anthropic-ai/sdk 0.134.0, the Anthropic client records its own OpenTelemetry spans on the tracer provider your app registers, or on the one you pass in its openTelemetry option. If that provider also sends to Otis, through OtisSpanProcessor or OtisExporter, each call through a wrapped client reaches Otis twice. The SDK then logs one warning, once a wrapped client has that tracing on and Otis receives one of the client's spans. Create the wrapped client with openTelemetry: false to record each call once. Setting ANTHROPIC_OPEN_TELEMETRY=false instead turns the tracing off for every Anthropic client in your app, so use it only if every one of them is wrapped. Otis leaves the client's own tracing on, because it may also feed another backend.
OpenRouter
otis.wrap() takes an @openrouter/sdk client and returns a traced copy of it. The client you pass in is left unchanged and stays untraced, so make every call through the copy. Wrap the client once, where you create it, and set the context for each request with withContext(). Each wrap() call builds a new client, so wrapping per request is wasteful.
import { OpenRouter } from "@openrouter/sdk";
export const openrouter = otis.wrap(new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }));
// In a request handler:
await otis.withContext({ userId, sessionId, chatId }, async () => {
const completion = await openrouter.chat.send({
chatRequest: { model: "openrouter/auto", messages },
});
});Otis records the following for the copy:
- Calls.
chat.send,responses.sendandbeta.responses.sendeach record one span per call, streamed or not:openrouter.chat.send,openrouter.responses.sendandopenrouter.beta.responses.send. A call that OpenRouter retries is one span, which succeeds if a later attempt does. - Attributes. The span carries what the OpenAI wrapper records (the user's message, turns, the reply, tool calls and token usage). It also carries the model OpenRouter routed the call to as
ai.model.id, the cost OpenRouter billed asai.usage.reportedCostUSD, and the generation id asgen_ai.response.id. The routed model can differ from the one you asked for when you useopenrouter/autoor a list of fallback models. - Failures. A call fails its span when it throws, and also when OpenRouter reports a failure inside a successful response, such as an error in the middle of a stream. A stream you stop reading part way records the text and tool calls that had arrived.
callModel. OpenRouter's agent loop records oneopenrouter.callModelspan for each request it sends to the model. A request that carries a tool's output continues the user's turn, and each tool the loop runs is recorded as anai.tool.<name>span. The spans keep the contextcallModelwas called in, so a result you read later, outsidewithContext(), keeps its user and session.extractUserMessage. The hook gets the messages in OpenAI's format, as sent (tool_call_id,image_url), not the camelCase objects you pass the client.- Other instrumentation. A patch or Proxy that another tool put on the original client is not carried over to the copy. Apply it to the copy instead.
If you call OpenRouter through the openai client instead, the OpenAI wrapper records the billed cost too.
To record OpenRouter calls without changing the code that makes them, use OpenRouter Broadcast instead. Use one or the other for a given client, because a call recorded both ways is counted twice.
Decision models
A decision model answers named questions about an input (a yes/no probability, a choice, a score) instead of writing a reply. A call to one is a . decisions.create on the OpenAI client records openai.decisions.create, systemOne on a TypeSafe client records typesafe.systemOne, and on an OpenRouter client systemOne.create and alpha.decisions.create record openrouter.systemOne.create and openrouter.alpha.decisions.create. A Perplexity client (@perplexity-ai/perplexity_ai) has the OpenAI client's shape, so otis.wrap() wraps its decisions.create the same way. Through otis.wrap(ai), AI SDK 7's experimental_decide (and experimental_evaluate, its earlier name) records ai.decide, whichever decision provider the model comes from.
Each call counts toward usage and cost, and is not recorded as a conversation turn: what it classified is never shown as something your user said. The span holds the input under ai.decision.input, the question names under ai.decision.questions, and the answers under ai.decision.answers, keyed by question name. Images in the input are replaced by their media type and size. On a vendor's own client, send an image as a data: URL or as binary data, because base64 text without a data: prefix can't be told from text and is sent as it is. Your instructions (OpenAI's system and developer items, the question text) are left out. ai.model.provider names the vendor that served the call. A TypeSafe client, and @ai-sdk/typesafe-ai, can point at several vendors, so their provider comes from the client's base URL: openrouter or liquid for those vendors' hosts, and typesafe for any other host, including one you run yourself. An OpenRouter client also records the billed cost.
A refused question makes experimental_decide throw Experimental_DecisionRefusalError. Its span keeps a successful status and records the cause AI content filter, as a content-policy refusal on a chat call does (see Cancellations and refusals). It also keeps the usage the refused call was billed for, as long as you pass the model as a model object rather than as a string id. The other decision APIs return a refusal as one of the answers, which records no cause.
All wrappers accept the same { context, nested, buildEvent, extractUserMessage } options. buildEvent runs on the Vercel AI SDK wrapper. extractUserMessage runs on the Vercel AI SDK, OpenAI, OpenRouter and Anthropic wrappers. On OpenAI's responses.create, it gets a list input as messages, with input_text parts as text parts.
Tool calls with the OpenAI, OpenRouter and Anthropic clients
With wrapOpenAI, wrapOpenRouter 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 AI 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. Set userId and chatId, on the wrap or with withContext() (the only way for an OpenRouter client), so calls from different conversations are never matched to each other. Some OpenAI-compatible providers reuse ids such as call_0.
Claude Agent SDK
Wrap the Claude Agent SDK module and call query() as usual:
import * as agentSdk from "@anthropic-ai/claude-agent-sdk";
const { query } = otis.wrap(agentSdk, { context: { userId, sessionId } });
for await (const message of query({ prompt, options })) {
// ...
}Otis reads the messages query() yields and records each call as an . An agent run is one execution of an agent, from the input it receives to its final output. With a streaming-input prompt (an async iterable of user messages, or streamInput()), each turn of the conversation is an agent run of its own.
claude-agent.query (the call: model, system prompt, total cost)
└── claude-agent.turn (streaming input only: the turn's prompt, answer, usage and cost)
├── claude-agent.model (one per AI call)
├── ai.tool.Read (one per tool call: input, output and call id)
├── ai.tool.Task (a subagent)
│ ├── claude-agent.model (the subagent's AI calls)
│ └── ai.tool.Grep (the subagent's tool calls)
├── claude-agent.model_usage (usage of a model other than the agent run's own)
└── claude-agent.compaction (where the agent's context was compacted)With a string prompt there are no turn spans. The AI calls, tool calls and markers sit directly under claude-agent.query, which carries the prompt, the answer and the usage. A is an agent that another agent starts, through one of its tool calls such as the Task tool, to do part of its work. Its spans sit under that tool call's span.
- AI calls. Each response the agent loop or a subagent receives from the model is a
claude-agent.modelspan, recorded beside the tool calls it asked for. It records the model, the ids and names of the tool calls it requested (ai.response.tool_calls), and its input and cache token counts. The response's text is not recorded on it. The answer is on the turn, or on the query for a string prompt. Its duration is an estimate. By default an AI call has no finish reason and no output-token count. Set the Claude Agent SDK'sincludePartialMessages: trueto record both. - Usage and cost. Token counts and cost come from the usage that
query()reports, subagents included. The AI-call spans' token counts are already in those totals, so don't add them up. A model other than the agent run's own, such as a subagent's, has aclaude-agent.model_usagespan of its own, so it is priced at that model's rate. A Claude Agent SDK version that reports no per-model usage gives the main agent's tokens only, so upgrade it to count subagents. - Tool calls. A tool call whose result comes back as an error is recorded as failed. A call the SDK's permission check denied carries
ai.tool.denied. A call with no result when the agent run ends carriesai.tool.returned = false. - Failures. A turn that ends with an error result, such as reaching
maxTurnsor a prompt too long for the model, is recorded as failed, with its cause. An AI call the SDK reports as failed records the kind of failure asai.response.error(such asrate_limit) and the finish reasonerror. - Compaction. A is the point where the SDK summarizes the agent's earlier messages so that the conversation fits in the model's context. Otis records a zero-length
claude-agent.compactionspan at that point. It holds what started the compaction inai.compaction.trigger(autoormanual) and the context's size in tokens just before it inai.compaction.pre_tokens. The summary the SDK writes after a compaction is never recorded. - System prompt and tools. The
systemPromptyou pass is recorded onclaude-agent.query, subject tocaptureSystemPrompt. With a preset, only the preset's name and the text you append are seen. Tool definitions are read from in-process MCP servers built withcreateSdkMcpServer. Built-in tools, and servers in other processes, have no definition in your process. - Reasoning. Reasoning is not recorded for the Claude Agent SDK. Its spans carry no reasoning text, even with
captureReasoningon, and no reasoning-token count.
If your span counts step up after you upgrade the Otis SDK, new spans are the reason. Earlier versions recorded no span for a tool call that the Claude Agent SDK or your own tool loop ran, and none for a Claude Agent SDK AI call or compaction. Token and cost totals don't change, except that a Claude Agent SDK app's subagent usage is now counted.
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({
messages: [{ role: "user", content: "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,
});Records the calls through the AI SDK's own telemetry integration interface instead of wrapping the model. The resulting spans are identical. Use this if you want to compose with another AI SDK telemetry integration, or if you'd rather not have the model wrapped. generateObject, streamObject and experimental_decide are recorded as in the default mode.
Native telemetry mode needs AI SDK v6 or later.
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
Otis counts a nested call toward usage and cost, and not as an interaction with your user. Auto-detection means you can't 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, artifactId, surface, agent, 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 |
OtisSpanProcessor | Another instrumentation's AI spans, started in callback | userId, sessionId, chatId from withContext(), when the span has none of its own | Yes | No |
Nested withContext() calls merge: inner values override outer on conflicts. metadata and groups are deep-merged.
If your tracer provider sends spans to Otis through OtisSpanProcessor, the user, session and chat IDs of the enclosing withContext() also reach two other instrumentations' spans: the Anthropic SDK's own (@anthropic-ai/sdk 0.134.0 and later) and OpenTelemetry's OpenAI instrumentation (@opentelemetry/instrumentation-openai). Neither records a user. The IDs come from the instance initOtis() returns, and only the copy of the span sent to Otis carries them, so your other backends receive it unchanged. An ID of a kind the span already names, such as the conversation ID you pass to the Anthropic client, is kept. See OpenTelemetry.
userId, sessionId, artifactId and groups are pseudonymized before they leave your app, so with the default settings Otis never receives your own IDs. A group's pseudonym matches the one identifyUser() records for that group, so a span and a group membership resolve to one account. metadata and documentId are stored as given, so keep personal data out of them. Privacy covers identifier hashing.
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"),
messages: [{ role: "user", content: 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() and otis.withCurrent(span, fn).
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.19 or later, or 20.6 or later | 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.