OpenTelemetry Logs for Erlang/Elixir

This document covers OpenTelemetry Logs for Erlang/Elixir, focusing on integrating with the Elixir Logger and Erlang logger modules. To learn how to install and configure OpenTelemetry Erlang/Elixir SDK, see Getting started with OpenTelemetry Erlang/Elixir.

Prerequisites

_otel-prereq-erlang not found...

If you are not familiar with logs terminology like structured logging or log-trace correlation, read the introduction to OpenTelemetry Logs first.

Overview

OpenTelemetry provides log integration for Erlang/Elixir through handler modules that bridge your existing logging infrastructure with OpenTelemetry:

  1. Elixir Logger backend: Integrates with Elixir's built-in Logger module
  2. Erlang logger handler: Integrates with Erlang's logger module (OTP 21+)

These integrations automatically capture logs and correlate them with traces when a span is active.

Elixir Logger integration

The OpenTelemetry Elixir SDK provides integration with Elixir's Logger module through the opentelemetry_logger_metadata package.

Installation

Add the dependency to your mix.exs:

elixir
defp deps do
  [
    {:opentelemetry, "~> 1.3"},
    {:opentelemetry_api, "~> 1.2"},
    {:opentelemetry_exporter, "~> 1.6"}
  ]
end

Basic configuration

Configure Logger to include OpenTelemetry metadata:

elixir
# config/config.exs
config :logger, :console,
  format: "$time $metadata[$level] $message\n",
  metadata: [:trace_id, :span_id, :trace_flags]

Adding trace context to logs

Use OpenTelemetry.Ctx to add trace context to your Logger metadata:

elixir
defmodule MyApp.TracedLogger do
  require Logger
  require OpenTelemetry.Tracer

  def log_with_trace(level, message, metadata \\ []) do
    # Get current span context
    span_ctx = OpenTelemetry.Tracer.current_span_ctx()

    trace_metadata = if OpenTelemetry.Span.is_valid(span_ctx) do
      [
        trace_id: OpenTelemetry.Span.trace_id(span_ctx) |> format_id(),
        span_id: OpenTelemetry.Span.span_id(span_ctx) |> format_id(),
        trace_flags: OpenTelemetry.Span.trace_flags(span_ctx)
      ]
    else
      []
    end

    Logger.log(level, message, Keyword.merge(metadata, trace_metadata))
  end

  defp format_id(id) when is_integer(id) do
    Integer.to_string(id, 16) |> String.downcase()
  end
  defp format_id(id), do: id
end

Complete example

elixir
defmodule MyApp.OrderProcessor do
  require Logger
  require OpenTelemetry.Tracer

  def process_order(order_id) do
    OpenTelemetry.Tracer.with_span "process_order" do
      span_ctx = OpenTelemetry.Tracer.current_span_ctx()
      trace_id = OpenTelemetry.Span.trace_id(span_ctx)

      # Log with trace context
      Logger.metadata(trace_id: trace_id)

      Logger.info("Starting order processing", order_id: order_id)

      case validate_order(order_id) do
        :ok ->
          Logger.info("Order validated successfully", order_id: order_id)
          complete_order(order_id)

        {:error, reason} ->
          Logger.error("Order validation failed",
            order_id: order_id,
            reason: reason
          )
          OpenTelemetry.Tracer.set_status(:error, "Validation failed: #{reason}")
          {:error, reason}
      end
    end
  end

  defp validate_order(_order_id), do: :ok

  defp complete_order(order_id) do
    OpenTelemetry.Tracer.with_span "complete_order" do
      Logger.info("Completing order", order_id: order_id)
      # Order completion logic
      {:ok, order_id}
    end
  end
end

Erlang logger integration

For Erlang applications using OTP 21+ logger, you can create a custom handler to add trace context:

Custom logger handler

erlang
-module(otel_logger_handler).
-behaviour(logger_handler).

-export([log/2, adding_handler/1, removing_handler/1]).

adding_handler(Config) ->
    {ok, Config}.

