Skip to content

Commit bd85fb5

Browse files
authored
Merge pull request #4 from i365dev/feature/execution-stats
feat(core): Add non-breaking flow execution statistics collection
2 parents 1851f3c + 3ff6c10 commit bd85fb5

File tree

4 files changed

+388
-10
lines changed

4 files changed

+388
-10
lines changed

lib/agent_forge/execution_stats.ex

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule AgentForge.ExecutionStats do
2+
@moduledoc """
3+
Provides functionality for collecting and analyzing execution statistics of flows.
4+
Tracks metrics such as execution time, steps taken, and signal patterns.
5+
"""
6+
7+
@type t :: %__MODULE__{
8+
start_time: integer(),
9+
steps: non_neg_integer(),
10+
signal_types: %{atom() => non_neg_integer()},
11+
handler_calls: %{atom() => non_neg_integer()},
12+
max_state_size: non_neg_integer(),
13+
complete: boolean(),
14+
elapsed_ms: integer() | nil,
15+
result: any()
16+
}
17+
18+
defstruct start_time: nil,
19+
steps: 0,
20+
signal_types: %{},
21+
handler_calls: %{},
22+
max_state_size: 0,
23+
complete: false,
24+
elapsed_ms: nil,
25+
result: nil
26+
27+
@doc """
28+
Creates a new execution stats struct with initial values.
29+
30+
## Examples
31+
32+
iex> stats = AgentForge.ExecutionStats.new()
33+
iex> is_integer(stats.start_time) and stats.steps == 0
34+
true
35+
"""
36+
def new do
37+
%__MODULE__{
38+
start_time: System.monotonic_time(:millisecond)
39+
}
40+
end
41+
42+
@doc """
43+
Records a step in the execution process, updating relevant statistics.
44+
45+
## Parameters
46+
47+
* `stats` - Current execution stats struct
48+
* `handler_info` - Information about the handler being executed
49+
* `signal` - The signal being processed
50+
* `state` - Current state of the flow
51+
52+
## Examples
53+
54+
iex> stats = AgentForge.ExecutionStats.new()
55+
iex> signal = %{type: :test, data: "data"}
56+
iex> updated = AgentForge.ExecutionStats.record_step(stats, :test_handler, signal, %{})
57+
iex> updated.steps == 1 and updated.signal_types == %{test: 1}
58+
true
59+
"""
60+
def record_step(stats, handler_info, signal, state) do
61+
state_size = get_state_size(state)
62+
63+
%{
64+
stats
65+
| steps: stats.steps + 1,
66+
signal_types: increment_counter(stats.signal_types, signal.type),
67+
handler_calls: increment_counter(stats.handler_calls, handler_info),
68+
max_state_size: max(stats.max_state_size, state_size)
69+
}
70+
end
71+
72+
@doc """
73+
Finalizes the execution stats with the result and calculates elapsed time.
74+
75+
## Parameters
76+
77+
* `stats` - Current execution stats struct
78+
* `result` - The final result of the flow execution
79+
80+
## Examples
81+
82+
iex> stats = AgentForge.ExecutionStats.new()
83+
iex> final = AgentForge.ExecutionStats.finalize(stats, {:ok, "success"})
84+
iex> final.complete and is_integer(final.elapsed_ms) and final.result == {:ok, "success"}
85+
true
86+
"""
87+
def finalize(stats, result) do
88+
%{
89+
stats
90+
| complete: true,
91+
elapsed_ms: System.monotonic_time(:millisecond) - stats.start_time,
92+
result: result
93+
}
94+
end
95+
96+
@doc """
97+
Formats the execution stats into a human-readable report.
98+
99+
## Examples
100+
101+
iex> stats = AgentForge.ExecutionStats.new()
102+
iex> stats = AgentForge.ExecutionStats.finalize(stats, {:ok, "success"})
103+
iex> report = AgentForge.ExecutionStats.format_report(stats)
104+
iex> String.contains?(report, "Total Steps: 0") and String.contains?(report, "Result: {:ok, \\"success\\"}")
105+
true
106+
"""
107+
def format_report(stats) do
108+
"""
109+
Execution Statistics:
110+
- Total Steps: #{stats.steps}
111+
- Elapsed Time: #{stats.elapsed_ms}ms
112+
- Signal Types: #{format_counters(stats.signal_types)}
113+
- Handler Calls: #{format_counters(stats.handler_calls)}
114+
- Max State Size: #{stats.max_state_size} entries
115+
- Result: #{inspect(stats.result)}
116+
"""
117+
end
118+
119+
# Private Functions
120+
121+
defp increment_counter(counters, key) do
122+
Map.update(counters, key, 1, &(&1 + 1))
123+
end
124+
125+
defp get_state_size(state) when is_map(state), do: map_size(state)
126+
defp get_state_size(_), do: 0
127+
128+
defp format_counters(counters) do
129+
counters
130+
|> Enum.map(fn {key, count} -> "#{key}: #{count}" end)
131+
|> Enum.join(", ")
132+
end
133+
end

