Exporter¶
The Node.js exporter (@tommasomarchionni/omp-usage-exporter) imports the JSONL event files written by the plugin, stores them in SQLite and exposes Prometheus metrics.
How it works¶
- Continuous import: an import cycle runs at startup and then every
--poll-interval-ms(default 5 s). On filesystems that support it,fs.watchtriggers a cycle shortly after a file changes. Polling is always active becausefs.watchis unreliable on network filesystems and some containers (--no-watchdisables the watcher). - Incremental reads: a per-file cursor stores the byte offset after the last processed line, so each cycle reads only new bytes.
- Atomic batches: events, aggregates, invalid-record counters and the cursor are committed in one SQLite transaction (500 lines per batch).
- Metrics at scrape time:
/metricsreads the aggregates from SQLite on every scrape. Counters always match the database, survive restarts and are never double counted. - Graceful shutdown: on SIGINT/SIGTERM the exporter stops HTTP, finishes the in-flight batch, checkpoints and closes SQLite. See Shutdown.
Command line¶
omp-usage-exporter [options]
--events-dir <path> directory containing event JSONL files [env OMP_USAGE_EVENTS_DIR]
--db-path <path> SQLite database path [env OMP_USAGE_DB_PATH]
--listen <addr> host:port, [ipv6]:port or :port [env OMP_USAGE_LISTEN]
--max-line-length <bytes> maximum JSONL line length [env OMP_USAGE_MAX_LINE_LENGTH]
--log-level <level> debug | info | warn | error [env OMP_USAGE_LOG_LEVEL]
--max-label-cardinality <n> maximum exported (provider, model) pairs [env OMP_USAGE_MAX_LABEL_CARDINALITY]
--poll-interval-ms <ms> interval between import cycles [env OMP_USAGE_POLL_INTERVAL_MS]
--shutdown-timeout-ms <ms> graceful shutdown budget [env OMP_USAGE_SHUTDOWN_TIMEOUT_MS]
--retention-days <days> delete fully imported, idle event files [env OMP_USAGE_EXPORTER_RETENTION_DAYS]
--pricing-file <path> price tables for equivalent cost [env OMP_USAGE_PRICING_FILE]
--print-prices resolve the pricing file, print prices as JSON and exit
--no-watch polling only (network filesystems)
--config-check validate configuration, print it as JSON and exit
--import-once run a single import cycle and exit
--backup <file> online backup of the database, safe while the service runs
--rebuild-aggregates recompute aggregates from stored events and exit
-V, --version print the version
-h, --help show help
Exit codes: 0 success, 1 runtime failure (locked database, listen error, import errors with --import-once, shutdown timeout), 2 invalid configuration, arguments or pricing file, 3 (--print-prices) a configured price could not be resolved.
Import guarantees¶
| Situation | Behavior |
|---|---|
| Lines appended | Only the new bytes are read |
Trailing line without \n |
Left in place; imported once the writer completes it |
Invalid JSON, schema violation, unknown schemaVersion, invalid UTF-8 |
Skipped, counted once in omp_usage_invalid_records_total{reason}, cursor advances |
Line longer than --max-line-length |
Skipped without being loaded in memory, counted as line_too_long |
| Blank line | Ignored |
File truncated (size < offset) |
Re-read from offset 0 |
| File replaced (different inode/device) | Re-read from offset 0 |
| File rewritten in place (bytes before the cursor changed) | Detected with a SHA-256 of the 256 bytes before the cursor and re-read from 0 |
| File renamed | Treated as a new path, re-read from 0 |
| File deleted | Cursor kept, no error |
| Symlink or non-regular file inside the events directory | Ignored with a warning (opened with O_NOFOLLOW) |
Every re-read is safe: eventId has a UNIQUE constraint and duplicates never update aggregates. Re-reads are counted in omp_usage_file_resets_total{reason}.
The events directory itself may be a symlink chosen by you; only entries inside it are restricted.
Single writer¶
On startup the exporter takes an OS-level exclusive lock on <db-path>.lock, through SQLite with locking_mode=EXCLUSIVE. A second exporter on the same database exits with code 1 and the message locked by another process. The OS releases the lock if the process crashes, so a stale lock never needs manual cleanup.
The main database stays readable while the exporter runs, for example with sqlite3 or --backup.
Database schema¶
PRAGMA user_version stores the schema version (currently 2). Migrations run automatically at startup inside a transaction. A database written by a newer exporter is refused instead of being modified.
| Table | Content |
|---|---|
events |
One row per event (event_id UNIQUE), flattened usage fields, validated JSON |
cursors |
file_path, offset, file_size, inode, device, mtime_ms, tail_hash |
aggregates |
Per (provider, model): tokens, reasoning, requests by status, reported cost, usage/cost missing |
stop_reason_aggregates |
Per (provider, model, stop_reason) counts |
invalid_records |
Skipped lines by reason |
Events without a provider or model are aggregated under the label value unknown. The events row keeps NULL.
Upgrading from 0.2.x: the v2 migration rebuilds aggregates from events and resets cursors. Offsets written by 0.2.x were wrong, so every file is read once more and deduplicated by eventId.
HTTP endpoints¶
Only GET/HEAD on two paths are served; every other path returns 404 and other methods return 405. Nothing exposes event files or database content.
/metrics¶
Prometheus text format. See Metrics.
/healthz¶
{
"status": "ok",
"database": true,
"lastImport": "2026-10-10T10:30:00.000Z",
"lastImportOk": true,
"lastError": null,
"timestamp": "2026-10-10T10:30:02.000Z"
}
status |
HTTP | Meaning |
|---|---|---|
ok |
200 | Database reachable, last cycle succeeded recently |
degraded |
503 | Last cycle had file errors, or no success for max(3 × poll interval, 60 s) |
unavailable |
503 | Database not usable |
Network exposure¶
The default listener is 127.0.0.1:9464. Binding any non-loopback address logs a warning at startup. When Prometheus runs on another host, bind the LAN address explicitly (--listen 192.168.1.50:9464). Avoid 0.0.0.0 and restrict the port with the macOS firewall or your network. The endpoint has no authentication, but it only exposes aggregated counters labelled by provider and model.
Performance¶
| Aspect | Value |
|---|---|
| Batch size | 500 lines per transaction, yielding to the event loop between batches |
| Read buffer | 64 KiB; memory per line bounded by --max-line-length |
| Scrape cost | One aggregate query per scrape (one row per provider/model) |
| Database size | ~1 KB per event |
Troubleshooting¶
locked by another process: another exporter (often the launchd service) is running. Uselaunchctl list | grep omp-usage.- Metrics not updating: check
/healthz,omp_usage_last_import_timestamp_secondsand the logs (--log-level debug). omp_usage_invalid_records_totalgrowing: run with--log-level debugto see file and byte offset of each skipped line.