OtisDocs

Framework guides

OpenTelemetry

Integrate with an existing OpenTelemetry setup, or send OTel spans directly to Otis.

OpenTelemetry is an open standard for recording what software does. It records each operation as a , such as an (one request to an AI model, with its response) or a tool call. The spans that one request produces form a . Otis reads OpenTelemetry spans, so you can add Otis to an app that already records them.

Follow this guide if either is true:

  • Your app already has OpenTelemetry instrumentation and you want to add Otis without rewriting it.
  • Your app or agent framework records spans through an instrumentation library (the OpenTelemetry GenAI instrumentations, OpenInference, Google ADK, Mastra, or spans you create yourself), and you want Otis to read them.

If you're starting fresh, use the Next.js, Node.js, or Serverless guides instead. Those use otis.wrap(ai) and don't require any OpenTelemetry knowledge. Agent frameworks covers the Claude Agent SDK, the OpenAI Agents SDK, Google ADK, Mastra and OpenInference. Claude API and Claude SDK covers the spans that Anthropic's Claude SDKs record in your app and the Claude API sends from a Claude Console destination.

Approach 1 — Add an Otis span processor to your existing provider

If you already configure a tracer provider for another backend, add OtisSpanProcessor to its span processors:

import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { OtisSpanProcessor } from "@runotis/sdk";

const provider = new NodeTracerProvider({
  spanProcessors: [
    existingProcessor, // the processor that exports to your other backend
    new OtisSpanProcessor({ apiKey: process.env.OTIS_API_KEY! }),
  ],
});
provider.register();

With NodeSDK from @opentelemetry/sdk-node, add OtisSpanProcessor to its spanProcessors list. On OpenTelemetry JS 1.x you can also call provider.addSpanProcessor() before provider.register().

Every span produced on this provider is exported to both your existing backend and Otis.

Approach 2 — Use OtisExporter with @vercel/otel

For Next.js apps on Vercel that use @vercel/otel:

instrumentation.ts
import { registerOTel } from "@vercel/otel";
import { OtisExporter } from "@runotis/sdk";

export function register() {
  registerOTel({
    serviceName: "my-app",
    traceExporter: new OtisExporter({
      apiKey: process.env.OTIS_API_KEY!,
    }),
  });
}

Approach 3 — Point an OTLP exporter at Otis ingest

Any OpenTelemetry SDK or instrumentation can send spans straight to Otis over OTLP, the OpenTelemetry protocol. Otis accepts OTLP over HTTP, with protobuf or JSON bodies, at this endpoint:

https://ingest.runotis.com/v1/traces

Send your Ingest Key in the Authorization header as Bearer <OTIS_API_KEY>. Most OpenTelemetry SDKs read their exporter settings from environment variables:

OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://ingest.runotis.com/v1/traces
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_TRACES_HEADERS=Authorization=Bearer%20<OTIS_API_KEY>

Four settings decide whether spans arrive:

  • Put the full URL only in the traces setting. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT takes the URL as written. The general OTEL_EXPORTER_OTLP_ENDPOINT takes the base URL, https://ingest.runotis.com, and the exporter adds /v1/traces itself.
  • Use the HTTP protocol. The endpoint doesn't accept OTLP over gRPC. Choose your SDK's HTTP exporter, or set the protocol to http/protobuf or http/json.
  • Encode the space in the header value as %20. The OpenTelemetry specification URL-decodes these variables.
  • Batch your spans. Use a batch span processor rather than one that sends each span in its own request. Ingest limits gives the number of requests a project may send each minute.

When a project is over its rate limit, or Otis can't check a key for a moment, the endpoint answers 429 or 503 with a Retry-After header. Exporters that follow the OTLP specification retry these on their own. A missing, unknown or revoked key gets 401, which isn't retried.

In Python:

import os

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider(resource=Resource.create({"service.name": "my-agent"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
    endpoint="https://ingest.runotis.com/v1/traces",
    headers={"Authorization": f"Bearer {os.environ['OTIS_API_KEY']}"},
)))
trace.set_tracer_provider(provider)

Identifying sessions and users

Otis groups spans into by session.id and links them to the people using your product by user.id. Context IDs lists the other keys it accepts. Otis builds tasks and from the spans of a session, so set both IDs on every span your agent emits. An agent run is one execution of an agent, from the input it receives to its final output, with every AI call, tool call and subagent in between.

