Packages
Library for accessibility snapshot testing in Phoenix applications using axe-core and Playwright.
Current section
Files
Jump to
Current section
Files
lib/telemetry_capture/timeline.ex
defmodule Excessibility.TelemetryCapture.Timeline do
@moduledoc """
Generates timeline data from telemetry snapshots.
Extracts key state, computes diffs, and formats timeline entries
for human and AI consumption.
"""
alias Excessibility.TelemetryCapture.Diff
alias Excessibility.TelemetryCapture.Enricher
alias Excessibility.TelemetryCapture.Filter
alias Excessibility.TelemetryCapture.Registry
@default_highlight_fields [:current_user, :live_action, :errors, :form]
@small_value_threshold 100
@doc """
Extracts key state from assigns for timeline display.
Includes:
- Highlighted fields (from config)
- Small primitive values (< #{@small_value_threshold} chars)
- List counts (products: [3 items] -> products_count: 3)
- Auto-detected important fields (status, action, etc.)
"""
def extract_key_state(assigns, highlight_fields \\ @default_highlight_fields) do
using_defaults? = highlight_fields == @default_highlight_fields
Enum.reduce(assigns, %{}, fn {key, value}, acc ->
cond do
key in highlight_fields -> Map.put(acc, key, value)
is_list(value) -> Map.put(acc, :"#{key}_count", length(value))
using_defaults? and small_value?(value) -> Map.put(acc, key, value)
true -> acc
end
end)
# Always include highlighted fields as-is
# Convert non-highlighted lists to counts
# Include small primitives only when using default fields
# Skip everything else
end
defp small_value?(value) when is_integer(value), do: true
defp small_value?(value) when is_atom(value), do: true
defp small_value?(value) when is_boolean(value), do: true
defp small_value?(value) when is_binary(value) do
byte_size(value) <= @small_value_threshold
end
defp small_value?(_), do: false
@doc """
Builds a complete timeline from snapshots.
Returns a map with:
- :test - test name
- :duration_ms - total test duration
- :timeline - list of timeline entries
"""
def build_timeline([], test_name) do
%{
test: test_name,
timeline: [],
duration_ms: 0
}
end
def build_timeline(snapshots, test_name, opts \\ []) do
first_timestamp = List.first(snapshots).timestamp
last_timestamp = List.last(snapshots).timestamp
duration_ms = DateTime.diff(last_timestamp, first_timestamp, :millisecond)
timeline =
snapshots
|> Enum.with_index(1)
|> Enum.map(fn {snapshot, index} ->
previous = if index > 1, do: Enum.at(snapshots, index - 2)
build_timeline_entry(snapshot, previous, index, opts)
end)
%{
test: test_name,
duration_ms: duration_ms,
timeline: timeline
}
end
@doc """
Builds a single timeline entry from a snapshot and its predecessor.
"""
def build_timeline_entry(snapshot, previous, sequence, opts \\ []) do
filtered_assigns = Filter.filter_assigns(snapshot.assigns, opts)
key_state =
extract_key_state(filtered_assigns, opts[:highlight_fields] || @default_highlight_fields)
previous_assigns =
if previous do
Filter.filter_assigns(previous.assigns, opts)
end
diff = Diff.compute_diff(filtered_assigns, previous_assigns)
changes = Diff.extract_changes(diff)
duration_since_previous =
if previous do
DateTime.diff(snapshot.timestamp, previous.timestamp, :millisecond)
end
# NEW: Run enrichers
enrichments = run_enrichers(filtered_assigns, snapshot, opts)
Map.merge(
%{
sequence: sequence,
event: snapshot.event_type,
timestamp: snapshot.timestamp,
view_module: snapshot.view_module,
key_state: key_state,
changes: changes,
duration_since_previous_ms: duration_since_previous
},
enrichments
)
end
# Run selected enrichers on assigns
defp run_enrichers(assigns, snapshot, opts) do
requested_enrichers = Keyword.get(opts, :enrichers, :all)
quick? = Keyword.get(opts, :quick, false)
enrichers =
case requested_enrichers do
:all ->
Registry.discover_enrichers()
names when is_list(names) ->
names
|> Enum.map(&Registry.get_enricher/1)
|> Enum.reject(&is_nil/1)
end
# Filter out expensive enrichers when quick mode is enabled
enrichers =
if quick? do
Enum.reject(enrichers, fn enricher ->
Enricher.get_cost(enricher) == :expensive
end)
else
enrichers
end
# Pass measurements through opts for enrichers that need them
measurements = Map.get(snapshot, :measurements, %{})
enricher_opts = Keyword.put(opts, :measurements, measurements)
Enum.reduce(enrichers, %{}, fn enricher, acc ->
enrichment = enricher.enrich(assigns, enricher_opts)
Map.merge(acc, enrichment)
end)
end
end