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

Source: https://openobserve.ai/opentelemetry/elixir/
Published: 2026-07-08
Language: Elixir

---

Elixir's OpenTelemetry story runs through the [OpenTelemetry Erlang SDK](https://github.com/open-telemetry/opentelemetry-erlang): 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](/blog/what-is-opentelemetry/) and come back here for the Elixir specifics. Everything below sends data to [OpenObserve](https://openobserve.ai/) 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:

| Signal  | Status | Notes |
|---------|--------|-------|
| Traces  | Stable | `opentelemetry_api` / `opentelemetry` / `opentelemetry_exporter` are stable 1.x; safe for production |
| Metrics | Development (experimental) | Lives in `opentelemetry_api_experimental` / `opentelemetry_experimental` 0.x; API works, but OTLP export is not in any hex release yet |
| Logs    | Development (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](https://cloud.openobserve.ai) account or a self-hosted instance. For local testing, a single container is enough:

```bash
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:

```bash
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](https://github.com/open-telemetry/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`:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

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

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

```elixir
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:

```elixir
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:**

```elixir
 # 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:**

```elixir
 # 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:

```bash
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:

```elixir
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](/blog/otel-exporters-introduction/).

## 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?":

```elixir
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:

  ```bash
  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.
