OpenTelemetryTestProcessor
A test span processor that behaves like Mox for OpenTelemetry traces. Test your OpenTelemetry instrumentation with the same ease and safety as you test other dependencies.
Features
- Explicit Opt-in: Tests must explicitly call
start/1to receive spans, preventing test pollution - Process Isolation: Safe for
async: truetests with private mode (default) - Child Process Support: Automatically inherits permissions for spawned tasks and processes
- Flexible Ownership: Use
allow/2to permit specific processes to send spans - Clean API: Receive spans as messages with
assert_receive {:trace_span, span} - Rich Span Data: Access span name, status, attributes, events, trace/span IDs, and the original OpenTelemetry span
Installation
Add opentelemetry_test_processor to your list of dependencies in mix.exs:
def deps do
[
{:opentelemetry_test_processor, "~> 0.1.0", only: :test}
]
endConfiguration
Configure the processor in your test environment (config/test.exs):
config :opentelemetry,
traces_exporter: :none,
processors: [
{OpenTelemetryTestProcessor, %{}}
]This disables the default exporter and sets up the test processor to capture spans in your tests.
An optional :timeout (in milliseconds) can be provided to configure how long the processor waits for the ownership server on each span (default: 5000ms):
config :opentelemetry,
traces_exporter: :none,
processors: [
{OpenTelemetryTestProcessor, %{timeout: 10_000}}
]Usage
Basic Usage
Tests must explicitly opt-in to receive spans by calling OpenTelemetryTestProcessor.start/0:
defmodule MyAppTest do
use ExUnit.Case
alias OpenTelemetry.Tracer
require Tracer
test "receives spans from traced code" do
OpenTelemetryTestProcessor.start()
# Your code that generates spans
Tracer.with_span "my operation" do
Tracer.set_status(:ok)
Tracer.set_attributes(%{"user_id" => 123})
end
# Assert on received spans
assert_receive {:trace_span, span}
assert span.name == "my operation"
assert span.status == %{status: :ok, message: ""}
assert span.attributes == %{"user_id" => 123}
end
endTesting Span Attributes
test "validates span attributes" do
OpenTelemetryTestProcessor.start()
attributes = %{"key" => "value", "count" => 42}
Tracer.with_span "test span" do
Tracer.set_attributes(attributes)
end
assert_receive {:trace_span, span}
assert span.attributes == attributes
endTesting Span Events
test "captures span events" do
OpenTelemetryTestProcessor.start()
Tracer.with_span "operation with events" do
Tracer.add_event("processing started", %{"item_count" => 10})
Tracer.add_event("processing completed", %{"duration_ms" => 150})
end
assert_receive {:trace_span, span}
assert length(span.events) == 2
assert Enum.any?(span.events, fn e -> e.type == "processing started" end)
endTesting Error Spans
test "captures error spans" do
OpenTelemetryTestProcessor.start()
Tracer.with_span "failing operation" do
Tracer.set_status(:error, "Something went wrong")
end
assert_receive {:trace_span, span}
assert span.status == %{status: :error, message: "Something went wrong"}
endTesting Nested Spans
test "handles nested spans" do
OpenTelemetryTestProcessor.start()
Tracer.with_span "parent operation" do
Tracer.set_attributes(%{"level" => "parent"})
Tracer.with_span "child operation" do
Tracer.set_attributes(%{"level" => "child"})
end
end
# Child span completes first
assert_receive {:trace_span, %{name: "child operation"}}
# Then parent span
assert_receive {:trace_span, %{name: "parent operation"}}
endVerifying Trace Propagation
Use trace_id, span_id, and parent_span_id to assert parent-child relationships between spans:
test "child span is linked to parent" do
OpenTelemetryTestProcessor.start()
Tracer.with_span "parent" do
Tracer.with_span "child" do
:ok
end
end
assert_receive {:trace_span, child}
assert_receive {:trace_span, parent}
# All spans share the same trace
assert child.trace_id == parent.trace_id
# Child's parent is the parent span
assert child.parent_span_id == parent.span_id
# Root span has no parent
assert parent.parent_span_id == :undefined
endWorking with Child Processes
Child processes automatically inherit span tracking permissions:
test "child processes via Task" do
OpenTelemetryTestProcessor.start()
task = Task.async(fn ->
Tracer.with_span "task operation" do
Tracer.set_status(:ok)
end
end)
Task.await(task)
assert_receive {:trace_span, %{name: "task operation"}}
endAllowing Non-Child Processes
For processes that aren't direct children, use allow/2:
test "with spawned process" do
OpenTelemetryTestProcessor.start()
test_pid = self()
spawn(fn ->
# allow/2 must be called before the span ends.
# Since on_end/2 runs in the same process as the span,
# calling allow/2 first is safe here.
OpenTelemetryTestProcessor.allow(test_pid, self())
Tracer.with_span "spawned operation" do
Tracer.set_status(:ok)
end
end)
assert_receive {:trace_span, %{name: "spawned operation"}}, 1000
endMode Management
Private Mode (Default)
Private mode is the default and is safe for async: true tests. Each test must explicitly call start/1:
setup :set_private
test "isolated test" do
OpenTelemetryTestProcessor.start()
# ... test code
endGlobal Mode
Global mode sends all spans to a shared owner process. Cannot be used with async: true:
# In your test module
use ExUnit.Case, async: false
setup {OpenTelemetryTestProcessor, :set_global}
test "shared spans" do
# No need to call start/1 in global mode
# ... test code
endContext-Based Mode
Automatically choose the mode based on the test context:
setup {OpenTelemetryTestProcessor, :set_from_context}
This uses private mode for async: true tests and global mode otherwise.
API Reference
OpenTelemetryTestProcessor
start(owner_pid \\ self())
Starts tracking spans for the given process. The process will receive all spans that are ended by itself or any of its child processes.
Returns: :ok | {:error, term()}
allow(owner_pid, allowed_pid)
Allows allowed_pid to use spans from owner_pid. When spans are ended by allowed_pid, they will be sent to owner_pid.
Returns: :ok | {:error, term()}
set_private(context \\ %{})
Sets the processor to private mode. Processes must explicitly opt-in with start/1. Safe for async: true tests.
set_global(context)
Sets the processor to global mode. All spans are sent to the shared owner process. Cannot be used with async: true tests.
set_from_context(context)
Chooses the processor mode based on context. Uses set_private/1 when async: true, otherwise set_global/1.
OpenTelemetryTestProcessor.Span
The span struct sent to test processes contains:
name- The span name (string)trace_id- The 128-bit integer trace ID (shared by all spans in a trace)span_id- The 64-bit integer span IDparent_span_id- The 64-bit integer parent span ID (:undefinedfor root spans)status- A map withstatus(atom::ok,:error,:unset) andmessage(string)attributes- A map of span attributesevents- A list of event maps withtypeandattributeskeysoriginal_span- The raw OpenTelemetry span record for advanced use cases
Examples
Testing a Service with Multiple Spans
defmodule MyApp.UserService do
alias OpenTelemetry.Tracer
require Tracer
def create_user(params) do
Tracer.with_span "create_user" do
Tracer.set_attributes(%{"user_email" => params.email})
Tracer.with_span "validate_user" do
# validation logic
end
Tracer.with_span "save_user" do
# save logic
end
Tracer.set_status(:ok)
{:ok, %User{}}
end
end
end
defmodule MyApp.UserServiceTest do
use ExUnit.Case
test "create_user generates proper spans" do
OpenTelemetryTestProcessor.start()
MyApp.UserService.create_user(%{email: "test@example.com"})
# Receive spans in order of completion
assert_receive {:trace_span, %{name: "validate_user"}}
assert_receive {:trace_span, %{name: "save_user"}}
assert_receive {:trace_span, %{name: "create_user", status: %{status: :ok}}}
end
endTesting Multiple Sequential Operations
test "multiple operations" do
OpenTelemetryTestProcessor.start()
Enum.each(1..3, fn i ->
Tracer.with_span "operation_#{i}" do
Tracer.set_attributes(%{"index" => i})
end
end)
assert_receive {:trace_span, %{name: "operation_1", attributes: %{"index" => 1}}}
assert_receive {:trace_span, %{name: "operation_2", attributes: %{"index" => 2}}}
assert_receive {:trace_span, %{name: "operation_3", attributes: %{"index" => 3}}}
endTroubleshooting
Spans Not Being Received
If you're not receiving spans in your tests:
-
Ensure you've configured the processor in
config/test.exs -
Call
OpenTelemetryTestProcessor.start()at the beginning of your test - Check that the code generating spans is actually being executed
-
Use
assert_receivewith a timeout:assert_receive {:trace_span, _}, 1000
Async Test Issues
If you're getting errors about global mode with async tests:
-
Remove
async: truefrom your test module when usingset_global/1 -
Or use
set_private/1orset_from_context/1instead
Process Permission Issues
If spawned processes aren't sending spans:
-
Use
Task.asyncinstead ofspawn(automatic permission inheritance) -
Or explicitly call
allow/2inside the spawned process before creating any spans
ArgumentError on Named Process
If you see ArgumentError: no process registered with name ..., the atom you passed to start/1 or allow/2 is not a registered process name. Make sure the process is alive and registered before calling these functions.
Global Mode Race Condition
set_global/1 switches the entire ownership server to shared mode, which affects all concurrently running tests. Never use set_global/1 (or set_from_context/1 with async: false) inside an async: true test module. Mixing global mode with async tests can cause spans to be routed to the wrong test process.
License
Beerware 🍺 — do whatever you want with it, but if we meet, buy me a beer. (This is essentially MIT-like. Use it freely, but if we meet, buy me a beer)