OpenTelemetry Erlang/Elixir TraceParent Header
Most context propagation in Erlang/Elixir applications is handled automatically by instrumentation libraries like OpenTelemetry Phoenix, OpenTelemetry Cowboy, and HTTP client libraries. These libraries automatically extract and inject W3C Trace Context headers including traceparent
for distributed tracing.
However, there are scenarios where manual context propagation is necessary, such as custom protocols, message queues, or when working with unsupported HTTP libraries. This guide covers manual handling of traceparent
headers for distributed tracing.
Prerequisites
Ensure you have OpenTelemetry configured in your application. For setup instructions, see Monitor OpenTelemetry Erlang/Elixir with Uptrace.
TraceParent header format
The traceparent
header follows the W3C Trace Context specification:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Format breakdown:
00
- Version (currently always "00")4bf92f3577b34da6a3ce929d0e0e4736
- Trace ID (32 hex characters)00f067aa0ba902b7
- Parent Span ID (16 hex characters)01
- Trace flags (8-bit field, "01" = sampled)
Manual context extraction
Extract trace context from incoming requests when automatic instrumentation isn't available:
Elixir manual extraction
defmodule MyApp.TraceContext do
def extract_from_headers(headers) when is_map(headers) do
headers_list = Enum.map(headers, fn {k, v} -> {String.downcase(k), v} end)
:otel_propagator_text_map.extract(headers_list)
end
def extract_from_headers(headers) when is_list(headers) do
# Normalize header names to lowercase
normalized_headers = Enum.map(headers, fn
{key, value} when is_binary(key) -> {String.downcase(key), value}
{key, value} when is_atom(key) -> {Atom.to_string(key) |> String.downcase(), value}
end)
:otel_propagator_text_map.extract(normalized_headers)
end
end
# Usage in a custom HTTP handler
defmodule MyApp.CustomHandler do
require OpenTelemetry.Tracer
def handle_request(headers, body) do
# Extract context from headers
MyApp.TraceContext.extract_from_headers(headers)
# Now any spans created will be part of the distributed trace
OpenTelemetry.Tracer.with_span "custom_request_handler" do
OpenTelemetry.Tracer.set_attribute("request.body_size", byte_size(body))
# Process the request
process_request(body)
end
end
defp process_request(body) do
# Your business logic here
Jason.decode(body)
end
end
Erlang manual extraction
-module(trace_context).
-export([extract_from_headers/1]).
-include_lib("opentelemetry_api/include/otel_tracer.hrl").
extract_from_headers(Headers) when is_list(Headers) ->
% Normalize headers to lowercase
NormalizedHeaders = lists:map(fun({Key, Value}) ->
NormalizedKey = case Key of
K when is_binary(K) -> string:lowercase(binary_to_list(K));
K when is_list(K) -> string:lowercase(K);
K when is_atom(K) -> string:lowercase(atom_to_list(K))
end,
{list_to_binary(NormalizedKey), Value}
end, Headers),
otel_propagator_text_map:extract(NormalizedHeaders).
% Usage in custom handler
handle_request(Headers, Body) ->
% Extract context from headers
trace_context:extract_from_headers(Headers),
% Create span as part of distributed trace
?with_span(<<"custom_request_handler">>, #{}, fun() ->
?set_attribute(<<"request.body_size">>, byte_size(Body)),
% Process the request
process_request(Body)
end).
process_request(Body) ->
% Your business logic here
jsx:decode(Body).
Manual context injection
Inject trace context into outgoing requests when automatic instrumentation isn't available:
Elixir manual injection
defmodule MyApp.CustomHTTPClient do
def make_request(url, body) do
# Prepare headers map for injection
headers = %{}
# Inject current trace context
injected_headers = :otel_propagator_text_map.inject(headers)
# Convert to format expected by HTTP client
header_list = Enum.map(injected_headers, fn {k, v} -> {k, v} end)
# Make HTTP request with injected headers
case :httpc.request(:post, {url, header_list, "application/json", body}, [], []) do
{:ok, {{_Version, 200, _ReasonPhrase}, _Headers, ResponseBody}} ->
{:ok, ResponseBody}
{:ok, {{_Version, StatusCode, _ReasonPhrase}, _Headers, _Body}} ->
{:error, {:http_error, StatusCode}}
{:error, Reason} ->
{:error, Reason}
end
end
end
# Usage with custom span
defmodule MyApp.ExternalService do
require OpenTelemetry.Tracer
def send_data(data) do
OpenTelemetry.Tracer.with_span "external_service_call" do
OpenTelemetry.Tracer.set_attributes(%{
"service.name" => "external_api",
"http.method" => "POST",
"data.size" => byte_size(data)
})
case MyApp.CustomHTTPClient.make_request("https://api.example.com/data", data) do
{:ok, response} ->
OpenTelemetry.Tracer.set_attribute("http.status_code", 200)
{:ok, response}
{:error, reason} ->
OpenTelemetry.Tracer.set_status(:error, "External service call failed")
OpenTelemetry.Tracer.set_attribute("error.type", "http_error")
{:error, reason}
end
end
end
end
Erlang manual injection
-module(custom_http_client).
-export([make_request/2]).
make_request(Url, Body) ->
% Prepare headers for injection
Headers = #{},
% Inject current trace context
InjectedHeaders = otel_propagator_text_map:inject(Headers),
% Convert to list format for httpc
HeaderList = maps:fold(fun(K, V, Acc) ->
[{binary_to_list(K), binary_to_list(V)} | Acc]
end, [], InjectedHeaders),
% Make HTTP request with injected headers
case httpc:request(post, {Url, HeaderList, "application/json", Body}, [], []) of
{ok, {{_Version, 200, _ReasonPhrase}, _ResponseHeaders, ResponseBody}} ->
{ok, ResponseBody};
{ok, {{_Version, StatusCode, _ReasonPhrase}, _ResponseHeaders, _Body}} ->
{error, {http_error, StatusCode}};
{error, Reason} ->
{error, Reason}
end.
% Usage with custom span
-module(external_service).
-export([send_data/1]).
-include_lib("opentelemetry_api/include/otel_tracer.hrl").
send_data(Data) ->
?with_span(<<"external_service_call">>, #{}, fun() ->
?set_attribute(<<"service.name">>, <<"external_api">>),
?set_attribute(<<"http.method">>, <<"POST">>),
?set_attribute(<<"data.size">>, byte_size(Data)),
case custom_http_client:make_request("https://api.example.com/data", Data) of
{ok, Response} ->
?set_attribute(<<"http.status_code">>, 200),
{ok, Response};
{error, Reason} ->
?set_status(?OTEL_STATUS_ERROR, <<"External service call failed">>),
?set_attribute(<<"error.type">>, <<"http_error">>),
{error, Reason}
end
end).
Message queue propagation
Propagate trace context through message queues for distributed tracing:
Elixir with RabbitMQ
defmodule MyApp.MessageProducer do
def publish_message(queue, message) do
# Inject trace context into message headers
headers = :otel_propagator_text_map.inject(%{})
# Convert headers for AMQP
amqp_headers = Enum.map(headers, fn {k, v} -> {k, :longstr, v} end)
# Publish message with trace context
AMQP.Basic.publish(
MyApp.RabbitMQ.channel(),
"",
queue,
Jason.encode!(message),
headers: amqp_headers
)
end
end
defmodule MyApp.MessageConsumer do
require OpenTelemetry.Tracer
def handle_message(payload, meta) do
# Extract trace context from message headers
headers = case meta.headers do
:undefined -> %{}
header_list ->
Enum.reduce(header_list, %{}, fn {key, _type, value}, acc ->
Map.put(acc, key, value)
end)
end
# Extract context and make it current
:otel_propagator_text_map.extract(headers)
# Process message within distributed trace
OpenTelemetry.Tracer.with_span "message_consumer" do
OpenTelemetry.Tracer.set_attributes(%{
"messaging.system" => "rabbitmq",
"messaging.destination" => meta.routing_key,
"messaging.message_payload_size_bytes" => byte_size(payload)
})
case Jason.decode(payload) do
{:ok, message} ->
process_message(message)
{:error, reason} ->
OpenTelemetry.Tracer.set_status(:error, "Message decode failed")
{:error, reason}
end
end
end
defp process_message(message) do
# Your message processing logic
IO.inspect(message, label: "Processing message")
end
end
Custom protocol propagation
Propagate context over custom protocols:
Elixir GenServer with context
defmodule MyApp.WorkerPool do
use GenServer
require OpenTelemetry.Tracer
def submit_work(pid, work_data) do
# Inject current trace context
trace_headers = :otel_propagator_text_map.inject(%{})
# Send work with trace context
GenServer.call(pid, {:submit_work, work_data, trace_headers})
end
def handle_call({:submit_work, work_data, trace_headers}, _from, state) do
# Extract trace context from headers
:otel_propagator_text_map.extract(trace_headers)
# Process work within distributed trace
result = OpenTelemetry.Tracer.with_span "worker_process" do
OpenTelemetry.Tracer.set_attributes(%{
"worker.pid" => inspect(self()),
"work.type" => work_data.type,
"work.priority" => work_data.priority
})
# Simulate work processing
:timer.sleep(100)
OpenTelemetry.Tracer.add_event("work.completed", %{
"work.id" => work_data.id
})
{:ok, "Work completed"}
end
{:reply, result, state}
end
end
Debugging trace propagation
Debug and validate trace context propagation:
Elixir debugging utilities
defmodule MyApp.TraceDebugger do
require Logger
def debug_current_context 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)
trace_flags = OpenTelemetry.Span.trace_flags(span_ctx)
# Format as traceparent header
traceparent = "00-#{trace_id}-#{span_id}-#{String.pad_leading(Integer.to_string(trace_flags, 16), 2, "0")}"
Logger.info("Current trace context - TraceParent: #{traceparent}")
%{
trace_id: trace_id,
span_id: span_id,
trace_flags: trace_flags,
traceparent: traceparent,
valid: true
}
else
Logger.warning("No valid trace context found")
%{valid: false}
end
end
def debug_headers(headers) do
traceparent = case Map.get(headers, "traceparent") do
nil -> "Not found"
value -> value
end
tracestate = case Map.get(headers, "tracestate") do
nil -> "Not found"
value -> value
end
Logger.info("Header debug - TraceparentHeader: #{traceparent}, Tracestate: #{tracestate}")
%{
traceparent: traceparent,
tracestate: tracestate
}
end
end
# Usage in debugging
defmodule MyApp.DebugController do
def debug_action(conn, _params) do
# Debug incoming headers
headers = Enum.into(conn.req_headers, %{})
MyApp.TraceDebugger.debug_headers(headers)
# Debug current context
MyApp.TraceDebugger.debug_current_context()
json(conn, %{status: "debug_complete"})
end
end