Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for Elixir: Tracing with the Erlang SDK & OpenObserve

Instrument Elixir apps with the OpenTelemetry Erlang SDK — stable traces over OTLP to OpenObserve, honest metrics/logs status, mix setup, and troubleshooting.

OpenTelemetry guide

Elixir’s OpenTelemetry story runs through the OpenTelemetry Erlang SDK: one implementation serves both languages, published on hex.pm with first-class Elixir modules and macros. Tracing is stable and production-proven — Phoenix apps, Broadway pipelines, and plain OTP applications trace with it every day. Metrics and logs, however, are still in development for this SDK, and this guide is precise about that line instead of pretending otherwise.

If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide and come back here for the Elixir specifics. Everything below sends data to OpenObserve over OTLP, self-hosted or Cloud.

Why OpenTelemetry for Elixir

Elixir systems are concurrent by construction: a single request may fan out across dozens of processes, and the interesting failures live in the gaps between them. OpenTelemetry gives you:

  • Vendor-neutral instrumentation. Instrument once with opentelemetry_api, export anywhere OTLP is accepted — no proprietary SDK in your supervision tree.
  • A design that fits OTP. The SDK is itself an OTP application. It starts with your release, supervises its own processes, and is configured through the application environment like everything else you ship.
  • A :telemetry-native ecosystem. The Elixir community standardized on :telemetry events years ago; OpenTelemetry instrumentation libraries subscribe to those events, so Phoenix, Ecto, and friends light up without touching your business code.

Signal stability in Elixir

The Erlang/Elixir implementation has the widest stability spread of any major SDK, so be precise about what “supported” means. As of mid-2026:

SignalStatusNotes
TracesStableopentelemetry_api / opentelemetry / opentelemetry_exporter are stable 1.x; safe for production
MetricsDevelopment (experimental)Lives in opentelemetry_api_experimental / opentelemetry_experimental 0.x; API works, but OTLP export is not in any hex release yet
LogsDevelopment (experimental)An experimental logger handler exists in the repo; no stable OTLP log pipeline shipped on hex

The experimental packages are versioned 0.x on purpose: the maintainers document that they will never be stable and that breaking changes land in minor releases. This guide shows what exists for metrics and logs and says plainly where the released packages stop.

Prerequisites

  • Erlang/OTP 23 or later and Elixir 1.13 or later. Anything remotely current (OTP 26/27, Elixir 1.16+) is fine.
  • An OpenObserve instance. Either an OpenObserve Cloud account or a self-hosted instance. For local testing, a single container is enough:
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
  • Your OpenObserve credentials. OTLP ingestion uses HTTP Basic auth. Generate the token once:
echo -n 'root@example.com:Complexpass#123' | base64

This prints cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= — the value you will pass in the Authorization: Basic header.

In OpenObserve Cloud, the ingestion page under Data Sources shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.

Zero-code instrumentation: the short answer for Elixir

There is no runtime agent for the BEAM — nothing you attach to a running release the way the Java agent attaches to a JVM. The Elixir model sits between “agent magic” and “wire everything by hand”:

  1. The SDK is an OTP application. Add opentelemetry to your dependencies and it boots with your release — no main()-style init code. Configuration lives in config/runtime.exs.
  2. Instrumentation libraries ride on :telemetry. Your frameworks already emit telemetry events for every request and query; OpenTelemetry libraries subscribe to them and translate events into spans. One setup() call at application start, and traffic is traced.

Two libraries cover the most common stack, each worth exactly one line here: opentelemetry_phoenix creates a span for every Phoenix request (pair it with opentelemetry_bandit or opentelemetry_cowboy for your HTTP server), and opentelemetry_ecto turns every Ecto query into a child span with database attributes. Dozens more live in the opentelemetry-erlang-contrib repository — Oban, Tesla, Finch, Redix, Broadway.

The rest of this guide focuses on the core SDK: installing it, creating spans yourself, and pointing OTLP at OpenObserve. That knowledge applies unchanged whether the spans come from your code or from a contrib library.

Manual instrumentation

Add the three core packages to mix.exs:

defp deps do
  [
    # opentelemetry_exporter must be listed before other opentelemetry
    # dependencies, per the official exporters documentation.
    {:opentelemetry_exporter, "~> 1.10"},
    {:opentelemetry, "~> 1.7"},
    {:opentelemetry_api, "~> 1.5"}
  ]
end

opentelemetry_api holds the interfaces you call from application code (safe for library authors to depend on alone), opentelemetry is the SDK that implements them, and opentelemetry_exporter speaks OTLP over HTTP/protobuf or gRPC.

