NAME
Langertha::Skeid::UsageStore - Usage event sink — config normalization and backend factory
VERSION
version 0.003
DESCRIPTION
A usage store is where a Langertha::Skeid usage event goes to become durable. One event is written per forwarded request, including failures, and it is the billing unit — which is why swapping the backend must never change what an event means. See docs/adr/0004-usage-events-are-the-billing-unit.md.
This module owns the config shape and hands back the object that implements it: Langertha::Skeid::UsageStore::JsonLog or Langertha::Skeid::UsageStore::DBI.
The store contract
A store object answers backend (its name), prepare (create what it needs; called once when the store is configured, may croak), store($event), report(\%filters) and disconnect. A store that can hold events back also answers flush (write what it holds, answer like store with written and lost counts) and disconnect flushes before it lets go; Langertha::Skeid::UsageStore::DBI with flush_interval_ms is one. store and report report failure in their answer rather than dying: the request an event describes has already been served. The proxy logs a failed store at error level as a lost usage event, with the request id and the backend name -- never a DSN, a path or a key. An error text must not carry a secret either; the DBI store masks a password= from its DSN.
store returns { ok => 1 } (with an id where the backend has one), { ok => 1, queued => 1 } for an event held for a later flush -- whose failures the store reports itself, the proxy's answer having gone out by then -- or { ok => 0, error => $message }. report takes the filters since (an ISO 8601 UTC timestamp, compared as a string), api_key_id, model (the served model) and limit, and returns
{
ok => 1, enabled => 1, backend => 'jsonlog', since => '...',
db_path => '...', # DBI stores: the SQLite file, '' for postgresql
log_path => '...', # jsonlog: the event directory or file
totals => { requests, input_tokens, output_tokens, total_tokens, cached_tokens,
cache_write_tokens, tool_calls, total_cost_usd },
by_key => [ { api_key_id, requests, total_tokens, total_cost_usd }, ... ],
by_model => [ { model, requests, total_tokens, total_cost_usd }, ... ],
recent => [ { id, created_at, api_format, endpoint, api_key_id, model, requested_model,
node_id, status_code, ok, input_tokens, output_tokens, total_tokens,
cached_tokens, cache_write_tokens, tool_calls, cost_total_usd }, ... ],
}
or { ok => 0, enabled => 0, error => $message }. The breakdowns are ordered by cost, highest first; recent is newest first.
normalize_config
my $normalized = Langertha::Skeid::UsageStore->normalize_config($cfg, %opts);
Turns a user-supplied usage_store config into the canonical hashref Skeid keeps on its usage_store attribute. Backend selection is inference-first, because most configs name only one thing: an explicit backend wins, otherwise a sqlite path key, a dbi:Pg: DSN or a log_path decides. password_env reads the password from the environment so it never has to sit in the file.
default_sqlite_path supplies the path for a sqlite config that names none.
The keys it reads, per backend:
backend--jsonlog,sqliteorpostgresql(anything starting withjsonorpostgrescounts). Absent:sqlite_path,pathordb_pathmeans sqlite, adbi:Pg:dsnmeans postgresql,log_pathmeans jsonlog, and nothing at all means sqlite -- which then needs a path. Apathalone is therefore a sqlite file; a jsonlog store must say so.jsonlog --
log_path(orpath), required;modedirorfile, defaultdirwhen the path is an existing directory or ends in/, elsefile;fsync(default off).sqlite --
sqlite_path(orpath,db_path), required;schema_file(default: the shipped one);auto_migrate(default on);flush_interval_ms(default0, write each event at once; see "flush_interval_ms" in Langertha::Skeid::UsageStore::DBI).postgresql --
dsn, else one built fromhost(default127.0.0.1),port(5432) anddbnameordatabase(skeid);user;password, orpassword_envnaming the variable that holds it;schema_file;auto_migrate(default on);flush_interval_ms, as for sqlite.
Croaks on a config that is not a hashref, a missing path, an unknown backend, or a flush_interval_ms that is not a whole number of milliseconds.
for_config
my $store = Langertha::Skeid::UsageStore->for_config($normalized);
Builds the store object for a normalized config, or returns nothing when no backend is configured. Does not touch the filesystem or connect — call prepare for that, so that constructing a Skeid object never has a side effect on disk.
num
my $n = Langertha::Skeid::UsageStore::num($row->{total_tokens});
Numeric coercion that treats undef as zero. Reports sum columns that may be NULL on an empty table, so this is the one place that is allowed to be lax about it.
read_text_file
my $sql = Langertha::Skeid::UsageStore::read_text_file($path);
Slurps a file, dying with the path on failure.
SEE ALSO
Langertha::Skeid::UsageStore::JsonLog, Langertha::Skeid::UsageStore::DBI
"record_usage" in Langertha::Skeid, "usage_report" in Langertha::Skeid -- what writes and reads a store
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-skeid/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.