Frameworks
Every way to instrument an agent, in Python and TypeScript — from a bare context manager to a fully patched framework runner. Pick the one that matches your stack; the install command pulls in the right adapter and the snippet is the minimal working integration.
Python
Python SDK (direct)
pip install morse-aiDirect instrumentation of any Python function via a context manager — no framework required.
Quote the extras in every pip install below ("morse-ai[...]"): zsh, the default shell on
macOS, stops with no matches found on an unquoted bracket.
import morse_ai
morse_ai.init() # reads MORSE_API_KEY from your environment
# Stand-ins so this runs as written, with no model key. Replace them with your agent logic
# and a check your code can really make.
def do_work():
return "42"
def is_valid(result):
return result == "42"
with morse_ai.run("my-agent") as r:
result = do_work()
# Declare an outcome only from something your code can check.
ok = is_valid(result)
r.set_outcome(success=ok, outcome="valid" if ok else "invalid")A run with no model call is recorded but has no cost, and the Traces list can hide it as
low-signal (click Show them to see it). For a first run that shows a model, tokens and a
cost, use the Quickstart snippet. Leave set_outcome out
when your code cannot tell whether the run worked: the run then stays unlabeled, which is not the
same as failed.
LangChain
pip install "morse-ai[langchain]" langchainAutomatic chain/agent tracing via a callback handler — no code inside the chain changes. If
langchain_core isn’t installed, MorseCallbackHandler is still importable but produces no
output (silent no-op). Plain LangChain needs only the langchain extra; the langgraph extra is
for LangGraph.
import morse_ai
from morse_ai.adapters.langchain import MorseCallbackHandler
morse_ai.init() # reads MORSE_API_KEY from your environment
handler = MorseCallbackHandler(agent_name="my-agent")
# Add the callback to any LangChain chain or agent
chain = your_chain.with_config(callbacks=[handler])
chain.invoke({"input": "Hello, world!"})LangChain callbacks are per call: a tool or model you call directly (my_tool.invoke(...))
outside the configured chain is recorded only if you pass the handler to that call too
(my_tool.invoke(args, config={"callbacks": [handler]})).
LangGraph
pip install "morse-ai[langgraph]" langgraphPer-node span capture plus automatic sub-agent topology from graph structure. When both LangGraph and LangChain are importable, auto-detect installs only the LangGraph adapter so a graph built on LangChain primitives doesn’t get duplicate spans.
init() installs the graph-aware adapter for you, so there is nothing to attach to the graph:
import morse_ai
morse_ai.init() # reads MORSE_API_KEY from your environment; also auto-installs the LangGraph adapter
result = your_graph.invoke({"messages": [...]})Async graphs work the same way: await your_graph.ainvoke(...) (or astream) inside a FastAPI
handler records one trace per request, with every node, LLM and tool span, without run(). Wrap a
request in with morse_ai.run("my-graph"): only when you want to name it yourself.
You do not need MorseCallbackHandler here. If you already pass one in
config={"callbacks": [...]}, each LLM, chain and tool call is still recorded once (the SDK
de-duplicates by LangChain run id), and on langgraph 1.0.x as well as the latest release.
OpenAI
pip install "morse-ai[openai]" openaiIn the Python SDK, wrap() traces both client.chat.completions.create and
client.responses.create, for synchronous and asynchronous clients. The module-level
install() also traces both APIs by patching their resource classes; Responses API
patching is skipped if the installed OpenAI package does not expose those classes.
import morse_ai
from morse_ai.adapters.openai import wrap
import openai
morse_ai.init() # reads MORSE_API_KEY from your environment
# Wrap your OpenAI client — Chat Completions and Responses calls are traced
client = wrap(openai.OpenAI())
with morse_ai.run("my-agent"):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Hello, world!"}],
)Known limits of the OpenAI client integration.
- Streaming is not captured yet. Calls with
stream=Truepass through untouched, but the model, token counts and cost are missing from the span. - Zero-config auto-detect does not instrument a raw OpenAI client. Setting only
MORSE_API_KEYis not enough foropenai.OpenAI(): callwrap()on the client (above) orinstall()frommorse_ai.adapters.openai. (The OpenAI Agents SDK below is auto-detected.)
OpenAI Agents
pip install "morse-ai[openai_agents]" openai-agentsRunner is patched automatically once you call install() — every agent run and handoff is
traced.
import morse_ai
from morse_ai.adapters.openai_agents import install
from agents import Agent, Runner
morse_ai.init() # reads MORSE_API_KEY from your environment
install() # patches Runner automatically — no further changes needed
agent = Agent(name="my-agent", instructions="You are a helpful assistant.", model="gpt-4o-mini")
result = Runner.run_sync(agent, "Hello, world!")Since SDK 0.7.0, an agent with no model= records the model the SDK actually ran and is priced, a
run that raises is recorded as failed without a morse_ai.run() wrapper, and a tool that raises
shows as a failed tool span even though the Agents SDK feeds the error back to the model.
OpenAI’s own tracer keeps running. The Agents SDK uploads its own trace of every run to OpenAI, independently of Morse. We deliberately leave that alone — it is your data path, your key, and switching off another vendor’s telemetry inside your process is not something an observability SDK should do behind your back.
If you don’t want the double capture, turn it off yourself:
export OPENAI_AGENTS_DISABLE_TRACING=1Set it before the first agent run — the Agents SDK reads it once, lazily. Note the name: a
plausible-looking OPENAI_TRACING_DISABLED is read by nothing and will leave tracing on.
Anthropic
pip install "morse-ai[anthropic]" anthropicWrap your client with wrap() — every messages.create call on the returned instance is traced,
including cache/token usage. No monkey-patching of the Anthropic class; only the wrapped instance
is instrumented, so an unwrapped client alongside it is unaffected.
import morse_ai
from morse_ai.adapters.anthropic import wrap
import anthropic
morse_ai.init() # reads MORSE_API_KEY from your environment
# Wrap your Anthropic client — every messages.create call is traced
client = wrap(anthropic.Anthropic())
with morse_ai.run("my-agent"):
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, world!"}],
)Since SDK 0.7.0, streaming is traced as well: messages.stream(...) and
messages.create(..., stream=True) record one llm span with model, tokens (including cache
tokens) and cost when the stream ends. A call that raises records a failed llm span with the
requested model. Older SDKs pass streaming calls through with the model, tokens and cost missing.
wrap() is the explicit, recommended path. A module-level install() also exists (used by
zero-config auto-detect when you only set MORSE_API_KEY), but wrap() mirrors the TypeScript
SDK’s wrapAnthropic() and has no global state.
Claude Agent SDK
pip install morse-ai claude-agent-sdkclaude_agent_sdk.query is patched automatically once you call install() — existing code works
unchanged. The Claude Agent SDK runs the Claude Code CLI underneath, so it needs the same Anthropic
credentials Claude Code does (for example ANTHROPIC_API_KEY).
query() is an async generator: calling it only creates the generator, and nothing runs (no
model call, no trace) until you iterate it with async for inside an async function. Run that
function with asyncio.run:
import asyncio
import claude_agent_sdk
import morse_ai
from morse_ai.adapters.claude_agent_sdk import install
morse_ai.init() # reads MORSE_API_KEY from your environment
install() # patches claude_agent_sdk.query automatically
async def main() -> None:
async for message in claude_agent_sdk.query(prompt="Give three uses for a paperclip."):
print(message)
asyncio.run(main())
morse_ai.flush() # short scripts: send everything before the process exitsCall it as claude_agent_sdk.query(...), or do from claude_agent_sdk import query only after
install() has run: an earlier import keeps the unpatched function and records nothing.
When you do not open a run yourself, the adapter opens one named claude-agent-sdk and records it
as successful unless the CLI reports an error. To judge the result with your own check instead,
wrap the loop in with morse_ai.run("my-agent") as r: and call r.set_outcome(...) from something
your code can verify.
TypeScript
The TypeScript SDK covers the same ground as Python, plus the Vercel AI SDK. Install commands
use the published npm name @morsehq-dev/sdk. The snippets use top-level await, which needs an
ES module: save them as .mjs (or set "type": "module" in your package.json).
TypeScript SDK (direct)
npm install @morsehq-dev/sdkDirect instrumentation of any async function via run()/spanAsync(). run() accepts an async
callback and closes the trace when it settles. There is no runAsync export: use run()
(or runTraced()).
Top-level await needs an ES module: save the file as .mjs (or set "type": "module" in your
package.json), or wrap the code in an async function in a CommonJS file. Otherwise node stops
with a SyntaxError on the first await.
import * as morse from "@morsehq-dev/sdk";
morse.init(); // reads MORSE_API_KEY from your environment
await morse.run({ agentName: "my-agent" }, async (handle) => {
const result = await doWork(); // your agent logic here
// Declare an outcome only from something your code can check.
const ok = isValid(result);
handle.setOutcome(ok, ok ? "valid" : "invalid");
});
await morse.flush(); // short scripts: send everything before the process exitsAs in Python, a run with no model call has no cost and can be hidden from the default Traces list as low-signal. For a first run that shows a model, tokens and a cost, use the Quickstart snippet.
OpenAI Agents
npm install @morsehq-dev/sdk @openai/agentsRunner is wrapped — agent, llm, tool, handoff and guardrail spans.
import * as morse from "@morsehq-dev/sdk";
import { Runner, Agent } from "@openai/agents";
import { wrapRunnerWithFullInstrumentation } from "@morsehq-dev/sdk/openai-agents";
morse.init({ apiKey: process.env.MORSE_API_KEY });
const { runner, dispose } = wrapRunnerWithFullInstrumentation(new Runner());
const agent = new Agent({ name: "my-agent", model: "gpt-4o" });
await runner.run(agent, "Hello, world!");
dispose();LangGraph
npm install @morsehq-dev/sdk @langchain/langgraphOne callback handler — agent spans per chain, plus llm and tool spans.
import * as morse from "@morsehq-dev/sdk";
import { MorseCallbackHandler } from "@morsehq-dev/sdk/langgraph";
morse.init({ apiKey: process.env.MORSE_API_KEY });
// Wrap the invocation in run(): outside an open trace the handler has nowhere to record the
// spans and logs "called with no active trace".
const handler = new MorseCallbackHandler();
await morse.run({ agentName: "my-graph" }, async () => {
const result = await graph.invoke(input, { callbacks: [handler] });
});LangChain
npm install @morsehq-dev/sdk @langchain/coreChains, LLMs, tools and retrievers outside a LangGraph graph.
import * as morse from "@morsehq-dev/sdk";
import { MorseCallbackHandler } from "@morsehq-dev/sdk/langchain";
morse.init({ apiKey: process.env.MORSE_API_KEY });
// Unlike the langgraph adapter this one opens no trace of its own —
// wrap the invocation so the spans have a parent.
const handler = new MorseCallbackHandler({ agentName: "my-agent" });
await morse.run({ agentName: "my-agent" }, async () => {
await chain.invoke(input, { callbacks: [handler] });
});Anthropic
npm install @morsehq-dev/sdk @anthropic-ai/sdkEvery messages.create call on the wrapped client is traced. Save the file as .mjs (top-level
await needs an ES module) and set ANTHROPIC_API_KEY:
import * as morse from "@morsehq-dev/sdk";
import Anthropic from "@anthropic-ai/sdk";
import { wrapAnthropic } from "@morsehq-dev/sdk/anthropic";
morse.init(); // reads MORSE_API_KEY from your environment
const client = wrapAnthropic(new Anthropic());
// Each messages.create call on the wrapped client emits an llm span inside the run.
await morse.run({ agentName: "my-agent" }, async () => {
await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 200,
messages: [{ role: "user", content: "Hello, world!" }],
});
});
await morse.flush(); // short scripts: send everything before the process exitsSince SDK 0.7.0, messages.create({ stream: true }) and messages.stream(...) are traced too: the
span gets model, tokens, cache tokens and cost when the stream ends.
OpenAI
npm install @morsehq-dev/sdk openaiwrapOpenAI wraps chat.completions.create on the client, the same way wrapAnthropic wraps
messages.create. The TypeScript wrapper covers Chat Completions only; Python’s wrap() also
covers responses.create.
import * as morse from "@morsehq-dev/sdk";
import OpenAI from "openai";
import { wrapOpenAI } from "@morsehq-dev/sdk/openai";
morse.init(); // reads MORSE_API_KEY from your environment
const client = wrapOpenAI(new OpenAI());
await morse.run({ agentName: "my-agent" }, async () => {
await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Hello, world!" }],
});
});
await morse.flush(); // short scripts: send everything before the process exitsClaude Agent SDK
npm install @morsehq-dev/sdk @anthropic-ai/claude-agent-sdkAgent, llm, tool, subagent-spawn and hook spans. Wrap the package’s top-level
query and call the returned function in its place — the result is still a
Query, with interrupt() and the other control methods forwarded through.
import * as morse from "@morsehq-dev/sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { wrapClaudeAgentQuery } from "@morsehq-dev/sdk/anthropic-agent-sdk";
morse.init({ apiKey: process.env.MORSE_API_KEY });
const tracedQuery = wrapClaudeAgentQuery(query);
for await (const message of tracedQuery({ prompt: "Hello, world!" })) {
// your code unchanged — spans flow to Morse as a side-effect
}The TypeScript and Python packages have different APIs. In TypeScript you wrap the top-level
query({ prompt, options }) with wrapClaudeAgentQuery; there is no ClaudeSDKClient class to
construct. In Python you call install() once and then iterate claude_agent_sdk.query(prompt=...)
in an async function. Use each language’s own snippet — the two are not interchangeable.
Vercel AI SDK
npm install @morsehq-dev/sdk aiOne agent span per streamText / generateText / generateObject call.
import * as morse from "@morsehq-dev/sdk";
import { createTracedVercelAI } from "@morsehq-dev/sdk/vercel-ai";
morse.init({ apiKey: process.env.MORSE_API_KEY });
// "ai" ships pure ESM with non-configurable exports, so there is nothing to
// monkey-patch — call these traced replacements instead of ai's own.
const { streamText, generateText, generateObject } = await createTracedVercelAI();Keep your AI SDK calls: use its own telemetry
morseTelemetry() is in @morsehq-dev/sdk 0.7.0 and later. On an older version, send the AI
SDK’s spans with no Morse SDK at all: see the Vercel AI SDK block under
Other frameworks via OpenTelemetry.
Prefer to keep importing generateText and streamText from ai? Send the AI SDK’s own
OpenTelemetry spans to Morse instead. In ai 7 the spans come from an integration in
@ai-sdk/otel, so install it and pass the class in:
npm install @morsehq-dev/sdk ai @ai-sdk/otelimport { generateText } from "ai";
import { LegacyOpenTelemetry } from "@ai-sdk/otel";
import * as morse from "@morsehq-dev/sdk";
import { morseTelemetry } from "@morsehq-dev/sdk/vercel-ai";
morse.init({ apiKey: process.env.MORSE_API_KEY });
await generateText({
model,
prompt,
telemetry: morseTelemetry({
integration: LegacyOpenTelemetry, // or OpenTelemetry for GenAI span names
functionId: "support-agent", // names the agent
threadId, // one thread per conversation
userId,
}),
});- The spans go through the SDK’s own exporter, sampling and redaction. Your own OpenTelemetry setup is not touched and does not see them.
- Call
morse.init()first. Without it,morseTelemetry()turns telemetry off for that call and prints one warning. It never throws. - Inside
morse.run(), the AI SDK spans join that trace. - Use this or
createTracedVercelAI()for a given call, not both: both would record it. - On
ai6 or older, passmorseTracer()asexperimental_telemetry.tracer.
Other frameworks via OpenTelemetry
Frameworks without a Morse SDK adapter reach Morse over OTLP, through the framework’s own OpenTelemetry output or a community instrumentor. Every row below was checked against a real capture from the real library, not a mock.
Two tiers:
- Verdict-ready: the framework’s spans carry everything Morse needs to price, thread and
judge a run once you set an outcome: typed spans, model, tokens and cost, input and output,
and a thread id. No framework declares whether a run succeeded, so you add
morse.outcome.successyourself. See Where to set the outcome when a framework owns the spans. - Traces: the trace reaches Morse, but spend or a key field is missing or unreliable. You can read the run; Morse cannot yet price it or group it into a thread correctly.
| Framework | Version tested | Path | Tier | Known gaps |
|---|---|---|---|---|
| CrewAI | 1.15.27 | OTLP via OpenInference | Verdict-ready | None found in the capture. |
| CrewAI | 1.15.27 | OTLP via OpenLLMetry | Traces | No tool span, so a tool call is visible only inside the next model call’s input. No session id, so no thread. |
| smolagents | 1.26.0 | OTLP via OpenInference | Verdict-ready | The agent span reports the sum of its model calls. Morse records it and never adds it to the total. |
| LlamaIndex | 0.14.25 | OTLP via OpenInference | Verdict-ready | No agent span type (agent and workflow spans show as custom). Embedding calls report no tokens, so embedding spend is not counted. |
| LlamaIndex | 0.14.25 | OTLP via OpenLLMetry | Traces | No agent, tool or retriever spans (all show as custom). The session id needs Traceloop SDK association properties. Embeddings are not priced. |
| DSPy | 3.4.1 | OTLP via OpenInference | Verdict-ready | No agent span (ReAct shows as custom). finish counts as a tool call. Each model call produces two model spans; Morse counts only the one that reports usage. |
| Agno | 3.1.3 | OTLP via OpenInference | Verdict-ready | Agent configuration and tool schemas are kept as plain metadata. |
| Haystack 2.x | 2.31 | OTLP via OpenInference | Verdict-ready | No tool span (tool calls show as custom). No agent span type. |
| Haystack 3.x | 3.3.0 | OTLP via OpenInference | Traces | The instrumentor only emits pipeline and agent spans: no model, tokens or cost. Use the native route below. |
| Haystack 3.x | 3.3.0 | OTLP native (opentelemetry-haystack 1.0.0) | Traces | No session id. The model call’s input is stored as raw text, not structured messages. A generator called directly by a pipeline gets no model span on this route; use OpenInference for that. |
| Strands Agents | 1.60.0 | OTLP native GenAI | Verdict-ready | No session id: set morse.thread_id yourself. |
| Google ADK | 2.11.0 | OTLP native GenAI | Verdict-ready | Prompt and response are captured only when OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true. |
| pydantic-ai | 2.55.0 | OTLP native GenAI | Verdict-ready | The run-level span reports the sum of its model calls. Morse records it and never adds it to the total. |
| Mastra (TypeScript) | 1.76.0 | OTLP native GenAI (@mastra/otel-exporter) | Verdict-ready | Model-call spans carry no input messages. The prompt is on the parent step span. |
| Vercel AI SDK (TS) | 6.0.314 | OTLP native ai.* (experimental_telemetry) | Verdict-ready | The tool span keeps the name ai.toolCall; the tool’s name is in its details. Thread and user come only from metadata you set. Embedding calls are counted but not priced. |
| Vercel AI SDK (TS) | 7.0.150 | OTLP @ai-sdk/otel LegacyOpenTelemetry | Verdict-ready | Same as 6.x. Thread and user come only from runtimeContext you include (7.x has no metadata). |
| Vercel AI SDK (TS) | 7.0.150 | OTLP @ai-sdk/otel OpenTelemetry (GenAI) | Verdict-ready | Cached input tokens are not read yet, so a cached prompt is priced as uncached. Thread and user come only from runtimeContext you include. |
| AutoGen | 0.7.5 | OTLP native GenAI | Traces | AutoGen emits no model-call span: no model, tokens, cost or prompt, and no thread id. Pair it with openinference-instrumentation-openai for spend. |
“Version tested” is the framework version in the capture, not a supported range. Versions in between are expected to work but are not verified.
Errors
Error status is mapped from the OpenTelemetry span status on every route: a span that ends with
status ERROR shows as failed in Morse.
Verified on real captures where a tool raises: CrewAI (OpenInference) and pydantic-ai mark the
tool span failed with the exception message, and the trace is failed. LlamaIndex’s FunctionAgent
catches tool exceptions and hands them back to the model as text, so on that route a failing tool
does not show as an error.
Set up a framework
Shared setup. The same settings apply to every framework. Use the signal-specific endpoint variable, as described on the OTLP page:
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="https://api.morsehq.dev/api/v1/otlp/v1/traces"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20$MORSE_API_KEY"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
export OTEL_RESOURCE_ATTRIBUTES="service.name=support-agent,service.version=2.4.1,morse.agent_version=2.4.1,deployment.environment.name=production"service.namebecomes the agent name.service.versionandmorse.agent_versionset the agent version with no code change.deployment.environment.nameis recorded on the trace as what your process reported. It does not change the trace’s environment: that always comes from the API key you send.- A Python process needs a tracer provider that exports over OTLP/HTTP. The standard setup reads the variables above:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)- A Node.js process needs the same.
NodeSDKreads the variables above, includingOTEL_RESOURCE_ATTRIBUTES:
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto// instrumentation.mjs
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(), // reads OTEL_EXPORTER_OTLP_TRACES_* from the environment
});
sdk.start();Load it before your app imports the framework, for example
node --import ./instrumentation.mjs app.mjs.
Already sending these spans to another backend? Add Morse as a second exporter instead: see OTel alongside another backend.
Then open your framework below.
CrewAI
OpenInference (Verdict-ready):
pip install crewai openinference-instrumentation-crewai openinference-instrumentation-openaifrom openinference.instrumentation.crewai import CrewAIInstrumentor
from openinference.instrumentation.openai import OpenAIInstrumentor
CrewAIInstrumentor().instrument()
OpenAIInstrumentor().instrument()Wrap the run in OpenInference’s using_session("<session id>") to get a thread id.
OpenLLMetry (Traces; no tool span, no session id):
pip install crewai opentelemetry-instrumentation-crewai opentelemetry-instrumentation-openaismolagents
pip install smolagents openinference-instrumentation-smolagentsfrom openinference.instrumentation.smolagents import SmolagentsInstrumentor
SmolagentsInstrumentor().instrument()Wrap the run in using_session("<session id>") to get a thread id.
LlamaIndex
OpenInference:
pip install llama-index-core openinference-instrumentation-llama-indexfrom openinference.instrumentation.llama_index import LlamaIndexInstrumentor
LlamaIndexInstrumentor().instrument()OpenLLMetry (the OpenAI instrumentor supplies the model-call spans):
pip install llama-index-core opentelemetry-instrumentation-llamaindex opentelemetry-instrumentation-openaiFor a thread id on this route, set the session through Traceloop SDK association properties.
DSPy
pip install dspy openinference-instrumentation-dspy openinference-instrumentation-litellmfrom openinference.instrumentation.dspy import DSPyInstrumentor
from openinference.instrumentation.litellm import LiteLLMInstrumentor
DSPyInstrumentor().instrument()
LiteLLMInstrumentor().instrument()Agno
pip install agno openinference-instrumentation-agnofrom openinference.instrumentation.agno import AgnoInstrumentor
AgnoInstrumentor().instrument()Wrap the run in using_session("<session id>") to get a thread id.
Haystack
Haystack 2.x, OpenInference:
pip install "haystack-ai==2.31.*" openinference-instrumentation-haystackfrom openinference.instrumentation.haystack import HaystackInstrumentor
HaystackInstrumentor().instrument()Haystack 3.x, native (OpenInference emits pipeline and agent spans only on 3.x):
pip install haystack-ai opentelemetry-haystackEnable Haystack’s OpenTelemetry tracer with content tracing on, as described by the
opentelemetry-haystack package. It sends no session id, so set morse.thread_id yourself, per
conversation on a wrapping span
(pattern).
Strands Agents
pip install "strands-agents[otel]"No instrumentor. Strands emits GenAI spans through the global tracer provider, so the shared setup
above is enough. It sends no session id, so set morse.thread_id per conversation on a span that
wraps the agent call. The pattern is in
Where to set the outcome; the same
span carries the outcome.
Do not set morse.thread_id in OTEL_RESOURCE_ATTRIBUTES for a service that handles many
conversations. A resource attribute applies to every trace the process sends, so every
conversation lands in one thread. It is only right for a process that runs a single conversation.
Google ADK
pip install google-adk
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=trueNo instrumentor. ADK emits GenAI spans through the global tracer provider. Without the content switch above, the run has model and tokens but no prompt or response.
pydantic-ai
pip install "pydantic-ai-slim[openai]"from pydantic_ai import Agent
Agent.instrument_all() # or pass instrument=True to one AgentMastra (TypeScript)
npm install @mastra/otel-exporterPoint the @mastra/otel-exporter OTLP/HTTP settings at the endpoint and header from the shared
setup above.
Vercel AI SDK (TypeScript)
No Morse SDK needed. Run the shared Node setup above, then turn the AI SDK’s telemetry on.
ai 7 emits spans through an integration from @ai-sdk/otel. Register it once, and carry the
thread and user as runtime context:
npm install @ai-sdk/otelimport { generateText, registerTelemetry } from "ai";
import { LegacyOpenTelemetry } from "@ai-sdk/otel";
registerTelemetry(new LegacyOpenTelemetry()); // or new OpenTelemetry({ runtimeContext: true })
await generateText({
model,
prompt,
telemetry: {
isEnabled: true,
functionId: "support-agent", // names the agent
includeRuntimeContext: { thread_id: true, user_id: true },
},
runtimeContext: { thread_id: conversationId, user_id: userId },
});ai 6 and older emit the spans themselves:
await generateText({
model,
prompt,
experimental_telemetry: {
isEnabled: true,
functionId: "support-agent",
metadata: { thread_id: conversationId, user_id: userId },
},
});Leave recordInputs and recordOutputs at their default (on), or the trace has no prompt or
response.
AutoGen
pip install autogen-agentchat openinference-instrumentation-openaiAutoGen’s own tracing gives agent and tool spans. Add the OpenAI instrumentor so the model calls, and their spend, appear:
from openinference.instrumentation.openai import OpenAIInstrumentor
OpenAIInstrumentor().instrument()Where to set the outcome when a framework owns the spans
The framework creates the spans, so there is no span of yours to put morse.outcome.success on.
Make one: wrap the framework call in your own span, and set the outcome on it once you know
whether the run worked. Spans the framework starts inside your span join the same trace, and
Morse reads the outcome from any span of the trace.
Python, with CrewAI as the example:
from opentelemetry import trace
tracer = trace.get_tracer("support-crew")
with tracer.start_as_current_span("support-crew") as span:
span.set_attribute("morse.thread_id", conversation_id) # one thread per conversation
try:
result = crew.kickoff(inputs={"ticket": ticket_text})
except Exception:
span.set_attribute("morse.outcome.success", False)
raise
span.set_attribute("morse.outcome.success", ticket_was_resolved(result))TypeScript, with a Mastra agent as the example:
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("support-agent");
const result = await tracer.startActiveSpan("support-agent", async (span) => {
span.setAttribute("morse.thread_id", conversationId); // one thread per conversation
try {
const output = await agent.generate(prompt);
span.setAttribute("morse.outcome.success", ticketWasResolved(output));
return output;
} catch (err) {
span.setAttribute("morse.outcome.success", false);
throw err;
} finally {
span.end();
}
});ticket_was_resolved()andticketWasResolved()stand for your own check. Only you know what success means for the run.- Your span becomes the trace’s root, so its name can become the agent name. Set
morse.agenton it to name the agent explicitly. See Naming the agent. - Check in Traces that your span and the framework’s spans are one trace. A framework that does not start its spans in the active OpenTelemetry context puts them in a separate trace, and the outcome lands on the wrong one. Then set the outcome on a span of the framework’s own trace, or label the run later with the Record outcomes API.
Before you go live
- Message content. Some libraries do not capture prompts and responses unless you ask. Set
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=truefor Google ADK. OpenLLMetry captures content by default (TRACELOOP_TRACE_CONTENTdefaults to on). OpenInference captures content by default too; theOPENINFERENCE_HIDE_*switches (for exampleOPENINFERENCE_HIDE_INPUTS) turn it off. Whatever arrives is stored encrypted. - Run only one instrumentor per LLM client, or calls are counted twice. If a framework instrumentor and a provider instrumentor (for example OpenAI) both wrap the same model call, Morse sees two spans and prices both. Morse does not de-duplicate them.
- Already sending spans to another backend? Add Morse as a second exporter, through an OpenTelemetry Collector or in your app, so the app is instrumented once. See OTel alongside another backend.
- Check it worked. Run the agent once, then open Traces. The run should appear with a model, token counts and a cost on its model spans, and with the outcome you set. If the trace is there but spend is empty, find your framework in the table above: it is a Traces-tier gap, not a setup error. If the outcome is missing, the run is left out of success rate and cost per confirmed outcome: see Where to set the outcome. If nothing arrived, see Nothing showed up?.
Retriever documents (beta)
The LangChain and LangGraph adapters (Python) and the LangChain adapter (TypeScript) record every
retriever call as a retriever span. In the span’s output, documents lists up to 10 returned
chunks:
{
"docs_count": 12,
"documents": [
{
"rank": 1,
"id": "kb-42",
"content": "Seat changes are free up to…",
"score": 0.91,
"source": "kb/seats.md"
}
]
}rankis the order the retriever returned the chunk, starting at 1.idis the document id from the retriever (idormetadata.id); when there is none, Morse’s SDK derives a stable id from the content.contentis truncated to 2 KB. A truncated chunk also carries"truncated": true.scoreis read frommetadata.scoreormetadata.relevance_score, otherwisenull.
Chunk content is stored with the rest of the span payload: redacted, then encrypted at rest. This
field is @beta and additive — docs_count, top_score, sources and content_preview are
unchanged. Morse uses it to score retrieval quality on a sample
of traces.
Related
- Overview — auto-detect, adapter priority, and the silent-failure guarantee.
- Installation — create an API key and the minimal
init()call. - set_context() — attach cost-attribution dimensions to a trace.