Skip to Content
IntegrationsOTLP

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.

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

  2. 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 TRACES variables, not the generic OTEL_EXPORTER_OTLP_ENDPOINT, and keep the %20. Endpoint explains both.

  3. Declare an outcome. Set morse.outcome.success to 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.

  4. 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/traces

api.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 with 415.
  • Base set to the full traces URL. The SDK adds /v1/traces again, so spans go to /api/v1/otlp/v1/traces/v1/traces and get 404.

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

Normalization

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:

  1. An explicit morse.span_type attribute.
  2. gen_ai.operation.name: invoke_agent and create_agent become agent, execute_tool becomes tool, and chat, generate_content and text_completion become llm.
  3. Any other gen_ai.* attribute: llm.
  4. db.system or db.system.name: db.
  5. messaging.system: queue.
  6. http.method, http.request.method, url.full or rpc.system: http.
  7. 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.

  • Exception events. An exception event (what record_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.message and gen_ai.content.prompt become the span’s input. gen_ai.choice and gen_ai.content.completion become its output. The gen_ai.client.inference.operation.details event 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:

AttributeTypeMeaning
morse.outcome.successboolDid this run achieve its goal? Required to declare an outcome.
morse.outcome.labelstringOptional 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 = true early is overridden by a root span that declares success = false after 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 only morse.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 carries gen_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.

ConventionKindMorse type
OpenTelemetry GenAIgen_ai.operation.name = invoke_agent, create_agentagent
OpenTelemetry GenAIgen_ai.operation.name = execute_tooltool
OpenTelemetry GenAIgen_ai.operation.name = chat, generate_content, text_completionllm
OpenTelemetry GenAIany other gen_ai.* spanllm
OpenInferenceLLM, EMBEDDINGllm
OpenInferenceAGENTagent
OpenInferenceTOOLtool
OpenInferenceRETRIEVER, RERANKERretriever
OpenInferenceGUARDRAILguardrail
OpenInferenceCHAIN, EVALUATOR, PROMPT, UNKNOWNcustom
OpenLLMetryagentagent
OpenLLMetrytooltool
OpenLLMetryworkflow, taskcustom

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_usage for 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 attributeEffect
morse.agent_versionThe agent version for every trace from the process.
morse.thread_idOne thread id for every trace from the process. See the note below.
morse.user_idThe end user the traces belong to.
morse.record_key. + a typeA 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 here

How to use it

  • Point your existing OTel SDK’s OTLP/HTTP exporter at https://api.morsehq.dev/api/v1/otlp/v1/traces with 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.name so agent and tool spans are typed agent and tool. A gen_ai.* span with no operation name is typed llm.
  • Set morse.outcome.success on 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 opentelemetry loggers. 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 debug exporter (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, or await sdk.shutdown() in Node.js, before the process exits.

What each status code means

StatusCauseFix
401The 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>.
403A Read-only personal key, which cannot send data.Use an environment key or a Full access personal key.
404Wrong 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.
415A 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.
413The 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.
429An ingest rate limit or quota.Exporters retry. See Ingest rate limits.
400The 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.success as a real boolean. See Declaring outcomes.
  • Every run lands in one thread. A morse.thread_id on the resource does that. Set it per conversation on a span instead.
  • 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.
Last updated on