A GenAI instrumentation may set neither ID. Some, such as Google ADK's and Mastra's, set gen_ai.conversation.id instead, which Otis reads as a , not as a session ID. A chat is one conversation inside a session. A span processor that adds both IDs as each span starts covers every span, including the ones your instrumentation creates. Set the IDs for the length of each request and reset them afterward, so a reused thread doesn't carry one request's user into the next:

from contextlib import contextmanager
from contextvars import ContextVar

from opentelemetry.sdk.trace import SpanProcessor

session_id: ContextVar[str | None] = ContextVar("session_id", default=None)
user_id: ContextVar[str | None] = ContextVar("user_id", default=None)


class IdentitySpanProcessor(SpanProcessor):
    def on_start(self, span, parent_context=None):
        if (value := session_id.get()) is not None:
            span.set_attribute("session.id", value)
        if (value := user_id.get()) is not None:
            span.set_attribute("user.id", value)


@contextmanager
def otis_identity(session: str, user: str):
    session_token = session_id.set(session)
    user_token = user_id.set(user)
    try:
        yield
    finally:
        user_id.reset(user_token)
        session_id.reset(session_token)


provider.add_span_processor(IdentitySpanProcessor())

# Around each request:
with otis_identity(current_session_id, current_user_id):
    run_agent(request)

OpenTelemetry baggage with a baggage span processor does the same job. Baggage also travels on the outgoing requests your service makes, so use a span processor like the one above if a user ID should stay inside your service.

In JavaScript, if your app also uses the Otis SDK, set the IDs through processUserId and processSessionId, as Hand-built OpenTelemetry spans shows. The SDK hashes IDs before they leave your app, and a raw ID on another span makes the same person two users.

For two instrumentations, OtisSpanProcessor sets the IDs for you: the Anthropic SDK's own tracing (@anthropic-ai/sdk 0.134.0 and later) and OpenTelemetry's OpenAI instrumentation (@opentelemetry/instrumentation-openai). When one of their spans starts inside otis.withContext(), on the instance initOtis() returns, the processor adds that scope's user, session and chat IDs to the copy of the span it sends to Otis, hashed the way otis.wrap() hashes them. Your other backends receive the span unchanged. A span that already carries an ID of a kind, under any name Otis reads for it, keeps its own, and so does a Claude Managed Agents span, whose session is its anthropic.session.id. With OtisExporter this doesn't happen, because an exporter receives a span only after it has ended, outside the request that started it.

const otis = initOtis({ apiKey: process.env.OTIS_API_KEY!, serviceName: "my-app" });

// Around each request:
await otis.withContext({ userId: user.id, sessionId: session.id }, () =>
  anthropic.messages.create({ model: "claude-opus-4-6", max_tokens: 1024, messages }),
);

Some frameworks carry these IDs under their own names:

  • OpenInference. Its instrumentations write session.id and user.id, which Otis reads as they are. In Python, wrap each request in using_attributes(session_id=..., user_id=...) from openinference.instrumentation, and every span created inside it carries both.
  • Google ADK. ADK records its session ID as a conversation ID, which Otis reads as a chat ID. Set session.id and user.id with a span processor.
  • Mastra. Put userId and sessionId in Mastra's tracing metadata, and export through @mastra/otel-exporter or @mastra/otel-bridge. Set them with tracingOptions.metadata on a call or requestContextKeys in the observability configuration. Every span of the agent run inherits them, and Otis reads them as the user and session IDs. Put the user's ID in userId, which may differ from the memory resource you pass to the agent.

Capturing message content

Otis reads prompts, responses and tool arguments from span attributes. OpenTelemetry's GenAI instrumentations leave content capture off by default. The OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT environment variable turns it on, and its value chooses where content goes:

  • SPAN_ONLY or SPAN_AND_EVENT. Content is written to spans, where Otis reads it. Use one of these.
  • EVENT_ONLY. Content is written only to OpenTelemetry log records, not to spans.

Some instrumentations accept these values only when OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental is also set, and read true as EVENT_ONLY or as no content. Check your instrumentation's documentation for the values it accepts.

Other instrumentations have switches of their own:

  • OpenInference records content by default. Leave OPENINFERENCE_HIDE_INPUTS and OPENINFERENCE_HIDE_OUTPUTS unset.
  • Mastra records content by default.
  • Google ADK's legacy telemetry has its own switch, described in Framework attributes.

