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.
- 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
Add opentelemetry_test_processor to your list of dependencies in mix.exs:
def deps do
[
{:opentelemetry_test_processor, "~> 0.1.0", only: :test}
]
endConfigure 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}}
]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
endtest "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
endtest "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)
endtest "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"}
endtest "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"}}
endUse 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
endChild 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"}}
endFor 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
endPrivate 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 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
endAutomatically 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.
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()}
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()}
Sets the processor to private mode. Processes must explicitly opt-in with start/1. Safe for async: true tests.
Sets the processor to global mode. All spans are sent to the shared owner process. Cannot be used with async: true tests.
Chooses the processor mode based on context. Uses set_private/1 when async: true, otherwise set_global/1.
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
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
endtest "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}}}
endIf 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
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
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
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.
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.
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)