If you ship Mix releases, also mark the SDK apps in mix.exs so the exporter starts before the SDK and an SDK crash can never take your node down:

def project do
  [
    app: :checkout_service,
    releases: [
      checkout_service: [
        applications: [
          opentelemetry_exporter: :permanent,
          opentelemetry: :temporary
        ]
      ]
    ]
  ]
end

:temporary is deliberate: telemetry should degrade, not cascade. If the OpenTelemetry supervision tree dies, your application keeps serving traffic.

Traces

Tracing in Elixir is macro-based. require OpenTelemetry.Tracer once per module, then wrap units of work in with_span — the span starts when the block enters, becomes the current span for everything the block calls, and ends when the block exits:

defmodule Checkout.Payments do
  require OpenTelemetry.Tracer, as: Tracer

  def process(order) do
    Tracer.with_span "process-payment", %{
      attributes: [{"payment.provider", "stripe"}, {"cart.items", length(order.items)}]
    } do
      Tracer.add_event("payment.authorized", [{"amount_cents", order.total_cents}])

      case charge(order) do
        {:ok, receipt} ->
          Tracer.set_attribute("payment.receipt_id", receipt.id)
          {:ok, receipt}

        {:error, reason} = error ->
          Tracer.set_status(:error, inspect(reason))
          error
      end
    end
  end

  defp charge(order) do
    Tracer.with_span "stripe.charge" do
      # nested spans parent automatically to the enclosing with_span
      Process.sleep(40)
      {:ok, %{id: "re_#{order.id}"}}
    end
  end
end

Nesting is automatic within a process: the inner stripe.charge span parents to process-payment because context is tracked per process. For exceptions, record them explicitly so the failure is visible and filterable in the trace UI:

try do
  risky_operation()
rescue
  e ->
    Tracer.record_exception(e, __STACKTRACE__)
    Tracer.set_status(:error, Exception.message(e))
    reraise e, __STACKTRACE__
end

Crossing process boundaries is the one thing the SDK cannot do for you. Context lives in the process dictionary, so a Task, GenServer call, or send starts from a blank slate. Capture and attach the context explicitly:

require OpenTelemetry.Tracer, as: Tracer

def fan_out(items) do
  ctx = OpenTelemetry.Ctx.get_current()

  items
  |> Enum.map(fn item ->
    Task.async(fn ->
      OpenTelemetry.Ctx.attach(ctx)

      Tracer.with_span "process-item", %{attributes: [{"item.id", item.id}]} do
        do_work(item)
      end
    end)
  end)
  |> Task.await_many()
end

For work where a parent-child relationship is wrong (a job consumed later from a queue, for instance), create a span link instead: capture Tracer.current_span_ctx() in the producer and pass %{links: [OpenTelemetry.link(parent_ctx)]} as start options in the consumer. And rather than hand-writing the attach dance everywhere, the opentelemetry_process_propagator package provides Task/Task.Supervisor wrappers that propagate context automatically.

Metrics (experimental)

The metrics API and SDK exist but are explicitly experimental. They ship as separate 0.x packages:

{:opentelemetry_api_experimental, "~> 0.5"},
{:opentelemetry_experimental, "~> 0.5"}

The Elixir-facing API is macro modules under OpenTelemetryAPIExperimentalCounter, Histogram, UpDownCounter, and observable variants:

defmodule Checkout.Metrics do
  require OpenTelemetryAPIExperimental.Counter, as: Counter
  require OpenTelemetryAPIExperimental.Histogram, as: Histogram

  def setup do
    Counter.create(:"orders.processed", %{unit: "{order}", description: "Orders processed"})
    Histogram.create(:"payment.duration", %{unit: "ms", description: "Payment duration"})
  end

  def record_order(provider, duration_ms) do
    Counter.add(:"orders.processed", 1, %{"payment.provider" => provider})
    Histogram.record(:"payment.duration", duration_ms, %{})
  end
end

Now the honest part. Three caveats before you build on this:

  • The packages are 0.x by design. The maintainers state the experimental apps will never be stable; breaking changes arrive in minor versions. Pin exact versions.
  • OTLP export of metrics does not work from the released hex packages. opentelemetry_exporter 1.10.0 delegates metric export to a module (otel_exporter_metrics_otlp) that exists only on the repository’s main branch — it is in neither opentelemetry_exporter nor opentelemetry_experimental 0.5.1 on hex. To push OTLP metrics today you must track opentelemetry-erlang main via git dependencies, with everything that implies.
  • The hex releases lag the repo significantly (0.5.1 dates to early 2024), so blog posts showing metrics working are usually running git checkouts.