If your instrumentation writes message content under an attribute that this page doesn't list, add the attribute on the Redaction tab's Attributes list in your project's settings. Otis then scans it for personal data before storing it.

Do not combine with otis.wrap()

Don't combine with otis.wrap()

OtisSpanProcessor / OtisExporter and otis.wrap() both route spans to Otis. Using both on the same calls sends duplicate spans. Pick one integration approach per call site.

The Anthropic SDK records its own spans whenever your app registers a global tracer provider. If that provider exports to Otis and you also wrap the client with otis.wrap(), each call reaches Otis twice. Turn the client's own tracing off with openTelemetry: false when you create it, or set ANTHROPIC_OPEN_TELEMETRY=false. Claude API and Claude SDK describes the other way round: sending the Anthropic client's own spans to Otis instead of wrapping it.

Recognized GenAI attributes

Otis reads the attributes below from spans sent by any OpenTelemetry instrumentation: the OpenTelemetry GenAI semantic conventions, OpenInference, the Vercel AI SDK's own telemetry, or spans you create yourself. Where a table lists several attributes, Otis takes the first one that holds a value.

User message

PriorityAttribute
1ai.prompt.lastUserMessage
2ai.prompt.messages
3ai.prompt
4gen_ai.input.messages (GenAI conventions 1.37.0 and later)
5gen_ai.prompt.messages (earlier GenAI conventions)
6gen_ai.prompt

Send the user's words in a user-role message. Otis reads a list of chat messages in two formats: messages made of typed parts, as the current GenAI conventions specify, and OpenAI's chat format, where each message has a role and a content. It reads either format from gen_ai.input.messages, and OpenAI's format from gen_ai.prompt, as a bare list or under a messages key. From such a list, Otis takes the user message from the text of the last user-role message. A tool result is never read as the user's words, so an AI call that returns tool results to the model still shows the question the user asked. Set gen_ai.operation.name on the AI call as well (chat, text_completion or generate_content), so that Otis can tell a call that starts the user's turn from one that continues it with tool results. The conversation history and tool results stay on the span in the attribute they arrived in.

An AI call that continues a conversation usually resends its earlier messages. When gen_ai.input.messages holds messages made of typed parts, and Otis has already stored the earlier messages from a previous AI call, the attribute keeps only the messages this call added. The span's Input tab in the Otis app then shows the whole conversation, rebuilt from the earlier calls, and the Otis MCP server returns it to your coding agent.

An older instrumentation may write each message under its own numbered attribute, such as gen_ai.prompt.0.content. Otis doesn't read that form, so upgrade the instrumentation to a release that writes gen_ai.input.messages.

Response message

PriorityAttribute
1ai.response.text
2ai.response
3ai.response.object
4gen_ai.output.messages
5gen_ai.response.text
6gen_ai.completion
7gen_ai.response

From gen_ai.output.messages, and from gen_ai.completion when it holds messages in OpenAI's chat format, Otis shows the text parts as the response. Tool calls and reasoning in the list stay on the span in the attribute they arrived in.

Token usage

PriorityInput tokensOutput tokens
1ai.usage.promptTokensai.usage.completionTokens
2ai.usage.input_tokensai.usage.output_tokens
3gen_ai.usage.prompt_tokensgen_ai.usage.completion_tokens
4gen_ai.usage.input_tokensgen_ai.usage.output_tokens

Reasoning tokens are read from ai.usage.reasoningTokens, then gen_ai.usage.reasoning.output_tokens, then gen_ai.usage.output_tokens.reasoning. Count them inside the output tokens as well, as the OpenTelemetry GenAI conventions ask, because Otis treats the reasoning count as part of the output count. Leave the attribute off when your provider reports no split, and send 0 when it reports zero, so that "not reported" and "no reasoning" stay apart.

Cached tokens are read the same way, as parts of the input count:

FieldSpan attributes (priority order)
Cache readsai.usage.cacheReadTokens, gen_ai.usage.cache_read.input_tokens, gen_ai.usage.input_tokens.cached
Cache writesai.usage.cacheCreationTokens, gen_ai.usage.cache_creation.input_tokens, gen_ai.usage.cache_write.input_tokens, gen_ai.usage.input_tokens.cache_write
1-hour cache writesai.usage.cacheCreation1hTokens, anthropic.usage.cache_creation.ephemeral_1h_input_tokens, gen_ai.usage.input_tokens.cache_write_1h

