Current section

Files

Jump to
excessibility lib telemetry_capture analyzers state_machine.ex
Raw

lib/telemetry_capture/analyzers/state_machine.ex

defmodule Excessibility.TelemetryCapture.Analyzers.StateMachine do
@moduledoc """
Analyzes state transitions across timeline events.
Detects:
- Keys added between events
- Keys removed between events
- Rapid state changes (many transitions)
- Unstable state (keys added then removed)
Uses data from the State enricher (state_keys) to track how the
assign keys change over time.
## Algorithm
1. Compare state_keys between consecutive events
2. Identify added and removed keys
3. Detect patterns:
- Rapid changes: 3+ transitions with changes
- Unstable state: Keys that appear and then disappear
4. Calculate summary statistics
## Output
Returns findings and statistics:
%{
findings: [
%{
severity: :info,
message: "2 keys added between events 1→2",
events: [1, 2],
metadata: %{added: [:products, :cart], removed: []}
}
],
stats: %{
total_transitions: 4,
total_keys_added: 5,
total_keys_removed: 2
}
}
"""
@behaviour Excessibility.TelemetryCapture.Analyzer
def name, do: :state_machine
def default_enabled?, do: true
def requires_enrichers, do: [:state]
def analyze(%{timeline: []}, _opts) do
%{findings: [], stats: %{}}
end
def analyze(%{timeline: [_single]}, _opts) do
%{findings: [], stats: %{total_transitions: 0}}
end
def analyze(%{timeline: timeline}, _opts) do
transitions = detect_transitions(timeline)
findings = analyze_transitions(transitions, timeline)
stats = calculate_stats(transitions)
%{
findings: findings,
stats: stats
}
end
defp detect_transitions(timeline) do
timeline
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [prev, curr] ->
prev_keys = MapSet.new(Map.get(prev, :state_keys, []))
curr_keys = MapSet.new(Map.get(curr, :state_keys, []))
added = curr_keys |> MapSet.difference(prev_keys) |> MapSet.to_list()
removed = prev_keys |> MapSet.difference(curr_keys) |> MapSet.to_list()
%{
from_sequence: prev.sequence,
to_sequence: curr.sequence,
added: added,
removed: removed,
has_changes?: length(added) > 0 or length(removed) > 0
}
end)
end
defp analyze_transitions(transitions, timeline) do
transition_findings = generate_transition_findings(transitions)
rapid_change_findings = detect_rapid_changes(transitions)
unstable_findings = detect_unstable_state(transitions, timeline)
transition_findings ++ rapid_change_findings ++ unstable_findings
end
defp generate_transition_findings(transitions) do
transitions
|> Enum.filter(& &1.has_changes?)
|> Enum.map(fn transition ->
message = build_transition_message(transition)
%{
severity: :info,
message: message,
events: [transition.from_sequence, transition.to_sequence],
metadata: %{
added: transition.added,
removed: transition.removed
}
}
end)
end
defp build_transition_message(transition) do
added_count = length(transition.added)
removed_count = length(transition.removed)
cond do
added_count > 0 and removed_count > 0 ->
"#{added_count} keys added, #{removed_count} keys removed between events #{transition.from_sequence}#{transition.to_sequence}"
added_count > 0 ->
"#{added_count} keys added between events #{transition.from_sequence}#{transition.to_sequence}"
removed_count > 0 ->
"#{removed_count} keys removed between events #{transition.from_sequence}#{transition.to_sequence}"
end
end
defp detect_rapid_changes(transitions) do
transitions_with_changes =
transitions
|> Enum.filter(& &1.has_changes?)
|> length()
if transitions_with_changes >= 3 do
sequences = Enum.map(transitions, & &1.from_sequence)
[
%{
severity: :warning,
message: "Rapid state changes detected: #{transitions_with_changes} transitions with changes",
events: sequences,
metadata: %{transition_count: transitions_with_changes}
}
]
else
[]
end
end
defp detect_unstable_state(transitions, timeline) do
# Track all keys that were added and later removed
all_added_keys =
transitions
|> Enum.flat_map(& &1.added)
|> MapSet.new()
all_removed_keys =
transitions
|> Enum.flat_map(& &1.removed)
|> MapSet.new()
unstable_keys = MapSet.intersection(all_added_keys, all_removed_keys)
if MapSet.size(unstable_keys) > 0 do
sequences = Enum.map(timeline, & &1.sequence)
[
%{
severity: :warning,
message: "Unstable state detected: #{MapSet.size(unstable_keys)} keys were added and then removed",
events: sequences,
metadata: %{
unstable_keys: MapSet.to_list(unstable_keys)
}
}
]
else
[]
end
end
defp calculate_stats(transitions) do
total_added = transitions |> Enum.map(&length(&1.added)) |> Enum.sum()
total_removed = transitions |> Enum.map(&length(&1.removed)) |> Enum.sum()
%{
total_transitions: length(transitions),
total_keys_added: total_added,
total_keys_removed: total_removed
}
end
end