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