symbiont_ex/lib/symbiont/ledger.ex

122 lines
2.8 KiB
Elixir

defmodule Symbiont.Ledger do
@moduledoc """
Append-only JSONL ledger for tracking all inference calls.
Every call to Claude gets logged with model, tokens, cost, timing.
This is the source of truth for cost tracking and billing analysis.
"""
use GenServer
require Logger
# -- Client API --
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Append a ledger entry. Returns :ok."
def append(entry) when is_map(entry) do
GenServer.cast(__MODULE__, {:append, entry})
end
@doc "Read the last `n` ledger entries."
def recent(n \\ 50) do
GenServer.call(__MODULE__, {:recent, n})
end
@doc "Compute aggregate stats across the full ledger."
def stats do
GenServer.call(__MODULE__, :stats)
end
# -- Server Callbacks --
@impl true
def init(opts) do
data_dir = Keyword.fetch!(opts, :data_dir)
path = Path.join(data_dir, "ledger.jsonl")
unless File.exists?(path), do: File.write!(path, "")
{:ok, %{path: path}}
end
@impl true
def handle_cast({:append, entry}, state) do
entry_with_ts =
entry
|> Map.put_new(:timestamp, DateTime.utc_now() |> DateTime.to_iso8601())
line = Jason.encode!(entry_with_ts) <> "\n"
File.write!(state.path, line, [:append])
Logger.info("Ledger entry: model=#{entry[:model]} cost=$#{entry[:estimated_cost_usd]}")
{:noreply, state}
end
@impl true
def handle_call({:recent, n}, _from, state) do
entries =
state.path
|> File.stream!()
|> Stream.reject(&(&1 == "\n"))
|> Enum.to_list()
|> Enum.take(-n)
|> Enum.map(&Jason.decode!/1)
{:reply, entries, state}
end
@impl true
def handle_call(:stats, _from, state) do
entries =
state.path
|> File.stream!()
|> Stream.reject(&(&1 == "\n"))
|> Enum.map(&Jason.decode!/1)
total_calls = length(entries)
total_cost = sum_costs(entries)
by_model =
entries
|> Enum.group_by(& &1["model"])
|> Map.new(fn {model, group} ->
{model, %{"calls" => length(group), "cost" => sum_costs(group)}}
end)
by_date =
entries
|> Enum.group_by(fn e ->
e["timestamp"] |> String.slice(0, 10)
end)
|> Map.new(fn {date, group} ->
{date, %{"calls" => length(group), "cost" => sum_costs(group)}}
end)
result = %{
"total_calls" => total_calls,
"total_cost_estimated_usd" => total_cost,
"by_model" => by_model,
"by_date" => by_date
}
{:reply, result, state}
end
# -- Private --
defp sum_costs(entries) do
entries
|> Enum.reduce(0.0, fn entry, acc ->
acc + to_float(entry["estimated_cost_usd"])
end)
|> Float.round(4)
end
defp to_float(nil), do: 0.0
defp to_float(n) when is_float(n), do: n
defp to_float(n) when is_integer(n), do: n * 1.0
defp to_float(_), do: 0.0
end