The pragmatic production setup in 2026: emit metrics through the :telemetry ecosystem you already have — for example PromEx or telemetry_metrics exposing a Prometheus endpoint — and let an OpenTelemetry Collector scrape and forward them to OpenObserve over OTLP. You keep standard metrics semantics in OpenObserve without betting production on an unreleased exporter, and you can swap in the SDK metrics pipeline when it stabilizes.

Logs (experimental)

Logs for Erlang/Elixir are likewise in development. The repository contains an experimental logger handler (otel_log_handler) and OTLP log encoding, but — same story as metrics — the OTLP logs exporter has not shipped in a hex release, and the upstream documentation for the logs API is still marked TBA.

What works well today is the classic two-step: emit structured JSON logs to stdout, ship them with an OpenTelemetry Collector (or FluentBit, or OpenObserve’s own agent) to OpenObserve, and inject trace context into Logger metadata so logs and traces correlate. The injection is a few lines:

defmodule Checkout.TraceLogger do
  @doc "Call after entering a span (e.g., in a Plug) to correlate logs with the active trace."
  def attach_trace_metadata do
    case OpenTelemetry.Tracer.current_span_ctx() do
      :undefined ->
        :ok

      span_ctx ->
        Logger.metadata(
          trace_id: OpenTelemetry.Span.hex_trace_id(span_ctx),
          span_id: OpenTelemetry.Span.hex_span_id(span_ctx)
        )
    end
  end
end

With a JSON log formatter that includes metadata (Elixir 1.15+ can format :logger output as JSON via handler config, or use a library like logger_json), every log line now carries trace_id and span_id. In OpenObserve you can jump from a slow span straight to the log lines it produced. When the SDK’s log signal stabilizes, it will replace the shipping half of this setup — the correlation metadata stays useful either way.

Sending data to OpenObserve

OpenObserve ingests OTLP natively — no collector required in between, although running one is a fine architecture too. Two transports are supported:

  • OTLP/HTTP at <host>/api/<org>/v1/traces (port 5080 self-hosted). This is the exporter’s default protocol (:http_protobuf).
  • OTLP/gRPC on port 5081 (self-hosted), which additionally requires an organization header alongside Authorization.

Stick with HTTP unless you have a reason not to. The idiomatic place to configure the exporter is config/runtime.exs, so the same release runs against dev, staging, and production backends:

Self-hosted OpenObserve:

 # config/runtime.exs
config :opentelemetry,
  resource: %{service: %{name: "checkout-service", version: "1.4.2"}},
  span_processor: :batch,
  traces_exporter: :otlp

config :opentelemetry_exporter,
  otlp_protocol: :http_protobuf,
  otlp_endpoint: "http://localhost:5080/api/default",
  otlp_headers: [
    {"Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="}
  ]

OpenObserve Cloud:

 # config/runtime.exs
config :opentelemetry_exporter,
  otlp_protocol: :http_protobuf,
  otlp_endpoint: "https://api.openobserve.ai/api/#{System.fetch_env!("O2_ORG")}",
  otlp_headers: [
    {"Authorization", "Basic " <> System.fetch_env!("O2_TOKEN")}
  ]

Note the header value uses a normal space"Basic <token>" — because these are literal HTTP header values.

The exporter also honors the standard OTLP environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_COMPRESSION), and the SDK reads OTEL_RESOURCE_ATTRIBUTES. Note: the Erlang/Elixir SDK does not support OTEL_SERVICE_NAME — there is no handler for it, so setting it has no effect. To set the service name via environment, use OTEL_RESOURCE_ATTRIBUTES with a service.name entry. Environment values override the application config:

export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_RESOURCE_ATTRIBUTES="service.name=checkout-service,service.version=1.4.2,deployment.environment=production"

One deviation from other SDKs, and it matters: most OpenTelemetry SDKs require the space in the header value to be percent-encoded (Basic%20<token>), and our other language guides show exactly that. The Erlang exporter does not percent-decode header values from OTEL_EXPORTER_OTLP_HEADERS — it splits on , and = and passes values through verbatim. Write a literal space (Authorization=Basic <token>, quoted so your shell keeps it intact); the %20 form will reach OpenObserve literally and fail auth with a 401.