removing_handler(_Config) ->
    ok.

log(#{msg := Msg, level := Level, meta := Meta} = LogEvent, Config) ->
    %% Get current span context
    SpanCtx = otel_tracer:current_span_ctx(),

    %% Add trace context if valid span exists
    EnrichedMeta = case otel_span:is_valid(SpanCtx) of
        true ->
            TraceId = otel_span:trace_id(SpanCtx),
            SpanId = otel_span:span_id(SpanCtx),
            TraceFlags = otel_span:trace_flags(SpanCtx),
            Meta#{
                trace_id => format_id(TraceId),
                span_id => format_id(SpanId),
                trace_flags => TraceFlags
            };
        false ->
            Meta
    end,

    %% Forward to default handler or your preferred output
    logger_std_h:log(LogEvent#{meta => EnrichedMeta}, Config).

format_id(Id) when is_integer(Id) ->
    string:lowercase(integer_to_list(Id, 16));
format_id(Id) ->
    Id.

Configuration

Add the handler in your sys.config:

erlang
[
 {kernel, [
   {logger, [
     {handler, otel_handler, otel_logger_handler, #{
       level => info,
       formatter => {logger_formatter, #{
         template => [time, " ", level, " ", msg, " ",
                      {trace_id, ["trace_id=", trace_id], []}, " ",
                      {span_id, ["span_id=", span_id], []}, "\n"]
       }}
     }}
   ]}
 ]}
].

Usage example

erlang
-module(order_processor).
-include_lib("opentelemetry_api/include/otel_tracer.hrl").

-export([process_order/1]).

