Skip to Content
IntegrationsOTel alongside another backend

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:

  • otlp receiver. 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, not endpoint, for Morse. traces_endpoint is used exactly as written. endpoint is a base URL that the exporter adds /v1/traces to.
  • The Authorization header. In Collector YAML you write the header value as is, with a plain space after Bearer. The %20 encoding is only for the OTEL_EXPORTER_OTLP_*_HEADERS environment variables. Use an environment API key or a Full access personal key; a Read-only key is refused with 403.
  • 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 loadbalancing exporter 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 on

For 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-http
import 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 Morse BatchSpanProcessor from above.
  • Phoenix: register() returns the tracer provider it set up. Call add_span_processor() on it with the Morse BatchSpanProcessor.

Node.js

npm install @opentelemetry/sdk-node @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-proto @opentelemetry/otlp-exporter-base
import { 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

  1. Run your agent once.
  2. Open Traces in Morse. The run should be there, with a model, token counts and a cost on its model spans.
  3. Check your current backend. The same run should still be there, unchanged.
  4. 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.

  • 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.
Last updated on