Three more rules that prevent most setup failures:

  1. No trailing slash on the endpoint, and give the base URL only. otlp_endpoint / OTEL_EXPORTER_OTLP_ENDPOINT get v1/traces appended by the exporter, so the final URL is http://localhost:5080/api/default/v1/traces.
  2. otlp_traces_endpoint is used verbatim. If you configure the traces-specific endpoint instead, you must include the full path yourself (.../api/default/v1/traces) — no suffix is appended. Mixing these two behaviors up is the most common 404.
  3. The organization is part of the URL path. /api/default targets the default organization; replace it with your org name in OpenObserve Cloud (the Data Sources page shows the exact endpoint and token to copy).

If you prefer gRPC, point at port 5081 and add the organization header:

config :opentelemetry_exporter,
  otlp_protocol: :grpc,
  otlp_endpoint: "http://localhost:5081",
  otlp_headers: [
    {"Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="},
    {"organization", "default"}
  ]

For a broader tour of exporter configuration, see A Beginner’s Guide to OTLP Exporters.

Verifying data arrival in OpenObserve

Start your app (iex -S mix or the release), exercise a code path that creates spans, and give the batch processor a few seconds to export. Then open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL):

  1. Streams — a default traces stream appears once the first batch lands. If the stream exists, ingestion is working end to end.
  2. Traces — pick your service (checkout-service) on the Traces page and open a trace. You should see process-payment with stripe.charge nested under it, carrying the attributes and events you set.
  3. Logs — if you wired the stdout-plus-collector pipeline, query the log stream and confirm records carry trace_id/span_id fields matching your traces.

For a quick sanity check without waiting on batching, switch the exporter to stdout in config/dev.exs — spans print to your terminal, which isolates “am I creating spans?” from “am I exporting them?”:

config :opentelemetry, traces_exporter: {:otel_exporter_stdout, []}

If nothing appears within ~30 seconds, jump to Troubleshooting below.

Production tips

  • Keep the batch processor. span_processor: :batch is the default and the right choice; it exports off the hot path every 5 seconds. Tune with OTEL_BSP_SCHEDULE_DELAY_MILLIS, OTEL_BSP_EXPORT_TIMEOUT_MILLIS, and OTEL_BSP_MAX_QUEUE_SIZE only if you observe drops.

  • Sample at the head, keep parents authoritative. The SDK supports the standard sampler variables:

    export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
    export OTEL_TRACES_SAMPLER_ARG="0.1"

    Or in config: config :opentelemetry, sampler: {:parent_based, %{root: {:trace_id_ratio_based, 0.1}}}. The parent_based wrapper makes downstream services honor upstream sampling decisions, so you get whole traces instead of fragments.

  • Invest in resource attributes. service.name, service.version, and deployment.environment are the dimensions you will filter by in every OpenObserve query. Set them in the :resource config map or via OTEL_RESOURCE_ATTRIBUTES; without a service name, spans arrive with no service.name attribute (backends may display them as unknown or ungrouped).

  • Flush before short-lived processes exit. Mix tasks, release migrations, and scripts can finish inside the 5-second batch window and silently drop their spans. Call :otel_tracer_provider.force_flush() before exiting, or shorten the schedule delay for those workloads.

  • Treat process boundaries as instrumentation boundaries. Every Task.async, GenServer.cast, and Broadway/Oban hop needs explicit context propagation (or opentelemetry_process_propagator). Audit the places your request path changes process; each one is a potential broken trace.

  • Ship the release config, not compile-time config. Endpoint and credentials belong in config/runtime.exs, evaluated at boot, so operations can repoint a release with environment variables and a restart — no rebuild.

  • Pin the API, SDK, and exporter together, and the experimental packages exactly. The three stable packages move in coordinated releases; upgrade them in one commit. For anything experimental, use == version pins — 0.x minor bumps are allowed to break you.

Troubleshooting

Symptom: exporter requests return 404 Not Found and no data arrives. Fix: a URL problem. With otlp_endpoint, supply only the base URL (http://localhost:5080/api/default) — the exporter appends v1/traces. With otlp_traces_endpoint, supply the full path including /v1/traces, because it is used as-is. Confirm the org segment matches your OpenObserve organization.

Symptom: 401 Unauthorized on every export. Fix: regenerate the token with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token). If you set the header via OTEL_EXPORTER_OTLP_HEADERS, use a literal space in Authorization=Basic <token> — the Erlang exporter does not decode %20, unlike most other SDKs. In otlp_headers config, it is always a literal space.

Symptom: no spans, no errors — silence. Fix: check that the SDK actually started and kept its exporter. Common causes: a config/dev.exs left pointing traces_exporter at :none or stdout; a Mix release that omits opentelemetry_exporter from applications so the exporter app never starts; or OTEL_SDK_DISABLED=true inherited from the environment. Boot logs from the opentelemetry application will report exporter init failures — read them before anything else.

