Current section
Files
Jump to
Current section
Files
lib/vial/stats.ex
defmodule Vial.Stats do
@moduledoc """
Context module for dashboard statistics and metrics.
"""
import Ecto.Query
alias Vial.Evals.SuiteRun
alias Vial.Runs.{Run, RunResult}
@doc """
Returns total number of test runs (suite runs + prompt runs).
"""
@spec total_runs() :: integer()
def total_runs do
suite_runs = from(sr in SuiteRun, select: count(sr.id)) |> repo().one()
prompt_runs = from(r in Run, select: count(r.id)) |> repo().one()
suite_runs + prompt_runs
end
@doc """
Returns tuple of {total_passed, total_failed} test counts.
"""
@spec test_totals() :: {integer(), integer()}
def test_totals do
query =
from sr in SuiteRun,
select: %{
total_passed: sum(sr.passed),
total_failed: sum(sr.failed)
}
result = repo().one(query)
passed = to_integer(result.total_passed)
failed = to_integer(result.total_failed)
{passed, failed}
end
defp to_integer(nil), do: 0
defp to_integer(%Decimal{} = value), do: Decimal.to_integer(value)
defp to_integer(value) when is_integer(value), do: value
@doc """
Calculates success rate percentage from passed and failed counts.
"""
@spec success_rate(integer(), integer()) :: float()
def success_rate(passed, failed) do
total = passed + failed
if total > 0, do: Float.round(passed / total * 100, 1), else: 0.0
end
@doc """
Returns average latency in milliseconds across all run results.
"""
@spec avg_latency() :: number()
def avg_latency do
query =
from rr in RunResult,
where: not is_nil(rr.latency_ms),
select: avg(rr.latency_ms)
case repo().one(query) do
nil ->
0
%Decimal{} = latency ->
latency |> Decimal.to_float() |> Float.round(0)
latency when is_float(latency) ->
Float.round(latency, 0)
latency ->
latency
end
end
@doc """
Returns recent activity combining both Run and SuiteRun records.
Fetches and normalizes both types of runs into a common format,
sorted by insertion time descending.
"""
@spec list_recent_activity(integer()) :: [map()]
def list_recent_activity(limit \\ 10) do
# Fetch recent prompt runs
prompt_runs =
from(r in Run,
order_by: [desc: r.inserted_at],
limit: ^limit,
preload: [prompt_version: :prompt, run_results: :provider]
)
|> repo().all()
|> Enum.map(&normalize_run/1)
# Fetch recent suite runs
suite_runs =
from(sr in SuiteRun,
order_by: [desc: sr.inserted_at],
limit: ^limit,
preload: [suite: :prompt, prompt_version: :prompt, provider: []]
)
|> repo().all()
|> Enum.map(&normalize_suite_run/1)
# Combine and sort by inserted_at
(prompt_runs ++ suite_runs)
|> Enum.sort_by(& &1.inserted_at, {:desc, DateTime})
|> Enum.take(limit)
end
defp normalize_run(run) do
%{
id: run.id,
type: :run,
name: run.name || "Unnamed Run",
prompt_name:
run.prompt_version && run.prompt_version.prompt && run.prompt_version.prompt.name,
providers_count: length(run.run_results),
cost: calculate_run_cost(run.run_results),
inserted_at: run.inserted_at,
path: "/runs/#{run.id}"
}
end
def normalize_suite_run(suite_run) do
%{
id: suite_run.id,
type: :suite_run,
name: suite_run.suite.name,
prompt_name:
suite_run.prompt_version && suite_run.prompt_version.prompt &&
suite_run.prompt_version.prompt.name,
providers_count: 1,
cost:
if suite_run.avg_cost_usd do
Decimal.to_float(suite_run.avg_cost_usd)
else
0.0
end,
passed: suite_run.passed,
failed: suite_run.failed,
inserted_at: suite_run.inserted_at,
path: "/suites/#{suite_run.suite_id}"
}
end
defp calculate_run_cost(run_results) do
Enum.reduce(run_results, 0.0, fn result, acc ->
if result.cost_usd, do: acc + result.cost_usd, else: acc
end)
end
defp repo do
Application.get_env(:vial, :repo) ||
raise """
Vial repo not configured.
Add to your config:
config :vial, repo: YourApp.Repo
"""
end
end