Current section

Files

Jump to
excessibility lib telemetry_capture analyzers hypothesis.ex
Raw

lib/telemetry_capture/analyzers/hypothesis.ex

defmodule Excessibility.TelemetryCapture.Analyzers.Hypothesis do
@moduledoc """
Generates hypotheses about likely root causes.
Analyzes patterns across enricher data to suggest investigation paths.
Designed to help LLMs and developers focus debugging efforts.
Not enabled by default - run with `--analyze=hypothesis`.
## Output
%{
findings: [
%{
severity: :info,
message: "Memory grew 5.2x. Likely cause: 'items' list is growing unbounded.",
events: [],
metadata: %{
growth_factor: 5.2,
investigation_steps: [
"Check which assigns are growing between events",
"Look for lists that accumulate without limit",
"Consider pagination or windowing for large datasets"
]
}
}
],
stats: %{hypothesis_count: 1}
}
"""
@behaviour Excessibility.TelemetryCapture.Analyzer
def name, do: :hypothesis
def default_enabled?, do: false
def analyze(%{timeline: []}, _opts) do
%{findings: [], stats: %{hypothesis_count: 0}}
end
def analyze(%{timeline: timeline}, _opts) do
hypotheses =
check_memory_growth(timeline) ++
check_query_growth(timeline) ++
check_list_growth(timeline)
%{
findings: hypotheses,
stats: %{hypothesis_count: length(hypotheses)}
}
end
defp check_memory_growth(timeline) do
sizes =
timeline
|> Enum.map(&Map.get(&1, :total_memory, 0))
|> Enum.filter(&(&1 > 0))
check_memory_growth_sizes(sizes, timeline)
end
defp check_memory_growth_sizes(sizes, _timeline) when length(sizes) < 2, do: []
defp check_memory_growth_sizes(sizes, timeline) do
first = List.first(sizes)
last = List.last(sizes)
growth = last / max(first, 1)
if growth > 3 do
build_memory_finding(growth, timeline)
else
[]
end
end
defp build_memory_finding(growth, timeline) do
list_culprit = find_growing_list(timeline)
message =
if list_culprit do
"Memory grew #{Float.round(growth, 1)}x. Likely cause: '#{list_culprit}' list is growing unbounded."
else
"Memory grew #{Float.round(growth, 1)}x. Check for accumulating data in assigns."
end
[
%{
severity: :info,
message: message,
events: [],
metadata: %{
growth_factor: growth,
investigation_steps: [
"Check which assigns are growing between events",
"Look for lists that accumulate without limit",
"Consider pagination or windowing for large datasets"
]
}
}
]
end
defp check_query_growth(timeline) do
counts =
timeline
|> Enum.map(&Map.get(&1, :query_records_loaded, 0))
|> Enum.filter(&(&1 > 0))
if length(counts) >= 2 and List.last(counts) > List.first(counts) * 5 do
[
%{
severity: :info,
message: "Query record count grew significantly. Possible N+1 - consider preload/join.",
events: [],
metadata: %{
investigation_steps: [
"Check Ecto queries in handle_event callbacks",
"Use Repo.preload for associations",
"Consider Ecto.Query.join for related data"
]
}
}
]
else
[]
end
end
defp check_list_growth(timeline) do
with first when not is_nil(first) <- List.first(timeline),
last when not is_nil(last) <- List.last(timeline) do
find_growing_lists_finding(first, last)
else
_ -> []
end
end
defp find_growing_lists_finding(first, last) do
first_lists = Map.get(first, :list_sizes, %{})
last_lists = Map.get(last, :list_sizes, %{})
growing = find_growing_lists(first_lists, last_lists)
case Enum.max_by(growing, fn {_k, s} -> s end, fn -> nil end) do
{key, size} ->
[
%{
severity: :info,
message: "List '#{key}' grew to #{size} items. Consider pagination.",
events: [],
metadata: %{list_name: key, final_size: size}
}
]
nil ->
[]
end
end
defp find_growing_list(timeline) do
with first when not is_nil(first) <- List.first(timeline),
last when not is_nil(last) <- List.last(timeline) do
first_lists = Map.get(first, :list_sizes, %{})
last_lists = Map.get(last, :list_sizes, %{})
growing = find_growing_lists(first_lists, last_lists)
case Enum.max_by(growing, fn {_key, size} -> size end, fn -> nil end) do
{key, _size} -> key
nil -> nil
end
end
end
defp find_growing_lists(first_lists, last_lists) do
Enum.filter(last_lists, fn {key, size} ->
prev_size = Map.get(first_lists, key, 0)
prev_size > 0 and size > prev_size * 2
end)
end
end