PtcRunner.Tracer (PtcRunner v0.4.1)
View SourceImmutable trace recorder for SubAgent execution.
Traces are built by prepending entries for efficiency, then reversed on finalize. Each Tracer has a unique trace_id for correlation in parallel/nested execution.
Design Goals
- Immutable traces - No shared mutable state
- Correlation IDs - Link parent and child executions
- Timestamp ordering - Reconstruct parallel timelines
- Process isolation - Each SubAgent owns its trace
- Safe aggregation - Merge traces without race conditions
Usage
tracer = Tracer.new()
tracer = Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 1}})
tracer = Tracer.add_entry(tracer, %{type: :llm_response, data: %{tokens: 100}})
result = Tracer.finalize(tracer)Parallel Traces
When SubAgents run in parallel via Task.async_stream, their traces are
generated concurrently. Use merge_parallel/2 to combine child traces
into a unified timeline sorted by timestamp:
parent = Tracer.new()
children = [child1, child2, child3] # finalized tracers
merged = Tracer.merge_parallel(parent, children)
usage = Tracer.aggregate_usage(merged)Note:
Step.traceis always a list[trace_entry()]. The merged map structure returned bymerge_parallel/2is a separate aggregation result, not a replacement forStep.trace.
Nested Traces
For SubAgents calling other SubAgents via tools, use record_nested_call/3:
tracer = Tracer.record_nested_call(tracer, tool_call, child_step)Trace ID Generation
Trace IDs are 32-character hex strings generated from cryptographically secure random bytes. No external dependencies required.
Summary
Types
A single trace entry.
Valid trace entry types.
Aggregated trace from parallel execution.
Tracer struct for recording execution traces.
Aggregated usage statistics.
Functions
Adds an entry to the tracer.
Aggregate usage statistics from a tracer or merged trace.
Returns entries in chronological order.
Finalizes the tracer, reversing entries to chronological order.
Returns entries matching the given type.
Returns all entries with type :llm_call.
Merge multiple traces from parallel execution.
Creates a new tracer with a unique trace ID.
Record a nested SubAgent execution within a tool call.
Returns entries with duration_ms in data, sorted by duration descending.
Returns all entries with type :tool_call.
Total duration in milliseconds from started_at to finalized_at.
Enhanced usage summary with duration breakdown.
Types
@type entry() :: %{type: entry_type(), timestamp: DateTime.t(), data: map()}
A single trace entry.
Fields:
type: The type of event being tracedtimestamp: When the entry was recordeddata: Additional data for this entry
@type entry_type() ::
:llm_call
| :llm_response
| :tool_call
| :tool_result
| :program_start
| :program_end
| :return
| :fail
| :nested_call
Valid trace entry types.
@type merged_trace() :: %{ root_trace_id: String.t(), entries: [entry()], metadata: %{ agent_count: non_neg_integer(), parallel: boolean(), wall_time_ms: non_neg_integer(), total_turns: non_neg_integer() } }
Aggregated trace from parallel execution.
Returned by merge_parallel/2 - separate from Tracer.t().
@type t() :: %PtcRunner.Tracer{ entries: [entry()], finalized_at: DateTime.t() | nil, parent_id: String.t() | nil, started_at: DateTime.t(), trace_id: String.t() }
Tracer struct for recording execution traces.
Fields:
trace_id: Unique 32-character hex ID for this executionparent_id: Parent trace ID for nested agent calls (nil for root)started_at: When the tracer was createdentries: List of trace entries (prepended for efficiency, reversed on finalize)finalized_at: Whenfinalize/1was called (nil until finalized)
@type usage_stats() :: %{ total_duration_ms: non_neg_integer(), llm_calls: non_neg_integer(), tool_calls: non_neg_integer(), total_turns: non_neg_integer(), agent_count: non_neg_integer() }
Aggregated usage statistics.
Returned by aggregate_usage/1.
Functions
Adds an entry to the tracer.
Entries are prepended for efficiency and reversed on finalize/1.
A timestamp is added automatically if not provided.
Raises FunctionClauseError if called on a finalized tracer.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 1}})
iex> length(tracer.entries)
1
iex> hd(tracer.entries).type
:llm_call
@spec aggregate_usage(t() | merged_trace()) :: usage_stats()
Aggregate usage statistics from a tracer or merged trace.
Works on both Tracer.t() and merged_trace() maps.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{}})
iex> tracer = PtcRunner.Tracer.finalize(tracer)
iex> usage = PtcRunner.Tracer.aggregate_usage(tracer)
iex> usage.llm_calls
1
iex> usage.tool_calls
1
Returns entries in chronological order.
If the tracer is not finalized, entries are reversed to chronological order. If already finalized, entries are already in chronological order.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_response, data: %{}})
iex> entries = PtcRunner.Tracer.entries(tracer)
iex> hd(entries).type
:llm_call
Finalizes the tracer, reversing entries to chronological order.
Sets the finalized_at timestamp. After finalization, add_entry/2 will
raise a FunctionClauseError.
Raises FunctionClauseError if called on an already finalized tracer.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_response, data: %{}})
iex> result = PtcRunner.Tracer.finalize(tracer)
iex> hd(result.entries).type
:llm_call
iex> is_struct(result.finalized_at, DateTime)
true
@spec find_by_type(t(), entry_type()) :: [entry()]
Returns entries matching the given type.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 1}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{name: "search"}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 2}})
iex> entries = PtcRunner.Tracer.find_by_type(tracer, :llm_call)
iex> length(entries)
2
iex> Enum.all?(entries, & &1.type == :llm_call)
true
Returns all entries with type :llm_call.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 1}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{name: "search"}})
iex> PtcRunner.Tracer.llm_calls(tracer) |> length()
1
@spec merge_parallel(t(), [t()]) :: merged_trace()
Merge multiple traces from parallel execution.
Returns a merged trace map (not a Tracer.t()) with all entries sorted by timestamp.
The parent tracer provides the root trace ID, while child tracers provide the entries.
Examples
iex> parent = PtcRunner.Tracer.new()
iex> child1 = PtcRunner.Tracer.new(parent_id: parent.trace_id)
iex> child1 = PtcRunner.Tracer.add_entry(child1, %{type: :llm_call, data: %{turn: 1}})
iex> child1 = PtcRunner.Tracer.finalize(child1)
iex> merged = PtcRunner.Tracer.merge_parallel(parent, [child1])
iex> merged.root_trace_id == parent.trace_id
true
iex> merged.metadata.agent_count
1
iex> merged.metadata.parallel
true
Creates a new tracer with a unique trace ID.
Options
:parent_id- Parent trace ID for nested agent calls
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> String.length(tracer.trace_id)
32
iex> tracer.parent_id
nil
iex> tracer.entries
[]
iex> tracer.finalized_at
nil
iex> tracer = PtcRunner.Tracer.new(parent_id: "abc123")
iex> tracer.parent_id
"abc123"
Record a nested SubAgent execution within a tool call.
Adds a :nested_call entry with the tool call and child step's return value and trace.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tool_call = %{name: "sub_agent", args: %{prompt: "test"}}
iex> child_step = %{return: "result", trace: [%{turn: 1}]}
iex> tracer = PtcRunner.Tracer.record_nested_call(tracer, tool_call, child_step)
iex> [entry] = PtcRunner.Tracer.entries(tracer)
iex> entry.type
:nested_call
iex> entry.data.result.return
"result"
@spec slowest_entries(t(), non_neg_integer()) :: [entry()]
Returns entries with duration_ms in data, sorted by duration descending.
Only includes entries that have a :duration_ms key in their data map.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{duration_ms: 100}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{duration_ms: 50}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{duration_ms: 200}})
iex> [slowest | _] = PtcRunner.Tracer.slowest_entries(tracer, 1)
iex> slowest.data.duration_ms
200
Returns all entries with type :tool_call.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{turn: 1}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{name: "search"}})
iex> PtcRunner.Tracer.tool_calls(tracer) |> length()
1
@spec total_duration(t()) :: non_neg_integer()
Total duration in milliseconds from started_at to finalized_at.
Returns 0 if the tracer is not finalized or timestamps are nil.
Examples
iex> tracer = %PtcRunner.Tracer{
...> trace_id: "test",
...> parent_id: nil,
...> started_at: ~U[2024-01-15 10:00:00Z],
...> entries: [],
...> finalized_at: ~U[2024-01-15 10:00:02Z]
...> }
iex> PtcRunner.Tracer.total_duration(tracer)
2000
iex> tracer = PtcRunner.Tracer.new()
iex> PtcRunner.Tracer.total_duration(tracer)
0
Enhanced usage summary with duration breakdown.
Includes total duration, LLM and tool call durations (summed from entries with
duration_ms in their data), and counts.
Examples
iex> tracer = PtcRunner.Tracer.new()
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :llm_call, data: %{duration_ms: 100}})
iex> tracer = PtcRunner.Tracer.add_entry(tracer, %{type: :tool_call, data: %{duration_ms: 50}})
iex> tracer = PtcRunner.Tracer.finalize(tracer)
iex> summary = PtcRunner.Tracer.usage_summary(tracer)
iex> summary.llm_duration_ms
100
iex> summary.tool_duration_ms
50
iex> summary.llm_call_count
1
iex> summary.tool_call_count
1