Symptom: spans appear, but each process’s spans form separate one-span traces. Fix: broken context propagation across processes. Attach the parent context (OpenTelemetry.Ctx.get_current/0 + Ctx.attach/1) inside every spawned process before starting spans, or adopt opentelemetry_process_propagator. For cross-service traces, ensure your HTTP client instrumentation injects traceparent and Phoenix/Plug instrumentation extracts it on the way in.

Symptom: :undef errors mentioning otel_exporter_metrics_otlp or metrics silently never export. Fix: you have hit the released-package gap — the hex releases of the exporter delegate metrics export to a module that only exists on the repository’s main branch. Either track opentelemetry-erlang main as git dependencies (accepting instability) or export metrics through a Prometheus endpoint scraped by an OpenTelemetry Collector that forwards to OpenObserve.

Symptom: a Mix task or migration runs cleanly but its spans never arrive. Fix: the VM exited inside the batch window. Call :otel_tracer_provider.force_flush() at the end of the task, or set OTEL_BSP_SCHEDULE_DELAY_MILLIS=500 for short-lived workloads so batches leave quickly.

Wire the three hex packages into your release, configure OTLP in runtime.exs, and an Elixir system gets stable, production-grade distributed tracing into OpenObserve — with a clear-eyed path for metrics and logs as the Erlang SDK’s remaining signals mature.

Frequently asked questions

Does Elixir have automatic (zero-code) OpenTelemetry instrumentation like Java?

No runtime agent exists for the BEAM. Instead, the SDK boots as an OTP application inside your release, and instrumentation libraries such as opentelemetry_phoenix and opentelemetry_ecto attach to the telemetry events your frameworks already emit. Wiring them up is a one-time setup call in application.ex, after which HTTP requests and database queries produce spans automatically — close to zero-code in practice, but you opt in explicitly.

Which OpenTelemetry signals are stable in the Erlang/Elixir SDK?

Only traces. The tracing API and SDK (opentelemetry_api, opentelemetry, opentelemetry_exporter) are stable 1.x releases and safe for production. Metrics and logs are in development: they live in the 0.x opentelemetry_api_experimental and opentelemetry_experimental packages, which can introduce breaking changes in any release, and the OTLP exporters for those signals have not shipped in the hex releases yet. Plan on traces from the SDK today and use telemetry-based tooling or a collector pipeline for metrics and logs.

What endpoint do I use to send OTLP data from Elixir to OpenObserve?

Set otlp_endpoint (or OTEL_EXPORTER_OTLP_ENDPOINT) to your OpenObserve base URL including the organization path, with no trailing slash. For OpenObserve Cloud that is https://api.openobserve.ai/api/your-org-name, and for a self-hosted instance it is http://localhost:5080/api/default. The Erlang exporter appends v1/traces to that path itself. If you instead use otlp_traces_endpoint, you must supply the full path ending in /v1/traces, because that setting is used verbatim.

How do I authenticate Elixir OTLP exports to OpenObserve?

OpenObserve uses HTTP Basic authentication. Base64-encode email:password (or an ingestion token), then send it as an Authorization header. In config/runtime.exs, set otlp_headers to a list like [{"Authorization", "Basic <token>"}] with a normal space. If you use the OTEL_EXPORTER_OTLP_HEADERS environment variable, note that the Erlang exporter does not percent-decode values the way most other SDKs do, so write the value with a literal space — Authorization=Basic <token>, quoted for your shell — rather than the Basic%20 form used with other languages.

Why do spans started inside a Task or GenServer not connect to my trace?

OpenTelemetry context on the BEAM is stored per process, and spawning a Task or messaging a GenServer crosses a process boundary, so the new process starts with empty context. Capture the context in the parent with OpenTelemetry.Ctx.get_current, then call OpenTelemetry.Ctx.attach inside the spawned process before starting spans. The opentelemetry_process_propagator package provides Task wrappers that do this for you.

Can I send metrics from Elixir over OTLP today?

Not with the packages published on hex.pm. The experimental metrics SDK (opentelemetry_experimental 0.5.x) implements counters, histograms, and observable instruments, but the OTLP metrics exporter module only exists on the main branch of the opentelemetry-erlang repository and has not been released. Practical options today are tracking main as a git dependency and accepting breakage, or exporting metrics through the telemetry ecosystem (for example PromEx exposing Prometheus metrics that an OpenTelemetry Collector scrapes and forwards to OpenObserve).

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo