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, sqlite or postgresql (anything starting with json or postgres counts). Absent: sqlite_path, path or db_path means sqlite, a dbi:Pg: dsn means postgresql, log_path means jsonlog, and nothing at all means sqlite -- which then needs a path. A path alone is therefore a sqlite file; a jsonlog store must say so.

  • jsonlog -- log_path (or path), required; mode dir or file, default dir when the path is an existing directory or ends in /, else file; fsync (default off).

  • sqlite -- sqlite_path (or path, db_path), required; schema_file (default: the shipped one); auto_migrate (default on); flush_interval_ms (default 0, write each event at once; see "flush_interval_ms" in Langertha::Skeid::UsageStore::DBI).

  • postgresql -- dsn, else one built from host (default 127.0.0.1), port (5432) and dbname or database (skeid); user; password, or password_env naming 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

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.