Documentation
¶
Overview ¶
Package usage provides usage tracking and logging functionality for the CLI Proxy API server. It includes plugins for monitoring API usage, token consumption, and other metrics to help with observability and billing purposes.
Index ¶
- func ComputeFingerprint(d FlatDetail) [20]byte
- func MigrateDataDirIfNeeded(configFilePath, newBaseDir string) error
- func MigrateLegacyToSQLite(ctx context.Context, store *Store, priceFn PriceFunc, baseDir string) error
- func ResolveDataDir(configured, configFilePath string) string
- func SetGlobalPersister(p *Persister)
- func SetStatisticsEnabled(enabled bool)
- type ArchivedDetail
- type EventFilters
- type FlatDetail
- type ImportHook
- type ImportResult
- type MergeResult
- type MigrationStatus
- type Parser
- type Persister
- func (p *Persister) BaseDir() string
- func (p *Persister) DBSize() int64
- func (p *Persister) DBSizeStatus() (sizeBytes int64, maxBytes int64, warningThresholdPct int, warning bool, ...)
- func (p *Persister) HasPricing() bool
- func (p *Persister) Import(data []byte) (ImportResult, error)
- func (p *Persister) IsReady() bool
- func (p *Persister) MigrationStatus(ctx context.Context) (MigrationStatus, error)
- func (p *Persister) Query(from, to time.Time, filters EventFilters, granularity string, ...) (QueryResult, error)
- func (p *Persister) QueryEvents(from, to time.Time, filters EventFilters, page, pageSize int, sortField string, ...) ([]FlatDetail, int, error)
- func (p *Persister) RecalculateCosts() (RecalculateCostsResult, error)
- func (p *Persister) Record(detail FlatDetail)
- func (p *Persister) Retention() config.UsageRetention
- func (p *Persister) Save()
- func (p *Persister) SchemaVersion(ctx context.Context) (string, error)
- func (p *Persister) SetPriceFunc(fn PriceFunc)
- func (p *Persister) SetRetention(r config.UsageRetention)
- func (p *Persister) Snapshot() StatisticsSnapshot
- func (p *Persister) SnapshotFiltered(from, to time.Time, filters EventFilters) StatisticsSnapshot
- func (p *Persister) Start(ctx context.Context)
- func (p *Persister) Stop()
- func (p *Persister) Trim()
- func (p *Persister) TrimPreview() TrimPreviewResult
- type PriceFunc
- type QueryResult
- type RecalculateCostsResult
- type RequestStatistics
- func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot, hooks ...ImportHook) MergeResult
- func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record)
- func (s *RequestStatistics) Snapshot() StatisticsSnapshot
- func (s *RequestStatistics) TrimDetails(cutoff time.Time) []ArchivedDetail
- type StatisticsSnapshot
- type Store
- func (s *Store) Close() error
- func (s *Store) DB() *sql.DB
- func (s *Store) Query(ctx context.Context, from, to time.Time, filters EventFilters, ...) (QueryResult, error)
- func (s *Store) QueryEvents(ctx context.Context, from, to time.Time, filters EventFilters, ...) ([]FlatDetail, int, error)
- func (s *Store) ResolveAPIKeyID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
- func (s *Store) ResolveAuthIndexID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
- func (s *Store) ResolveCredentialID(ctx context.Context, exec dbExec, source, authIndex string) (sql.NullInt64, error)
- func (s *Store) ResolveModelID(ctx context.Context, exec dbExec, name string) (int64, error)
- func (s *Store) ResolveProviderID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
- func (s *Store) ResolveSourceID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
- func (s *Store) RollupDay(ctx context.Context, dayStartNs int64) error
- func (s *Store) StartRollup(ctx context.Context) func()
- type TrimPreviewResult
- type Writer
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ComputeFingerprint ¶
func ComputeFingerprint(d FlatDetail) [20]byte
ComputeFingerprint produces a 20-byte SHA-1 over the canonical dedupKey payload. Field order MUST stay aligned with dedupKey() in logger_plugin.go so the same logical event arriving via v2 detail / v1 nested / frontend CSV / frontend JSON / /usage/export collapses onto one row through the events.fingerprint UNIQUE constraint.
Provider compatibility: when Provider is empty (legacy JSON imports, where the field never existed on disk) the payload format reverts to the v1 11- field layout so historical rows reproduce the same SHA-1 the JSON era produced. When Provider is set (live writes from the v2 LoggerPlugin) the 12-field layout is used so the same email speaking to two providers no longer dedupes to a single row.
SHA-1 is used as a fast 160-bit hash, not for security; collision risk for a well-formed dedup payload is negligible (birthday bound ≫ realistic event counts) and any duplicate would harmlessly produce INSERT OR IGNORE no-ops.
func MigrateDataDirIfNeeded ¶
MigrateDataDirIfNeeded moves an existing usage-data directory from any legacy location to the resolved new location when WRITABLE_PATH or install layout changed between restarts. Cross-filesystem moves fall back to copy + remove. Skips when paths match or when the new location already has summary.json / events.db.
func MigrateLegacyToSQLite ¶
func MigrateLegacyToSQLite(ctx context.Context, store *Store, priceFn PriceFunc, baseDir string) error
MigrateLegacyToSQLite imports every JSON file the v1/v2 era left behind in baseDir into the store. Old JSON files are left in place — events.db is the authoritative data source after migration, and meta.migrated_from_json guarantees subsequent restarts skip the rescan.
Idempotent: a successful run records meta.migrated_from_json, and subsequent invocations short-circuit immediately without scanning disk.
On parse failure the migration aborts with the failing file's path attached, the meta row is NOT written, and the caller can decide whether to retry or surface the failure.
func ResolveDataDir ¶
ResolveDataDir picks the usage data directory: configured > WRITABLE_PATH/usage-data > config dir/usage-data.
func SetGlobalPersister ¶
func SetGlobalPersister(p *Persister)
SetGlobalPersister registers the active Persister for the LoggerPlugin to use.
func SetStatisticsEnabled ¶
func SetStatisticsEnabled(enabled bool)
SetStatisticsEnabled toggles whether in-memory statistics are recorded.
Types ¶
type ArchivedDetail ¶
ArchivedDetail pairs a trimmed requestDetail with its API and model names for archival.
type EventFilters ¶
type EventFilters struct {
Model string
Source string
APIKey string
Status string // "success", "failure", or "" (all)
Search string
}
EventFilters specifies optional dimensions the dashboard layers on top of a time-range query. Multi-value filters are expressed as comma-separated strings to match the on-the-wire form sent by the management UI.
type FlatDetail ¶
type FlatDetail struct {
Timestamp time.Time `json:"timestamp"`
Model string `json:"model"`
Source string `json:"source"` // credential file name
AuthIndex string `json:"auth_index"` // alternate credential identifier
APIKey string `json:"api_key"` // CPA access key used by the client
Provider string `json:"provider"` // upstream vendor (claude/codex/gemini/...)
Tokens tokenStats `json:"tokens"`
Failed bool `json:"failed"`
}
FlatDetail is the canonical denormalised request record passed between the LoggerPlugin, the persister, and the SQLite store. Each field maps to a dim_* table or directly to the events row. Source and AuthIndex are kept separate so /usage/events can still answer "which credential file" while dim_credential collapses them into one user-visible label.
Provider was added in schema v2 to disambiguate credentials that share a source string across different upstream vendors (Claude OAuth and Codex OAuth using the same Google email being the canonical example). Empty string means "unknown / pre-v2" — fingerprint() and the writers treat that as identical to the historical NULL so legacy rows imported from the JSON era keep their original deduplication identity.
type ImportHook ¶
type ImportHook func(detail FlatDetail)
ImportHook is called for each successfully imported detail with the resolved fields.
type ImportResult ¶
type ImportResult struct {
Added int64
Skipped int64
Format string
EarliestDayNs int64
LatestDayNs int64
HasDayRange bool
}
ImportResult mirrors RequestStatistics.MergeSnapshot's MergeResult so the HTTP handler can keep the same response shape.
func ImportBytes ¶
func ImportBytes(ctx context.Context, store *Store, priceFn PriceFunc, data []byte) (ImportResult, error)
ImportBytes is the synchronous entry point used by HTTP handlers. It parses the payload, then imports every detail in a single transaction so the caller sees consistent counts on success.
func ImportFile ¶
func ImportFile(ctx context.Context, store *Store, priceFn PriceFunc, path string) (ImportResult, error)
ImportFile reads, parses and imports a single file. Used by migrate_legacy and could be exposed via a future CLI subcommand.
type MergeResult ¶
type MigrationStatus ¶
MigrationStatus reports the persisted migration marker. From=="" means no migration has been recorded yet.
func CheckMigrationStatus ¶
func CheckMigrationStatus(ctx context.Context, store *Store) (MigrationStatus, error)
CheckMigrationStatus reads the meta row that flags a completed JSON import.
type Parser ¶
type Parser interface {
Detect(data []byte) bool
Parse(data []byte) ([]FlatDetail, error)
}
Parser describes one importable on-disk format. ParseAny tries Detect on each parser in priority order; the first match wins.
type Persister ¶
type Persister struct {
// contains filtered or unexported fields
}
Persister is the SQLite-backed coordinator for usage data. It owns the Store + Writer + rollup goroutine, exposes a stable surface to the rest of the codebase, and hides the schema / WAL / dim_table details. Public method signatures match the JSON-era persister 1:1 so handler / SDK / test code keeps compiling unchanged.
func NewPersister ¶
func NewPersister(baseDir string, retention config.UsageRetention) *Persister
NewPersister constructs a Persister rooted at baseDir. A zero-valued Days is filled with the default (-1, disabled).
func (*Persister) DBSize ¶
DBSize returns the on-disk size of events.db plus its WAL/SHM sidecars. Returns 0 when the store is not yet open or the file does not exist.
func (*Persister) DBSizeStatus ¶
func (*Persister) HasPricing ¶
HasPricing reports whether a price lookup function has been configured.
func (*Persister) Import ¶
func (p *Persister) Import(data []byte) (ImportResult, error)
Import reads a payload (any of the supported formats), inserts the events, and returns counts. Used by /usage/import.
func (*Persister) IsReady ¶
IsReady reports whether Start successfully opened the SQLite store. Callers (sdk/cliproxy/service.go) should branch on this immediately after Start: when not ready, they MUST call SetGlobalPersister(nil) so the LoggerPlugin's fallback to in-memory RequestStatistics kicks in. Without that, Record() silently drops every event behind the nil-writer guard.
func (*Persister) MigrationStatus ¶
func (p *Persister) MigrationStatus(ctx context.Context) (MigrationStatus, error)
MigrationStatus returns the persisted legacy JSON migration marker.
func (*Persister) Query ¶
func (p *Persister) Query( from, to time.Time, filters EventFilters, granularity string, includeGroups bool, ) (QueryResult, error)
Query is the read-side entry point used by /usage/summary.
func (*Persister) QueryEvents ¶
func (p *Persister) QueryEvents( from, to time.Time, filters EventFilters, page, pageSize int, sortField string, sortDesc bool, ) ([]FlatDetail, int, error)
QueryEvents serves /usage/events with SQL-native pagination.
func (*Persister) RecalculateCosts ¶
func (p *Persister) RecalculateCosts() (RecalculateCostsResult, error)
RecalculateCosts re-prices every event using the current PriceFunc, then rebuilds hour_bucket and day_bucket from the updated events table. Heavy SQL — this is intended for occasional admin-triggered recalculation, not per-request use.
func (*Persister) Record ¶
func (p *Persister) Record(detail FlatDetail)
Record submits a usage record to the writer goroutine. Non-blocking — the writer drops on overflow rather than back-pressuring the request path.
func (*Persister) Retention ¶
func (p *Persister) Retention() config.UsageRetention
Retention returns the current retention configuration.
func (*Persister) Save ¶
func (p *Persister) Save()
Save flushes the WAL to the main database file. Non-blocking, no guarantee writers are quiesced — the WAL checkpoint is "PASSIVE" so it completes only as much as it can without forcing other connections to wait.
func (*Persister) SchemaVersion ¶
SchemaVersion returns the current schema_version meta value.
func (*Persister) SetPriceFunc ¶
SetPriceFunc registers (or replaces) the pricing function.
func (*Persister) SetRetention ¶
func (p *Persister) SetRetention(r config.UsageRetention)
SetRetention updates retention. Takes effect on the next Trim invocation.
func (*Persister) Snapshot ¶
func (p *Persister) Snapshot() StatisticsSnapshot
Snapshot reconstructs a v1-style StatisticsSnapshot from the SQLite tables so /usage/export keeps returning the same response shape the UI knows how to consume. Heavy on memory for big datasets — same as the JSON-era path it replaces, but bounded by the data the user already has.
func (*Persister) SnapshotFiltered ¶
func (p *Persister) SnapshotFiltered(from, to time.Time, filters EventFilters) StatisticsSnapshot
SnapshotFiltered reconstructs a v1-style StatisticsSnapshot scoped by the given time range and event filters. Zero-valued from/to means "no time bound"; an empty EventFilters means "no dimension filter". The filtered version is what /usage/export uses when the user wants to export only the data they're currently viewing in the dashboard.
func (*Persister) Start ¶
Start opens the SQLite store, performs the legacy-JSON migration when needed, then launches the writer goroutine and the day-bucket rollup loop. Failure to open the store is logged with a clear "degraded mode" warning; the caller MUST check IsReady() afterwards and fall back to the in-memory RequestStatistics path (by calling SetGlobalPersister(nil)) when not ready, otherwise records will be silently dropped via Record's nil-writer guard.
func (*Persister) Stop ¶
func (p *Persister) Stop()
Stop drains the writer, halts the rollup loop, and closes the store. Idempotent.
func (*Persister) Trim ¶
func (p *Persister) Trim()
Trim deletes events / hour_bucket / day_bucket rows older than retention.
func (*Persister) TrimPreview ¶
func (p *Persister) TrimPreview() TrimPreviewResult
TrimPreview reports what Trim would delete without actually deleting it.
type PriceFunc ¶
PriceFunc returns the pricing for a model: prices are in USD per 1M tokens.
func BuildModelPriceFunc ¶
BuildModelPriceFunc creates a cached PriceFunc that reads model prices from model-prices.json. When the file is missing, empty, or invalid we return nil so callers do not pretend pricing is available and accidentally emit flat-zero cost charts.
type QueryResult ¶
type QueryResult struct {
Totals summaryTotals
ByModel map[string]*summaryModelStats
ByCredential map[string]*summaryCredentialStats
ByAPIKey map[string]*summaryAPIKeyStats
TimeSeries []timePoint
TimeSeriesByModel map[string][]timePoint
TimeSeriesByCredential map[string][]timePoint
TimeSeriesByAPIKey map[string][]timePoint
}
QueryResult is the read-side payload Query produces and the management handler converts into JSON. It mirrors what summary.go used to expose so /usage/summary's response shape doesn't change with the SQLite swap.
type RecalculateCostsResult ¶
type RecalculateCostsResult struct {
RecalculatedDays int `json:"recalculated_days"`
TotalCost float64 `json:"total_cost"`
AlreadyRunning bool `json:"already_running,omitempty"`
}
RecalculateCostsResult holds the outcome of a cost recalculation.
type RequestStatistics ¶
type RequestStatistics struct {
// contains filtered or unexported fields
}
RequestStatistics maintains aggregated request metrics in memory. This is the legacy storage used when no Persister is configured.
func GetRequestStatistics ¶
func GetRequestStatistics() *RequestStatistics
GetRequestStatistics returns the shared statistics store.
func (*RequestStatistics) MergeSnapshot ¶
func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot, hooks ...ImportHook) MergeResult
MergeSnapshot merges an exported statistics snapshot into the current store. Existing data is preserved and duplicate request details are skipped. An optional hook is called for each newly added detail (after dedup, before return).
func (*RequestStatistics) Record ¶
func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record)
Record ingests a new usage record and updates the aggregates (legacy path).
func (*RequestStatistics) Snapshot ¶
func (s *RequestStatistics) Snapshot() StatisticsSnapshot
Snapshot returns a copy of the aggregated metrics for external consumption.
func (*RequestStatistics) TrimDetails ¶
func (s *RequestStatistics) TrimDetails(cutoff time.Time) []ArchivedDetail
TrimDetails removes all details older than the cutoff time from memory. Aggregate counters are preserved. Trimmed records are returned for archival.
type StatisticsSnapshot ¶
type StatisticsSnapshot struct {
TotalRequests int64 `json:"total_requests"`
SuccessCount int64 `json:"success_count"`
FailureCount int64 `json:"failure_count"`
TotalTokens int64 `json:"total_tokens"`
APIs map[string]aPISnapshot `json:"apis"`
RequestsByDay map[string]int64 `json:"requests_by_day"`
RequestsByHour map[string]int64 `json:"requests_by_hour"`
TokensByDay map[string]int64 `json:"tokens_by_day"`
TokensByHour map[string]int64 `json:"tokens_by_hour"`
}
StatisticsSnapshot represents an immutable view of the aggregated metrics.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store owns the *sql.DB plus dimension-name → id caches.
Cache strategy: every dim is INSERT-OR-IGNORE on first write and SELECT-d back to learn the assigned id, so id stability is guaranteed even when two goroutines race on the same name. Once seen, the (name, id) pair is pinned in the local sync.Map so steady-state writes only need a single SQL roundtrip (the events INSERT). The cache never invalidates because dim ids never change — rows are never deleted (FK ON DELETE RESTRICT) and ROWIDs are stable.
func OpenStore ¶
OpenStore opens (or creates) the SQLite database, applies PRAGMAs, runs the schema (idempotent thanks to IF NOT EXISTS), and validates schema_version.
Connection limits: SQLite serialises writes regardless of pool size, so a large pool just inflates context overhead. Eight connections gives readers breathing room without wasting memory.
func (*Store) Query ¶
func (s *Store) Query( ctx context.Context, from, to time.Time, filters EventFilters, granularity string, includeGroups bool, ) (QueryResult, error)
Query implements /usage/summary against the bucket tables. Routing rule: span ≤ 90d → hour_bucket; span > 90d → day_bucket. granularity controls time-series granularity but never falls back to scanning events.
func (*Store) QueryEvents ¶
func (s *Store) QueryEvents( ctx context.Context, from, to time.Time, filters EventFilters, page, pageSize int, sortField string, desc bool, ) ([]FlatDetail, int, error)
QueryEvents serves /usage/events. Pagination is SQL-native (LIMIT/OFFSET) so large windows don't pull every row into memory the way DetailStore.QueryRange did — that "read everything then slice" pattern was the OOM accomplice in the JSON era and explicitly cannot be reintroduced.
func (*Store) ResolveAPIKeyID ¶
func (s *Store) ResolveAPIKeyID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
ResolveAPIKeyID returns the dim_api_key id, or a NULL when name is blank.
func (*Store) ResolveAuthIndexID ¶
func (s *Store) ResolveAuthIndexID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
ResolveAuthIndexID returns the dim_auth_index id, or a NULL when name is blank.
func (*Store) ResolveCredentialID ¶
func (s *Store) ResolveCredentialID(ctx context.Context, exec dbExec, source, authIndex string) (sql.NullInt64, error)
ResolveCredentialID returns the dim_credential id for COALESCE(source, auth_index). Both inputs blank → NULL credential, matching summary.go::credKey semantics.
func (*Store) ResolveModelID ¶
ResolveModelID returns the dim_model id, falling back to "unknown" when the caller passes an empty model name. Model is the only dim that is NOT NULL on the events table, so we never return NullInt64 here.
func (*Store) ResolveProviderID ¶
func (s *Store) ResolveProviderID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
ResolveProviderID returns the dim_provider id, or a NULL when name is blank. Old v1/v2 JSON imports never carried provider, so a blank string is the expected steady-state for backfilled rows — the NULL surfaces in /usage queries as the "(unknown)" bucket so the dimension chart doesn't drop them.
func (*Store) ResolveSourceID ¶
func (s *Store) ResolveSourceID(ctx context.Context, exec dbExec, name string) (sql.NullInt64, error)
ResolveSourceID returns the dim_source id, or a NULL when name is blank.
func (*Store) RollupDay ¶
RollupDay recomputes day_bucket rows for the day starting at dayStartNs from the corresponding hour_bucket rows. Idempotent: re-running with the same day produces identical state, so missed runs after a crash are self-healing.
The UPSERT uses "SET col = excluded.col" rather than "+= excluded.col" because excluded already holds the recomputed total — adding would double count on the second pass.
func (*Store) StartRollup ¶
StartRollup launches the day-bucket rollup goroutine and returns a stop function. The first catch-up pass still runs synchronously before this function returns, but it now resumes from meta.day_bucket_rollup_watermark_ns instead of re-scanning every historical day on every startup. The hourly ticker keeps re-rolling yesterday/today in the background and advances the watermark as new closed days appear.
type TrimPreviewResult ¶
type TrimPreviewResult struct {
FilesCount int `json:"files_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
DateRange *trimDateRange `json:"date_range,omitempty"`
Details []cleanPreviewFile `json:"details"`
}
TrimPreviewResult mirrors the JSON-era preview shape so /usage/trim/preview continues to work without UI changes.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer ingests FlatDetail records and persists them in batched transactions against a Store. A single goroutine owns all SQL writes so we never collide with SQLite's "one writer at a time" rule.
func NewWriter ¶
NewWriter wires a writer to a store. priceFn may be nil — events still land on disk, just with cost_micro = 0 until pricing is configured.
func (*Writer) SetPriceFunc ¶
SetPriceFunc lets callers swap pricing at runtime (e.g. after a model catalog refresh) without restarting the writer.
func (*Writer) Start ¶
Start launches the writer goroutine. Calling Start more than once is a no-op so callers don't need their own guard.
func (*Writer) Stop ¶
func (w *Writer) Stop()
Stop closes the input channel, lets the loop drain anything in-flight, then waits for shutdown. Idempotent.
func (*Writer) Submit ¶
func (w *Writer) Submit(detail FlatDetail)
Submit enqueues a record for persistence. Non-blocking: when the channel is saturated the record is counted (and warning-logged with throttling) rather than back-pressuring the request path. Usage tracking is observability — a few dropped records is preferable to slowing production traffic.