OTLP
Already emit OpenTelemetry traces? You don’t need our SDK for them. Morse accepts standard OTLP traces directly, so an existing OTel pipeline can point at Morse without adding an SDK dependency to your app.
Already sending these traces to another backend? Keep it, and send a copy to Morse: see OTel alongside another backend.
Quickstart over OTLP
Four steps. They assume your app already has an OpenTelemetry tracer provider that exports over OTLP/HTTP. If it does not, start with the shared setup on the Frameworks page.
-
Get an API key. Open Settings → API keys, find your environment, click Generate (or Rotate), then Generate key. The setup wizard’s first screen also has a Generate API key button. An environment key can always send traces. A personal key works only with Full access; a Read-only key is refused with
403. -
Point the traces exporter at Morse.
export MORSE_API_KEY="<your key>" 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"Use the
TRACESvariables, not the genericOTEL_EXPORTER_OTLP_ENDPOINT, and keep the%20. Endpoint explains both. -
Declare an outcome. Set
morse.outcome.successto a boolean on any span of the run:span.set_attribute("morse.outcome.success", result.resolved)Without it Morse can price the run but not judge it, and the trace is left out of success rate and cost per confirmed outcome. See Declaring outcomes.
-
Check it worked. Run your agent once and open Traces. The run should be there, its model spans should show a model, token counts and a cost, and the trace should show the outcome you declared. If it is not there, see Nothing showed up?.
How it works
Morse’s ingestion is protocol-agnostic: the SDK path and the OTLP path both end up in the same persist function, so a trace looks identical in the UI regardless of how it arrived.
Endpoint
POST https://api.morsehq.dev/api/v1/otlp/v1/tracesapi.morsehq.dev is the API host and the ingest host: there is no separate ingest hostname. If you
were given a different Morse API host (a test environment, for example), use it in place of
api.morsehq.dev here and in the exporter variables below.
This is HTTP only — there is no gRPC ingest endpoint today. If your exporter is configured for OTLP/gRPC, switch it to OTLP/HTTP and point it at the path above.
If your app can only export over gRPC, run an OpenTelemetry Collector that receives gRPC and sends OTLP/HTTP to Morse: see Collector fan-out.
Configure it with the signal-specific environment variable. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
is used exactly as written:
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"Header values are URL-decoded by the OpenTelemetry SDKs, so encode the space in
Bearer YOUR_KEY as %20.
Why not the generic endpoint variable
The OpenTelemetry specification treats OTEL_EXPORTER_OTLP_ENDPOINT as a base URL. The SDK
adds a path for each signal: /v1/traces, /v1/logs and /v1/metrics. That gives two traps:
- Base set to
https://api.morsehq.dev/api/v1/otlp. Traces reach the right place. But if the same process also exports logs or metrics over OTLP, they go to Morse’s logs and metrics endpoints as protobuf. Those endpoints accept JSON only, so the requests fail with415. - Base set to the full traces URL. The SDK adds
/v1/tracesagain, so spans go to/api/v1/otlp/v1/traces/v1/tracesand get404.
The TRACES variables send traces to Morse and nothing else, which avoids both.
Content types
The endpoint accepts either:
application/x-protobuf— standard OTLP protobuf, converted internally and normalized the same way as JSON.application/json— OTLP JSON.
Either can be gzip-compressed (Content-Encoding: gzip), which is what stock exporters usually
send. For a protobuf request the response is a standard ExportTraceServiceResponse. When Morse
rejected some spans, its partial_success field carries the count and the reason.
A request with no Content-Type is treated as JSON. Errors are explicit, so your exporter sees a
failed batch instead of a silent success:
- 415 — any other content type. The logs (
/api/v1/otlp/v1/logs) and metrics (/api/v1/otlp/v1/metrics) endpoints accept JSON only for now and also return 415 for protobuf. - 400 — a body that cannot be parsed, or JSON that is not an object.
- 413 — the request is over your plan’s size cap (checked after gzip decompression), or an OTLP logs request carries more than 1,000 records. Exporters do not retry a 413, so keep batches under the cap: see Request size limits.
Naming the agent
By default a trace’s agent name comes from the resource’s service.name. When that is missing or
is OTel’s unknown_service default, Morse uses the root span’s name. To set the agent name
explicitly per trace, put a morse.agent attribute on the root span. It wins over
service.name — useful when one process runs several agents under a single OTEL_SERVICE_NAME.
The value is trimmed and capped at 255 characters, and it is ignored on non-root spans.
with tracer.start_as_current_span("handle-ticket") as span:
span.set_attribute("morse.agent", "support-agent")Auth
Send your Morse API key the same way you would for the SDK — as a bearer API key, in
Authorization: Bearer <key>. No other header is accepted: an X-API-Key header is not read, and
the request fails with 401.
curl -X POST https://api.morsehq.dev/api/v1/otlp/v1/traces \
-H "Authorization: Bearer $MORSE_API_KEY" \
-H "Content-Type: application/json" \
-d @spans.jsonNormalization
Every OTLP span is mapped into the same typed span model the SDK produces (agent, llm, tool,
http, db, queue, custom and a few more) before it is persisted. The first rule that
matches wins:
- An explicit
morse.span_typeattribute. gen_ai.operation.name:invoke_agentandcreate_agentbecomeagent,execute_toolbecomestool, andchat,generate_contentandtext_completionbecomellm.- Any other
gen_ai.*attribute:llm. db.systemordb.system.name:db.messaging.system:queue.http.method,http.request.method,url.fullorrpc.system:http.- The OTLP span
kind.
Stock OpenTelemetry HTTP, database and messaging spans therefore become http, db and queue
spans. They are never billed and do not change the trace’s status. They stay inside the agent
trace, so you can see the calls a run made. See Infra span fair use.
Span events and links
- Exception events. An
exceptionevent (whatrecord_exception()emits) is kept as the span’s error detail: type, message and stack trace. If a failed span has no status message, the exception message is shown as its error. - GenAI content events. Older GenAI instrumentations put prompts and responses in events
rather than attributes.
gen_ai.system.message,gen_ai.user.message,gen_ai.assistant.message,gen_ai.tool.messageandgen_ai.content.promptbecome the span’s input.gen_ai.choiceandgen_ai.content.completionbecome its output. Thegen_ai.client.inference.operation.detailsevent fills both. When a span carries content in attributes too, the attributes win. Like all content, it is stored encrypted. - Links. Span links are kept on the span, up to 50.
Declaring outcomes
Morse can tell you how much your agents cost the moment spans arrive. It cannot tell you
whether they worked — nothing in the OpenTelemetry semantic conventions expresses “this
run achieved what the user wanted”. A span that ends STATUS_CODE_OK only means the code
did not raise.
So you declare it, with two span attributes:
| Attribute | Type | Meaning |
|---|---|---|
morse.outcome.success | bool | Did this run achieve its goal? Required to declare an outcome. |
morse.outcome.label | string | Optional short reason — "resolved", "escalated", "refund_issued". |
Why it matters
A trace with no morse.outcome.success is excluded from every production-success
metric in Morse — the success rate on your agent pages, and cost-per-confirmed-outcome
(CPSO). A trace carrying only cost is a trace Morse can
bill-analyze but cannot judge.
Declaring an outcome sets that trace’s outcome_provenance to developer — the same
trust tier the Morse SDK’s set_outcome() earns. It is the highest-trust tier available
from instrumentation, because you are the one who knows what success meant.
morse.outcome.label on its own does not do this. The success flag is what earns the
developer tier; a label with no success flag leaves the trace at implicit and still
excluded. Set morse.outcome.success whenever you set a label.
Any span, any batch
Put the attributes on whichever span knows the answer. You do not have to find the root span, and you do not have to hold a span open until the outcome is known — the attributes are read off every span, including spans that arrive in a later export batch.
In practice that means a tool span deep in the run can declare failure, or a final evaluation step can declare success after the agent span has already been exported.
When more than one span declares
The rules match calling the SDK’s set_outcome() twice: the later call wins.
- The span that ended last wins. Morse orders declarations by the declaring span’s end
time, not by when its batch arrived, so a tool span that declared
success = trueearly is overridden by a root span that declaressuccess = falseafter a later check. An older declaration that arrives late does not roll the outcome back. - The flag and the label can be on different spans. If the winning span sets only
morse.outcome.success, the label comes from a span that set onlymorse.outcome.label. A label from an overridden declaration is never reused. - A label you set in the dashboard is never overridden. Mark Success or Mark Failure on a trace outranks any span that arrives afterwards.
- At exactly the same end time, a failure wins.
Each declaration that changes the outcome is kept in the trace’s outcome history, with the source OpenTelemetry.
Morse does not read the OpenTelemetry GenAI evaluation event (gen_ai.evaluation.*) as an
outcome. An evaluation score says how good an answer was, not whether the run achieved its
goal, so declare the outcome with morse.outcome.success.
Examples
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("support-agent") as span:
result = run_agent(ticket)
span.set_attribute("morse.outcome.success", result.resolved)
span.set_attribute("morse.outcome.label", "resolved" if result.resolved else "escalated")import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("support-agent");
await tracer.startActiveSpan("support-agent", async (span) => {
const result = await runAgent(ticket);
span.setAttribute("morse.outcome.success", result.resolved);
span.setAttribute("morse.outcome.label", result.resolved ? "resolved" : "escalated");
span.end();
});import "go.opentelemetry.io/otel"
import "go.opentelemetry.io/otel/attribute"
tracer := otel.Tracer("support-agent")
ctx, span := tracer.Start(ctx, "support-agent")
defer span.End()
result := runAgent(ticket)
span.SetAttributes(
attribute.Bool("morse.outcome.success", result.Resolved),
attribute.String("morse.outcome.label", label(result)),
)Send morse.outcome.success as a real boolean, not the string "true" — a string is
ignored and the trace stays implicit.
There’s no separate “OTLP view” in the product — traces that arrive this way show up in Traces exactly like SDK-originated traces, with the same filters, the same verdict strip, and the same eval/context tabs.
Semantic conventions Morse understands
Libraries describe the same thing in different attribute names. Morse reads three families and maps each into the typed span model above, so you do not translate anything yourself.
- OpenTelemetry GenAI (
gen_ai.*): model, token usage, operation name and conversation id. Used by Strands, Google ADK, pydantic-ai, AutoGen and Mastra. Where a span carriesgen_ai.*keys, those win over anything another convention says. - OpenInference (
openinference.span.kind,llm.*,session.id): used by the Arize instrumentors for CrewAI, smolagents, LlamaIndex, DSPy, Agno and Haystack. - OpenLLMetry (
traceloop.span.kind,traceloop.association.properties.*): used by the Traceloop instrumentors.
A span is read as one convention. If it carries both an OpenInference and a Traceloop marker, Morse reads it as OpenInference and flags the span.
See Frameworks for which framework uses which, and what each one is missing.
How a span kind becomes a span type
The library’s own span kind decides the type. Morse never guesses it from which attributes are present, and never upgrades a generic step to an agent.
| Convention | Kind | Morse type |
|---|---|---|
| OpenTelemetry GenAI | gen_ai.operation.name = invoke_agent, create_agent | agent |
| OpenTelemetry GenAI | gen_ai.operation.name = execute_tool | tool |
| OpenTelemetry GenAI | gen_ai.operation.name = chat, generate_content, text_completion | llm |
| OpenTelemetry GenAI | any other gen_ai.* span | llm |
| OpenInference | LLM, EMBEDDING | llm |
| OpenInference | AGENT | agent |
| OpenInference | TOOL | tool |
| OpenInference | RETRIEVER, RERANKER | retriever |
| OpenInference | GUARDRAIL | guardrail |
| OpenInference | CHAIN, EVALUATOR, PROMPT, UNKNOWN | custom |
| OpenLLMetry | agent | agent |
| OpenLLMetry | tool | tool |
| OpenLLMetry | workflow, task | custom |
A few framework-specific GenAI steps are typed custom so their summed tokens are not priced
twice, for example Strands’ execute_event_loop_cycle and Mastra’s step spans.
Because a CHAIN or workflow span is custom, some frameworks show their agent loop as a
custom span. That is the library’s labelling, not a lost span.
Tokens, cost and content
- Tokens are counted only on model-call spans. Token usage reported on an agent, chain or
workflow span is kept as
reported_usagefor reference and is never priced or added up. This stops a parent span that sums its children from being billed twice. - Model names are used as sent, including dated names and provider prefixes such as
openai/gpt-4o-mini. - Prompts, responses and tool arguments are always stored encrypted, whichever convention carried them. They are never written to the plaintext span metadata. Embedding vectors are dropped.
Attribution with no code change
Set these on the resource, usually through OTEL_RESOURCE_ATTRIBUTES, and they apply to every
span the process sends. A value set on a span itself wins over the resource value.
| Resource attribute | Effect |
|---|---|
morse.agent_version | The agent version for every trace from the process. |
morse.thread_id | One thread id for every trace from the process. See the note below. |
morse.user_id | The end user the traces belong to. |
morse.record_key. + a type | A record key, such as morse.record_key.ticket. |
export OTEL_RESOURCE_ATTRIBUTES="service.name=support-agent,morse.agent_version=2.4.1,morse.record_key.ticket=T-1042"A process-wide morse.thread_id puts every trace from that process in one thread, so use it for
single-conversation processes only. Otherwise set it per conversation on a span, for example
span.set_attribute("morse.thread_id", conversation_id). It can go on any span of the trace.
Session ids from the library are read automatically: session.id (OpenInference),
traceloop.association.properties.session_id (OpenLLMetry) and gen_ai.conversation.id (GenAI).
deployment.environment and deployment.environment.name are recorded on the trace as what your
process reported. They do not change the trace’s environment, which always comes from the API key.
Send to Morse and another backend
If you already export spans to Langfuse, Phoenix or another backend, keep one instrumentor in your app and let an OpenTelemetry Collector copy the spans to each destination. Running a second instrumentor instead counts every model call twice. The minimal Collector exporter is below. OTel alongside another backend has the full config, batch sizing, sampling, exporter blocks per vendor, and an in-app route with no Collector.
exporters:
otlphttp/morse:
traces_endpoint: https://api.morsehq.dev/api/v1/otlp/v1/traces
headers:
Authorization: "Bearer ${env:MORSE_API_KEY}"
compression: gzip
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp/morse] # add your other backend's exporter hereHow to use it
- Point your existing OTel SDK’s OTLP/HTTP exporter at
https://api.morsehq.dev/api/v1/otlp/v1/traceswith your Morse API key as the bearer token. - For traces, send either protobuf or JSON (gzip or not) — don’t reconfigure your exporter’s encoding just for Morse. The logs and metrics endpoints accept JSON only for now, so point only your traces exporter at Morse.
- If you use the OpenTelemetry GenAI conventions, set
gen_ai.operation.nameso agent and tool spans are typedagentandtool. Agen_ai.*span with no operation name is typedllm. - Set
morse.outcome.successon a span so Morse can judge the run and not just price it — see Declaring outcomes. Without it the trace is excluded from success rate and CPSO.
Nothing showed up?
Work down this list. Most problems show in the exporter’s own log, so turn that on first.
See what the exporter is doing
- Node.js: set
OTEL_LOG_LEVEL=debug. Export failures are logged with the HTTP status. - Python: export failures are logged through the
opentelemetryloggers. Make sure your logging config does not silence them.logging.getLogger("opentelemetry").setLevel(logging.DEBUG)shows everything. - Collector: the Collector’s own log shows each failed export with its status code. To see
what the Collector received, add a
debugexporter (verbosity: detailed) to the traces pipeline next to the Morse exporter. - Short scripts: a batch span processor exports every few seconds. A script that exits sooner
drops its spans unless it flushes first. Call
provider.shutdown()in Python, orawait sdk.shutdown()in Node.js, before the process exits.
What each status code means
| Status | Cause | Fix |
|---|---|---|
401 | The key is wrong or revoked, or the header did not parse. In OTEL_EXPORTER_OTLP_*_HEADERS the space after Bearer must be %20. | Check the key, and write the header as Authorization=Bearer%20<key>. |
403 | A Read-only personal key, which cannot send data. | Use an environment key or a Full access personal key. |
404 | Wrong path. Usually /v1/traces was added twice because the full URL was put in OTEL_EXPORTER_OTLP_ENDPOINT. | Put the full URL in OTEL_EXPORTER_OTLP_TRACES_ENDPOINT instead. See Endpoint. |
415 | A content type other than protobuf or JSON, or logs and metrics sent to Morse as protobuf through a generic base URL. | Send traces only: use the TRACES variables, or set OTEL_LOGS_EXPORTER=none and OTEL_METRICS_EXPORTER=none if nothing else needs them. |
413 | The request is over your plan’s size cap, measured after gzip decompression. It is not retried, so the batch is lost. | Send fewer spans per batch. See Request size limits. |
429 | An ingest rate limit or quota. | Exporters retry. See Ingest rate limits. |
400 | The body could not be parsed, or the gzip stream is broken. | Look at what your exporter sends, for example with the Collector debug exporter. |
The trace arrived but something is missing
- No prompt or response. Some instrumentations capture message content only when you ask. For
Google ADK and the OpenTelemetry GenAI instrumentations, set
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true. See Before you go live. - No cost. Find your framework in the framework table. Some routes send no model-call span, so there is nothing to price.
- No outcome, or the run is missing from success rate. Set
morse.outcome.successas a real boolean. See Declaring outcomes. - Every run lands in one thread. A
morse.thread_idon the resource does that. Set it per conversation on a span instead.
Related
- OTel alongside another backend — keep your current tracing backend and send Morse a copy.
- Traces — where OTLP-ingested spans show up, alongside SDK-originated ones, and where a declared outcome appears.
- MCP endpoint — query that same trace data from an MCP client.