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/analyzers/ecto_query_analysis.ex
defmodule Excessibility.TelemetryCapture.Analyzers.EctoQueryAnalysis do
@moduledoc """
Analyzes Ecto query patterns across timeline events.
Replaces the old N+1 analyzer. Uses actual query data from the
ecto_queries enricher instead of just counting NotLoaded associations.
Detects:
- Excessive queries per event (>5 queries)
- N+1 patterns (multiple SELECTs on same table in one event)
- Slow individual queries (>100ms)
- Slow total query time per event (>500ms)
## Output
%{
findings: [
%{
severity: :critical,
message: "handle_event:load_items triggered 30 queries in 142ms...",
events: [1],
metadata: %{query_count: 30, total_ms: 142.0, pattern: :n_plus_one}
}
],
stats: %{total_queries: 30, total_query_ms: 142.0, max_queries_per_event: 30}
}
"""
@behaviour Excessibility.TelemetryCapture.Analyzer
@excessive_query_threshold 5
@n_plus_one_threshold 3
@slow_query_ms 100
@slow_total_ms 500
def name, do: :ecto_query_analysis
def default_enabled?, do: true
def requires_enrichers, do: [:ecto_queries]
def analyze(%{timeline: []}, _opts), do: %{findings: [], stats: %{}}
def analyze(%{timeline: timeline}, _opts) do
findings = Enum.flat_map(timeline, &analyze_event/1)
stats = calculate_stats(timeline)
%{findings: findings, stats: stats}
end
defp analyze_event(event) do
queries = Map.get(event, :ecto_queries, [])
query_count = length(queries)
total_ms = Map.get(event, :ecto_total_query_ms, 0)
detect_excessive_queries(event, query_count, total_ms) ++
detect_n_plus_one(event, queries) ++
detect_slow_queries(event, queries) ++
detect_slow_total(event, total_ms, query_count)
end
defp detect_excessive_queries(event, count, total_ms) when count > @excessive_query_threshold do
severity = if count > 10, do: :critical, else: :warning
[
%{
severity: severity,
message: "#{event.event} triggered #{count} queries in #{format_ms(total_ms)}",
events: [event.sequence],
metadata: %{query_count: count, total_ms: total_ms, pattern: :excessive}
}
]
end
defp detect_excessive_queries(_event, _count, _total_ms), do: []
defp detect_n_plus_one(event, queries) when length(queries) >= @n_plus_one_threshold do
queries
|> Enum.filter(&(&1.operation == :select))
|> Enum.group_by(& &1.source)
|> Enum.flat_map(fn {source, source_queries} ->
count = length(source_queries)
if count >= @n_plus_one_threshold do
total_ms = source_queries |> Enum.map(& &1.duration_ms) |> Enum.sum()
[
%{
severity: :critical,
message:
"#{count} of #{length(queries)} queries are SELECT on \"#{source}\" in #{event.event} (N+1 pattern) — consider preloading or batching",
events: [event.sequence],
metadata: %{
source: source,
count: count,
total_ms: Float.round(total_ms * 1.0, 2),
pattern: :n_plus_one
}
}
]
else
[]
end
end)
end
defp detect_n_plus_one(_event, _queries), do: []
defp detect_slow_queries(event, queries) do
Enum.flat_map(queries, fn query ->
if query.duration_ms > @slow_query_ms do
[
%{
severity: :warning,
message:
"Slow query on \"#{query.source}\" took #{format_ms(query.duration_ms)} in #{event.event} — consider caching or async loading",
events: [event.sequence],
metadata: %{
source: query.source,
duration_ms: query.duration_ms,
query: query.query,
pattern: :slow_query
}
}
]
else
[]
end
end)
end
defp detect_slow_total(event, total_ms, query_count) when total_ms > @slow_total_ms do
[
%{
severity: :critical,
message:
"#{event.event} ran #{query_count} queries totaling #{format_ms(total_ms)} — consider reducing query count or caching",
events: [event.sequence],
metadata: %{query_count: query_count, total_ms: total_ms, pattern: :slow_total}
}
]
end
defp detect_slow_total(_event, _total_ms, _count), do: []
defp calculate_stats(timeline) do
all_queries = Enum.flat_map(timeline, &Map.get(&1, :ecto_queries, []))
query_counts = Enum.map(timeline, &Map.get(&1, :ecto_query_count, 0))
if Enum.empty?(all_queries) do
%{}
else
total_ms = all_queries |> Enum.map(& &1.duration_ms) |> Enum.sum()
%{
total_queries: length(all_queries),
total_query_ms: Float.round(total_ms * 1.0, 2),
max_queries_per_event: Enum.max(query_counts, fn -> 0 end),
queries_by_source: all_queries |> Enum.group_by(& &1.source) |> Map.new(fn {k, v} -> {k, length(v)} end)
}
end
end
defp format_ms(ms) when is_float(ms), do: "#{Float.round(ms, 1)}ms"
defp format_ms(ms), do: "#{ms}ms"
end