process_order(OrderId) ->
    ?with_span(<<"process_order">>, #{}, fun() ->
        logger:info("Starting order processing", #{order_id => OrderId}),

        case validate_order(OrderId) of
            ok ->
                logger:info("Order validated successfully", #{order_id => OrderId}),
                complete_order(OrderId);
            {error, Reason} ->
                logger:error("Order validation failed", #{
                    order_id => OrderId,
                    reason => Reason
                }),
                ?set_status(?OTEL_STATUS_ERROR, <<"Validation failed">>),
                {error, Reason}
        end
    end).

validate_order(_OrderId) -> ok.

complete_order(OrderId) ->
    ?with_span(<<"complete_order">>, #{}, fun() ->
        logger:info("Completing order", #{order_id => OrderId}),
        {ok, OrderId}
    end).

Log-trace correlation

When you emit a log within an active trace span, you can include:

  • trace_id: Links log to the entire distributed trace
  • span_id: Links log to the specific operation
  • trace_flags: Indicates if the trace is sampled

This enables bidirectional navigation between logs and traces in Uptrace.

Elixir correlation helper

elixir
defmodule MyApp.LogHelper do
  require Logger

  @doc """
  Logs a message with automatic trace context injection.
  """
  def info(message, metadata \\ []) do
    Logger.info(message, add_trace_context(metadata))
  end

  def error(message, metadata \\ []) do
    Logger.error(message, add_trace_context(metadata))
  end

  def warning(message, metadata \\ []) do
    Logger.warning(message, add_trace_context(metadata))
  end

  def debug(message, metadata \\ []) do
    Logger.debug(message, add_trace_context(metadata))
  end

  defp add_trace_context(metadata) do
    span_ctx = OpenTelemetry.Tracer.current_span_ctx()

    if OpenTelemetry.Span.is_valid(span_ctx) do
      trace_id = OpenTelemetry.Span.trace_id(span_ctx)
      span_id = OpenTelemetry.Span.span_id(span_ctx)

      Keyword.merge(metadata, [
        trace_id: format_hex(trace_id),
        span_id: format_hex(span_id)
      ])
    else
      metadata
    end
  end

  defp format_hex(id) when is_integer(id) do
    id |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(32, "0")
  end
  defp format_hex(id), do: to_string(id)
end

Phoenix integration

For Phoenix applications, you can use a custom Logger backend or Plug to automatically add trace context:

elixir
defmodule MyAppWeb.Plugs.TraceLogger do
  @behaviour Plug

  require Logger

  def init(opts), do: opts

  def call(conn, _opts) do
    span_ctx = OpenTelemetry.Tracer.current_span_ctx()

    if OpenTelemetry.Span.is_valid(span_ctx) do
      trace_id = OpenTelemetry.Span.trace_id(span_ctx)
      span_id = OpenTelemetry.Span.span_id(span_ctx)

      Logger.metadata(
        trace_id: format_hex(trace_id),
        span_id: format_hex(span_id)
      )
    end

    conn
  end

  defp format_hex(id) when is_integer(id) do
    id |> Integer.to_string(16) |> String.downcase()
  end
  defp format_hex(id), do: to_string(id)
end

Add the plug to your endpoint or router:

elixir
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  # Add after OpenTelemetry instrumentation
  plug MyAppWeb.Plugs.TraceLogger

  # ... other plugs
end

Best practices

Use structured logging

Use key-value pairs for structured logging to enable filtering and analysis:

elixir Elixir
# Good: Structured logging
Logger.info("Database query executed",
  query_type: "SELECT",
  table: "users",
  duration_ms: 45,
  rows_affected: 1
)

# Bad: Unstructured message
Logger.info("SELECT query on users table took 45ms and affected 1 row")
erlang Erlang
%% Good: Structured logging
logger:info("Database query executed", #{
    query_type => <<"SELECT">>,
    table => <<"users">>,
    duration_ms => 45,
    rows_affected => 1
}),

%% Bad: Unstructured message
logger:info("SELECT query on users table took 45ms and affected 1 row").

Log at appropriate levels

elixir Elixir
# Debug: Detailed diagnostic information
Logger.debug("Cache lookup", key: cache_key, hit: true)

# Info: Normal operational messages
Logger.info("Request processed", status: 200, duration_ms: 150)

# Warning: Unusual but recoverable situations
Logger.warning("Retry attempt", attempt: 3, max_attempts: 5)

# Error: Failures requiring attention
Logger.error("Payment processing failed", error: reason, order_id: order_id)
erlang Erlang
%% Debug: Detailed diagnostic information
logger:debug("Cache lookup", #{key => CacheKey, hit => true}),

%% Info: Normal operational messages
logger:info("Request processed", #{status => 200, duration_ms => 150}),

%% Warning: Unusual but recoverable situations
logger:warning("Retry attempt", #{attempt => 3, max_attempts => 5}),

%% Error: Failures requiring attention
logger:error("Payment processing failed", #{error => Reason, order_id => OrderId}).

Avoid logging sensitive data

Never log passwords, tokens, API keys, or personally identifiable information (PII):

elixir Elixir
# Bad: Logging sensitive data
Logger.info("User login", password: password, token: auth_token)

# Good: Log only safe identifiers
Logger.info("User login", user_id: user_id, ip_address: client_ip)
erlang Erlang
%% Bad: Logging sensitive data
logger:info("User login", #{password => Password, token => AuthToken}),

%% Good: Log only safe identifiers
logger:info("User login", #{user_id => UserId, ip_address => ClientIp}).

Include context in error logs

When logging errors, include enough context to diagnose the issue:

elixir Elixir
try do
  process_payment(order)
rescue
  error ->
    Logger.error("Payment processing failed",
      error_type: error.__struct__,
      error_message: Exception.message(error),
      order_id: order.id,
      amount: order.total,
      stacktrace: Exception.format_stacktrace(__STACKTRACE__)
    )
    reraise error, __STACKTRACE__
end
erlang Erlang
try
    process_payment(Order)
catch
    Class:Reason:Stacktrace ->
        logger:error("Payment processing failed", #{
            error_class => Class,
            error_reason => Reason,
            order_id => maps:get(id, Order),
            amount => maps:get(total, Order)
        }),
        erlang:raise(Class, Reason, Stacktrace)
end.

What's next?