Current section

Files

Jump to
excessibility lib telemetry_capture analyzers assign_diff.ex
Raw

lib/telemetry_capture/analyzers/assign_diff.ex

defmodule Excessibility.TelemetryCapture.Analyzers.AssignDiff do
@moduledoc """
Detects large assigns that get re-diffed frequently.
When a large assign (>5KB) appears in the changes map for >50% of events,
it's being sent over the wire repeatedly — a common LiveView performance
foot-gun.
## Thresholds
- Warning: assign >5KB, diffed in >50% of events
- Critical: assign >20KB, diffed in >50% of events
## Output
%{
findings: [
%{
severity: :warning,
message: "`current_user` is 12 KB and was re-diffed in 5/7 events",
events: [2, 3, 4, 6, 7],
metadata: %{assign_name: :current_user, size_bytes: 12400, diff_count: 5, total_events: 7}
}
],
stats: %{assign_diff_ratios: %{current_user: 0.71, filter: 0.14}}
}
"""
@behaviour Excessibility.TelemetryCapture.Analyzer
@size_warning_threshold 5_000
@size_critical_threshold 20_000
@diff_ratio_threshold 0.5
def name, do: :assign_diff
def default_enabled?, do: true
def requires_enrichers, do: [:assign_sizes]
def analyze(%{timeline: []}, _opts), do: %{findings: [], stats: %{}}
def analyze(%{timeline: timeline}, _opts) do
diffable_events = Enum.filter(timeline, &Map.has_key?(&1, :assign_sizes))
if length(diffable_events) < 2 do
%{findings: [], stats: %{}}
else
assign_names = discover_assigns(diffable_events)
diff_ratios = calculate_diff_ratios(assign_names, diffable_events)
avg_sizes = calculate_avg_sizes(assign_names, diffable_events)
findings = build_findings(assign_names, diff_ratios, avg_sizes, diffable_events)
%{
findings: findings,
stats: %{assign_diff_ratios: diff_ratios}
}
end
end
defp discover_assigns(events) do
events
|> Enum.flat_map(fn event ->
event |> Map.get(:assign_sizes, %{}) |> Map.keys()
end)
|> Enum.uniq()
end
defp changes_for(event), do: Map.get(event, :changes) || %{}
defp has_changes?(event), do: map_size(changes_for(event)) > 0
defp changed_assign?(event, name), do: Map.has_key?(changes_for(event), name)
defp calculate_diff_ratios(assign_names, events) do
events_with_changes = Enum.filter(events, &has_changes?/1)
total = length(events_with_changes)
if total == 0 do
%{}
else
Map.new(assign_names, fn name ->
diff_count = Enum.count(events_with_changes, &changed_assign?(&1, name))
{name, diff_count / total}
end)
end
end
defp calculate_avg_sizes(assign_names, events) do
Map.new(assign_names, fn name ->
sizes =
events
|> Enum.map(&get_in(&1, [:assign_sizes, name]))
|> Enum.reject(&is_nil/1)
avg = if Enum.empty?(sizes), do: 0, else: div(Enum.sum(sizes), length(sizes))
{name, avg}
end)
end
defp build_findings(assign_names, diff_ratios, avg_sizes, events) do
events_with_changes = Enum.filter(events, &has_changes?/1)
total = length(events_with_changes)
assign_names
|> Enum.filter(&flaggable_assign?(&1, diff_ratios, avg_sizes))
|> Enum.map(&build_assign_finding(&1, diff_ratios, avg_sizes, events_with_changes, total))
end
defp flaggable_assign?(name, diff_ratios, avg_sizes) do
Map.get(diff_ratios, name, 0) >= @diff_ratio_threshold and
Map.get(avg_sizes, name, 0) >= @size_warning_threshold
end
defp build_assign_finding(name, diff_ratios, avg_sizes, events_with_changes, total) do
avg_size = Map.get(avg_sizes, name, 0)
ratio = Map.get(diff_ratios, name, 0)
diff_sequences =
events_with_changes
|> Enum.filter(&changed_assign?(&1, name))
|> Enum.map(& &1.sequence)
diff_count = length(diff_sequences)
severity = if avg_size >= @size_critical_threshold, do: :critical, else: :warning
suggestion =
if severity == :critical,
do: " — consider extracting to a LiveComponent or reducing struct size",
else: " — consider reducing struct size or using a LiveComponent"
%{
severity: severity,
message: "`#{name}` is #{format_bytes(avg_size)} and was re-diffed in #{diff_count}/#{total} events#{suggestion}",
events: diff_sequences,
metadata: %{
assign_name: name,
size_bytes: avg_size,
diff_count: diff_count,
total_events: total,
diff_ratio: Float.round(ratio * 1.0, 2)
}
}
end
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 1)} MB"
end