Count cache reads and cache writes inside the input tokens. ai.usage.cacheCreation1hTokens is the part of the cache writes that went to Anthropic's 1-hour cache, which is billed at a higher rate than the 5-minute cache. Without it, Otis prices every cache write at the 5-minute rate.

When your provider or gateway reports what it charged for a call, send the amount in US dollars as ai.usage.reportedCostUSD. Cost describes how Otis uses it.

The time to first token of a streamed call is read from ai.stream.msToFirstChunk, in milliseconds, then from gen_ai.response.time_to_first_chunk, in seconds.

Duplicated token usage — ai.usage.skip

If your instrumentation emits a parent span carrying run-level usage and child spans carrying per-step usage — the shape the Vercel AI SDK produces with ai.generateText over ai.doGenerate — the same tokens appear on two spans. Mark the spans whose usage is already accounted for on an ancestor:

AttributeValueMeaning
ai.usage.skiptrue or 1These tokens are already counted on an ancestor span, so Otis leaves them out of token and cost totals.

Absence means count. A span with no ai.usage.skip is counted, so you only set it on the duplicates. If your instrumentation emits usage on exactly one span per logical call — the common case, and the shape to prefer — you never need to set it at all. An agent span that repeats the total of the AI calls under it is the same case: mark the agent span with ai.usage.skip.

On a duplicated child, set both ai.usage.skip = true and ai.nested = true. The two attributes mean different things, as the callout below explains. A duplicated child repeats its parent's tokens and isn't a top-level interaction, so it needs both.

ai.usage.skip is not ai.nested

