Send OpenTelemetry traces to Morse alongside your current backend
Your app already sends OpenTelemetry traces to a tracing backend. This page shows how to send the same traces to Morse as well. You keep your current backend and your current instrumentation. Morse reads the agent runs out of those traces and builds the performance brief from them.
When to use this page
Use it when all of these are true:
- Your app is already instrumented with OpenTelemetry, by your own code, a framework or an instrumentor such as OpenInference or OpenLLMetry.
- You want to keep sending those traces where they go today.
- You want Morse to price and judge your agent runs from the same spans.
If you are starting from nothing, the OTLP quickstart or the Morse SDK is shorter.
There are two ways to do it:
- A Collector that copies each span to both backends. This is the recommended route, and the only route for an app that exports over gRPC. See Collector fan-out.
- Two exporters inside your app. No Collector to run. See Two exporters in your app.
Either way, keep one instrumentor per library. See Do not add a second instrumentor.
Collector fan-out
An OpenTelemetry Collector receives your app’s spans once and sends a copy to each exporter in the pipeline.
This is also how a gRPC-only app reaches Morse. Morse accepts OTLP over HTTP only. The Collector takes gRPC in on port 4317 and sends HTTP out to Morse, so your app does not change.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
send_batch_size: 256
send_batch_max_size: 256
timeout: 5s
exporters:
# Your current backend. Replace this block with the one for your backend below.
otlphttp/existing:
endpoint: https://otlp.example.com
headers:
authorization: "Bearer ${env:EXISTING_BACKEND_KEY}"
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]
processors: [batch]
exporters: [otlphttp/existing, otlphttp/morse]What each part does:
otlpreceiver. Listens for gRPC on 4317 and HTTP on 4318, the standard OTLP ports. Point your app’s exporter at the Collector instead of at your backend.traces_endpoint, notendpoint, for Morse.traces_endpointis used exactly as written.endpointis a base URL that the exporter adds/v1/tracesto.- The
Authorizationheader. In Collector YAML you write the header value as is, with a plain space afterBearer. The%20encoding is only for theOTEL_EXPORTER_OTLP_*_HEADERSenvironment variables. Use an environment API key or a Full access personal key; a Read-only key is refused with403. compression: gzip. Morse accepts gzip on the traces endpoint. It is the exporter’s default; it is written out here so you can see it.send_batch_max_size: 256. See the next section.
Newer Collector releases (v0.144.0 and later) call this exporter otlp_http. The otlphttp name
used on this page still works there as a deprecated alias. On a new Collector you can write
otlp_http/morse instead.
Keep each batch under your plan’s size cap
Morse rejects a request larger than your plan’s cap with 413: 2 MB on Free and Starter, 5 MB on
Pro, 10 MB on Scale. The cap applies to the body after gzip decompression. Exporters do not
retry a 413, so the whole batch is lost. See Request size limits.
The batch processor counts spans, not bytes. A GenAI span that carries a full prompt and response
is often several kilobytes, so a large batch of them can pass the cap even when it is small on the
wire. 256 spans per batch is a safe starting point on a 2 MB plan. If you see 413 in the
Collector log, lower it to 128. On Pro or Scale you can raise it.
Set send_batch_size as well. The processor requires send_batch_max_size to be at least
send_batch_size, and send_batch_size defaults to 8192.
The batch processor in a shared pipeline also sizes what goes to your current backend. Smaller batches are harmless there. If you want different sizes, give Morse its own pipeline, as in Put Morse before any sampling.
Send traces only
Put Morse in the traces pipeline only. Do not add otlphttp/morse to a logs or metrics
pipeline. Morse reads agent runs from traces. Its logs and metrics endpoints accept OTLP JSON only,
and the Collector sends protobuf, so those requests fail with 415.
Put Morse before any sampling
The performance brief counts every run. If Morse receives a 10% sample, the brief is computed on that 10%: success rates, spend and cost per confirmed outcome all describe the sample, not your traffic.
So if you tail-sample for your current backend, give Morse its own pipeline that does not sample. One receiver can feed several pipelines:
processors:
batch/morse:
send_batch_size: 256
send_batch_max_size: 256
tail_sampling:
decision_wait: 30s
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-ten-percent
type: probabilistic
probabilistic:
sampling_percentage: 10
service:
pipelines:
traces/morse:
receivers: [otlp]
processors: [batch/morse]
exporters: [otlphttp/morse]
traces/existing:
receivers: [otlp]
processors: [tail_sampling, batch]
exporters: [otlphttp/existing]The receiver, the batch processor and the exporters are the ones from the first example. Head
sampling in your app (a
sampler on the TracerProvider) cannot be split this way: spans it drops never reach the
Collector. If you head-sample, Morse sees the same sample your current backend sees.
Optional: send Morse only your agent traces
If your service handles a lot of traffic that never runs an agent, you can keep that traffic out of Morse. Stock HTTP, database and messaging spans are never billed, but they do count toward infra span fair use.
Select whole traces, not single spans. Morse prices a run from its model spans, names the agent from the root span, and shows the HTTP and database calls inside the run. A filter that keeps only GenAI spans drops the root and the children, so the run arrives in pieces.
The tail_sampling processor can keep every trace that contains at least one GenAI span, with all
of its spans. It keeps every matching trace, so it is a selection, not a sample:
processors:
tail_sampling/agent-traces:
decision_wait: 60s
policies:
- name: has-genai-span
type: ottl_condition
ottl_condition:
error_mode: ignore
span:
- 'span.attributes["gen_ai.operation.name"] != nil or span.attributes["gen_ai.system"] != nil or span.attributes["openinference.span.kind"] != nil or span.attributes["traceloop.span.kind"] != nil'
service:
pipelines:
traces/morse:
receivers: [otlp]
processors: [tail_sampling/agent-traces, batch/morse]
exporters: [otlphttp/morse]The condition matches the three conventions Morse reads: OpenTelemetry GenAI, OpenInference and OpenLLMetry. Things to know before you use it:
- The processor holds each trace in memory for
decision_wait. Set it longer than your longest agent run, or the late spans of a long run are decided without the GenAI span. - Every span of a trace must reach the same Collector. If you run several Collectors behind a load
balancer, route by trace ID first (the
loadbalancingexporter does this). - Do not add probabilistic, rate-limit or latency policies to this pipeline. They turn the selection into a sample. See Put Morse before any sampling.
- If your Collector rejects the
span.prefix (older releases), write the paths without it:attributes["gen_ai.operation.name"] != nil.
The filter processor works span by span, so it is the wrong tool for “agent spans only”. Use it
only to drop traffic you know has no agent in it, such as a whole service:
processors:
filter/no-agents:
error_mode: ignore
trace_conditions:
- resource.attributes["service.name"] == "billing-cron"trace_conditions is the syntax from Collector-contrib v0.146.0. Older releases write the same
condition under traces: → span:.
Exporter blocks for common backends
Only the exporter block for your current backend changes. Add otlphttp/morse from the
first example next to it, in the same traces pipeline. Each block below comes
from that vendor’s own documentation, which wins if it disagrees with this page.
Langfuse
exporters:
otlphttp/langfuse:
endpoint: https://cloud.langfuse.com/api/public/otel # EU. US: https://us.cloud.langfuse.com/api/public/otel
headers:
Authorization: "Basic ${env:LANGFUSE_AUTH_STRING}" # base64 of public_key:secret_key
x-langfuse-ingestion-version: "4"Langfuse accepts OTLP over HTTP only, like Morse. Source: Langfuse OpenTelemetry docs .
Arize Phoenix
exporters:
otlphttp/phoenix:
traces_endpoint: http://phoenix:6006/v1/traces # your Phoenix host
headers:
authorization: "Bearer ${env:PHOENIX_API_KEY}" # only when authentication is onFor Phoenix Cloud, use the collector endpoint and API key from your Phoenix Settings page. Sources: Phoenix configuration , Phoenix authentication .
Pydantic Logfire
exporters:
otlphttp/logfire:
endpoint: https://logfire-us.pydantic.dev # EU: https://logfire-eu.pydantic.dev
headers:
Authorization: "${env:LOGFIRE_WRITE_TOKEN}"The header value is the write token itself, with no Bearer prefix. Source:
Logfire alternative clients .
Honeycomb
exporters:
otlphttp/honeycomb:
endpoint: https://api.honeycomb.io:443 # EU: https://api.eu1.honeycomb.io:443
headers:
x-honeycomb-team: "${env:HONEYCOMB_API_KEY}"Honeycomb Classic also needs an x-honeycomb-dataset header. Source:
Honeycomb Collector docs .
Datadog
exporters:
otlphttp/datadog:
endpoint: https://otlp.${env:DD_SITE}
headers:
dd-api-key: ${env:DD_API_KEY}Datadog’s guide adds connectors to this pipeline for trace metrics. Keep them as Datadog describes,
and add otlphttp/morse to the traces pipeline that receives the spans before any sampling. If you
use the datadog exporter or the Datadog Agent instead, leave that setup unchanged and add
otlphttp/morse next to it. Source:
Datadog Collector setup .
Grafana Cloud
extensions:
basicauth/grafana_cloud:
client_auth:
username: "${env:GRAFANA_CLOUD_INSTANCE_ID}"
password: "${env:GRAFANA_CLOUD_API_KEY}"
exporters:
otlphttp/grafana_cloud:
endpoint: "${env:GRAFANA_CLOUD_OTLP_ENDPOINT}"
auth:
authenticator: basicauth/grafana_cloud
service:
extensions: [basicauth/grafana_cloud]The instance ID and OTLP endpoint are on your stack’s OpenTelemetry tile. The basicauth
extension ships in the Collector-contrib distribution. If you run Grafana Alloy instead, add an
OTLP/HTTP exporter for Morse in Alloy’s own config. Source:
Grafana OpenTelemetry Collector docs .
Two exporters in your app
Without a Collector, give your tracer provider two span processors: one for your current backend and one for Morse. Each span is exported to both.
Pass the endpoint and headers in code for both exporters. An exporter created with no
arguments reads the OTEL_EXPORTER_OTLP_* environment variables, so two of them would both send
to the same place.
Python
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-httpimport os
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http import Compression
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider()
# Your current backend: keep the endpoint and headers you use today.
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint="https://otlp.example.com/v1/traces",
headers={"authorization": f"Bearer {os.environ['EXISTING_BACKEND_KEY']}"},
)
)
)
# Morse
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint="https://api.morsehq.dev/api/v1/otlp/v1/traces",
headers={"Authorization": f"Bearer {os.environ['MORSE_API_KEY']}"},
compression=Compression.Gzip,
),
max_export_batch_size=256,
)
)
trace.set_tracer_provider(provider)In code the header value has a plain space; %20 is only for environment variables.
max_export_batch_size=256 keeps each request under the size cap, as in
the Collector section.
If your current backend’s SDK creates the tracer provider for you, add Morse to that provider instead of creating a second one:
- Logfire:
logfire.configure(additional_span_processors=[...]), passing the MorseBatchSpanProcessorfrom above. - Phoenix:
register()returns the tracer provider it set up. Calladd_span_processor()on it with the MorseBatchSpanProcessor.
Node.js
npm install @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/otlp-exporter-baseimport { NodeSDK } from "@opentelemetry/sdk-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { CompressionAlgorithm } from "@opentelemetry/otlp-exporter-base";
const sdk = new NodeSDK({
serviceName: "support-agent",
spanProcessors: [
// Your current backend: keep the endpoint and headers you use today.
new BatchSpanProcessor(
new OTLPTraceExporter({
url: "https://otlp.example.com/v1/traces",
headers: { authorization: `Bearer ${process.env.EXISTING_BACKEND_KEY}` },
}),
),
// Morse
new BatchSpanProcessor(
new OTLPTraceExporter({
url: "https://api.morsehq.dev/api/v1/otlp/v1/traces",
headers: { Authorization: `Bearer ${process.env.MORSE_API_KEY}` },
compression: CompressionAlgorithm.GZIP,
}),
{ maxExportBatchSize: 256 },
),
],
});
sdk.start();Load this file before your app imports the framework, for example
node --import ./instrumentation.mjs app.mjs.
Do not add a second instrumentor
Add Morse as a second exporter. Never add a second instrumentor for it.
If two instrumentors wrap the same model call (say your backend’s OpenAI integration and an OpenInference OpenAI instrumentor), every call produces two model spans. Morse prices both, so tokens and spend are counted twice. Morse does not de-duplicate them.
So keep the instrumentation you have. If your backend’s SDK already instruments a library (for
example logfire.instrument_openai(), or Phoenix’s register(auto_instrument=True)), do not add
another instrumentor for that library. Both exporters receive the spans it already makes.
Check it worked
- Run your agent once.
- Open Traces in Morse. The run should be there, with a model, token counts and a cost on its model spans.
- Check your current backend. The same run should still be there, unchanged.
- Declare an outcome. Set
morse.outcome.success(a boolean) on any span of the run, then run it again. The trace now shows the outcome. Without it, Morse can price the run but not judge it: the trace is left out of success rate and cost per confirmed outcome, so the performance brief has nothing to rank. See Declaring outcomes, and Where to set the outcome when a framework owns the spans.
If the run is in your current backend but not in Morse, work through Nothing showed up?. If it is in Morse without a cost, find your framework in the framework table: some routes do not carry spend.
Related
- OTLP — the endpoint, auth, how spans are typed, and outcomes.
- Frameworks — which framework routes are verdict-ready and what each one is missing.
- Limits — request size caps and ingest rate limits.