lib/agent_forge/flow.ex

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ defmodule AgentForge.Flow do
22
@moduledoc """
33
Provides functions for processing signals through a chain of handlers.
44
Each handler is a function that takes a signal and state, and returns a tuple with result and new state.
5+
Automatically collects execution statistics for monitoring and debugging.
56
"""
67

78
alias AgentForge.Signal
9+
alias AgentForge.ExecutionStats
10+
11+
# Store last execution stats in module attribute
12+
@last_execution_stats_key :"$agent_forge_last_execution_stats"
813

914
@doc """
1015
Processes a signal through a list of handlers.
@@ -61,27 +66,34 @@ defmodule AgentForge.Flow do
6166
# Private functions
6267

6368
defp process_handlers(handlers, signal, state) do
64-
Enum.reduce_while(handlers, {:ok, signal, state}, fn handler,
65-
{:ok, current_signal, current_state} ->
69+
stats = ExecutionStats.new()
70+
71+
Enum.reduce_while(handlers, {:ok, signal, state, stats}, fn handler,
72+
{:ok, current_signal,
73+
current_state, current_stats} ->
74+
# Record step before processing
75+
updated_stats =
76+
ExecutionStats.record_step(current_stats, handler, current_signal, current_state)
77+
6678
case process_handler(handler, current_signal, current_state) do
6779
{{:emit, new_signal}, new_state} ->
68-
{:cont, {:ok, new_signal, new_state}}
80+
{:cont, {:ok, new_signal, new_state, updated_stats}}
6981

7082
{{:emit_many, signals}, new_state} when is_list(signals) ->
7183
# When multiple signals are emitted, use the last one for continuation
72-
{:cont, {:ok, List.last(signals), new_state}}
84+
{:cont, {:ok, List.last(signals), new_state, updated_stats}}
7385

7486
{:skip, new_state} ->
75-
{:halt, {:ok, nil, new_state}}
87+
{:halt, {:ok, nil, new_state, updated_stats}}
7688

7789
{:halt, data} ->
78-
{:halt, {:ok, data, state}}
90+
{:halt, {:ok, data, state, updated_stats}}
7991

8092
{{:halt, data}, _state} ->
81-
{:halt, {:ok, data, state}}
93+
{:halt, {:ok, data, state, updated_stats}}
8294

8395
{{:error, reason}, new_state} ->
84-
{:halt, {:error, reason, new_state}}
96+
{:halt, {:error, reason, new_state, updated_stats}}
8597

8698
{other, _} ->
8799
raise "Invalid handler result: #{inspect(other)}"
@@ -93,6 +105,23 @@ defmodule AgentForge.Flow do
93105
end
94106

95107
# Handle the final result
96-
defp handle_result({:ok, signal, state}), do: {:ok, signal, state}
97-
defp handle_result({:error, reason, _state}), do: {:error, reason}
108+
defp handle_result({:ok, signal, state, stats}) do
109+
final_stats = ExecutionStats.finalize(stats, {:ok, signal})
110+
Process.put(@last_execution_stats_key, final_stats)
111+
{:ok, signal, state}
112+
end
113+
114+
defp handle_result({:error, reason, _state, stats}) do
115+
final_stats = ExecutionStats.finalize(stats, {:error, reason})
116+
Process.put(@last_execution_stats_key, final_stats)
117+
{:error, reason}
118+
end
119+
120+
@doc """
121+
Returns statistics from the last flow execution.
122+
Returns nil if no flow has been executed yet.
123+
"""
124+
def get_last_execution_stats do
125+
Process.get(@last_execution_stats_key)
126+
end
98127
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
defmodule AgentForge.ExecutionStatsTest do
2+
use ExUnit.Case
3+
doctest AgentForge.ExecutionStats
4+
5+
alias AgentForge.ExecutionStats
6+
alias AgentForge.Signal
7+
8+
describe "new/0" do
9+
test "creates new stats with initial values" do
10+
stats = ExecutionStats.new()
11+
assert stats.steps == 0
12+
assert stats.signal_types == %{}
13+
assert stats.handler_calls == %{}
14+
assert stats.max_state_size == 0
15+
assert stats.complete == false
16+
assert stats.elapsed_ms == nil
17+
assert stats.result == nil
18+
assert is_integer(stats.start_time)
19+
end
20+
end
21+
22+
describe "record_step/4" do
23+
test "increments steps and tracks signal types" do
24+
stats = ExecutionStats.new()
25+
signal = Signal.new(:test_signal, "data")
26+
state = %{key: "value"}
27+
28+
updated_stats = ExecutionStats.record_step(stats, :test_handler, signal, state)
29+
30+
assert updated_stats.steps == 1
31+
assert updated_stats.signal_types == %{test_signal: 1}
32+
assert updated_stats.handler_calls == %{test_handler: 1}
33+
assert updated_stats.max_state_size == 1
34+
end
35+
36+
test "tracks multiple signal types and handlers" do
37+
stats = ExecutionStats.new()
38+
signal1 = Signal.new(:type_a, "data1")
39+
signal2 = Signal.new(:type_b, "data2")
40+
signal3 = Signal.new(:type_a, "data3")
41+
state = %{key1: "value1", key2: "value2"}
42+
43+
stats = ExecutionStats.record_step(stats, :handler1, signal1, %{})
44+
stats = ExecutionStats.record_step(stats, :handler2, signal2, state)
45+
stats = ExecutionStats.record_step(stats, :handler1, signal3, state)
46+
47+
assert stats.steps == 3
48+
assert stats.signal_types == %{type_a: 2, type_b: 1}
49+
assert stats.handler_calls == %{handler1: 2, handler2: 1}
50+
assert stats.max_state_size == 2
51+
end
52+
53+
test "handles non-map state" do
54+
stats = ExecutionStats.new()
55+
signal = Signal.new(:test, "data")
56+
57+
updated_stats = ExecutionStats.record_step(stats, :handler, signal, nil)
58+
59+
assert updated_stats.steps == 1
60+
assert updated_stats.max_state_size == 0
61+
end
62+
end
63+
64+
describe "finalize/2" do
65+
test "completes stats with result and elapsed time" do
66+
stats = ExecutionStats.new()
67+
result = {:ok, "success"}
68+
69+
# Ensure some time passes
70+
:timer.sleep(1)
71+
stats = ExecutionStats.finalize(stats, result)
72+
73+
assert stats.complete == true
74+
assert stats.result == result
75+
assert stats.elapsed_ms > 0
76+
end
77+
end
78+
79+
describe "format_report/1" do
80+
test "generates readable report" do
81+
stats = ExecutionStats.new()
82+
signal = Signal.new(:test_signal, "data")
83+
84+
stats =
85+
stats
86+
|> ExecutionStats.record_step(:handler, signal, %{a: 1})
87+
|> ExecutionStats.finalize({:ok, "done"})
88+
89+
report = ExecutionStats.format_report(stats)
90+
91+
assert is_binary(report)
92+
assert report =~ "Total Steps: 1"
93+
assert report =~ "Signal Types: test_signal: 1"
94+
assert report =~ "Handler Calls: handler: 1"
95+
assert report =~ "Max State Size: 1"
96+
assert report =~ ~s(Result: {:ok, "done"})
97+
end
98+
end
99+
end

0 commit comments

Comments
 (0)