These look interchangeable and answer different questions. ai.nested marks a call as not a top-level user interaction (a tool's own AI call, or a background enrichment pipeline), so Otis doesn't count it as an interaction with your user. ai.usage.skip answers are these tokens already counted on another span?, and it is the one that decides what goes into token and cost totals. A duplicated child needs ai.usage.skip; tagging it ai.nested alone does not deduplicate it, and a genuine tool-internal call should carry ai.nested and not ai.usage.skip, because its tokens appear nowhere else.

Model and provider

PriorityModelProvider
1ai.model.idai.model.provider
2gen_ai.request.modelgen_ai.system
3—gen_ai.provider.name

Otis counts a span's tokens and cost only when the span names its model under ai.model.id or gen_ai.request.model. Set gen_ai.request.model on every AI call, including when you also send gen_ai.response.model.

Tool calls

FieldSpan attributes (priority order)
Argumentsai.tool.input, gen_ai.tool.call.arguments
Resultai.tool.output, gen_ai.tool.call.result
Tool namegen_ai.tool.name, tool.name, then the span name after execute_tool
Call IDgen_ai.tool.call.id

Set the call ID to the ID the model gave the tool call in its response. Otis uses it to pair each tool call with the AI call that asked for it in an agent run's trajectory.

Agent and surface

Otis reads the agent that handled a request from otis.agent, then gen_ai.agent.name, and the surface from otis.surface. Each is read from the span first and then from the resource. Set either as a resource attribute to give every span from a service the same value.

Context IDs

FieldSpan attributes (priority order)
Sessionsession.id, session_id
Useruser.id, user_id, enduser.id
Documentdocument.id, document_id, project.id, project_id, workspace.id, file.id
Chatchat.id, chat_id, conversation.id, gen_ai.conversation.id, thread.id
Artifactartifact.id, artifact_id

A span attribute wins over a resource attribute. When no span attribute carries one of a field's keys, the same keys are read from the resource. With identifier hashing on for the project, session, user and artifact IDs are pseudonymized under any of these keys, on spans and resources alike. When another attribute on a span holds the same raw user or session ID, Otis replaces it with the same pseudonym. Chat and document IDs are stored as sent, so keep personal data such as an email address out of them.

GenAI span event fallback

When a span's attributes hold no user message or no response, Otis reads the missing one from GenAI span events:

  • gen_ai.user.message, gen_ai.system.message, gen_ai.tool.message → user message
  • gen_ai.assistant.message, gen_ai.choice → response message

Put the text in the event's content or gen_ai.content attribute. Span attributes always take priority over events.

Framework attributes

Some frameworks keep message content under attributes of their own. Otis reads it as it reads the GenAI attributes above:

  • OpenInference, the conventions of Arize's instrumentations for the OpenAI Agents SDK, LangChain, LlamaIndex and others: each AI call's messages, model, tokens and tool definitions, each tool call's arguments, result and call ID, and each agent's name. Otis counts tokens from AI-call (LLM) spans only, so an agent or chain span that repeats its children's totals needs no ai.usage.skip.
  • Google ADK's legacy telemetry, which ADK Java and TypeScript write, and ADK Python writes before 2.13 or with ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=1: each AI call's request and response, and each tool call's arguments and result. ADK Java writes this content only when ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=true is set. ADK Python and TypeScript write it unless the variable is false. ADK Python records each AI call on two spans in this telemetry. Otis counts the call and its tokens once. Don't also install opentelemetry-instrumentation-google-genai with this telemetry: its spans repeat the same AI calls, and Otis then counts each one twice.
  • Mastra, through @mastra/otel-exporter or @mastra/otel-bridge: each step's text and tool calls.

Otis scans this content for personal data, as it scans the attributes above.

Span types

Otis tells an AI call, a tool call and an agent run apart by the attributes below. It doesn't read a span's OpenTelemetry SpanKind.

Span typegen_ai.operation.nameOpenInference openinference.span.kindOtis SDK span names
AI callchat, text_completion, generate_contentLLMai.doGenerate, ai.doStream, anthropic.messages.create, openai.chat.completions.create, openai.responses.create
Tool callexecute_toolTOOLai.tool.<name>, mcp.tool.<name>, cli.tool.<name>
Agent runinvoke_agent, invoke_workflowAGENTai.generateText, ai.streamText, claude-agent.query
EmbeddingembeddingsEMBEDDING
RetrievalretrievalRETRIEVER

If you create spans by hand, set gen_ai.operation.name on each AI call, tool call and agent span. Otis stores a span that matches none of these, such as a chain, turn or step span between an agent and its tools. It leaves that span out of an agent run's steps unless the span failed.

Coexistence with otis.wrap()

otis.wrap(ai) records its spans on the SDK's own tracer provider. It does not register a global tracer provider or change yours, so your existing OpenTelemetry setup keeps working as before.

wrap() and your existing tracer produce separate span trees:

  • wrap() spans go through the SDK's own provider to Otis
  • Your existing tracer's spans go through your own provider to your backend

If you have experimental_telemetry configured on AI SDK calls for another backend, keep it; wrap() passes it through unchanged:

// Both active: Otis gets the wrap() spans, and your other backend gets the AI SDK's telemetry spans
const { streamText } = otis.wrap(ai);
const result = await streamText({
  model: anthropic("claude-sonnet-4-6"),
  prompt: "Hello",
  experimental_telemetry: { isEnabled: true, tracer: myOwnTracer },
});

This means you can start with your existing OpenTelemetry setup and incrementally adopt otis.wrap() for calls where you want the richer AI-specific span tree (tool execution, streaming metrics, auto-detected nested calls).

OpenTelemetry version compatibility

OtisSpanProcessor and OtisExporter work with OpenTelemetry JS 1.x and 2.x.

Integration checklist

  • OTIS_API_KEY set in env
  • OtisSpanProcessor or OtisExporter added to your tracer provider (or OTLP exporter pointed at ingest)
  • An OTLP exporter uses HTTP, with the full URL in a traces-specific endpoint setting
  • Spans are sent through a batch span processor
  • Not double-routing: if using otis.wrap(), not also using OtisSpanProcessor on the same calls
  • Spans emitted by your existing instrumentation use one of the recognized attribute sets
  • Every AI call names its model in gen_ai.request.model or ai.model.id
  • Spans you create by hand set gen_ai.operation.name
  • Every span carries session.id and user.id (with Mastra, userId and sessionId in its tracing metadata)
  • GenAI content capture, if you want message text in Otis, is set to SPAN_ONLY or SPAN_AND_EVENT
  • If usage is emitted on both a parent span and its children, the children carry ai.usage.skip
  • A test AI call produces a trace that reaches Otis with model, tokens, and prompt/response attributes populated

Next steps

On this page