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.Filter @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 %{ 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 } end end