A very small service that answers one question: who is spending your LLM tokens, and are they worth it? Your apps (or a gateway such as Keystone, KTMS) push one event per model call; you query totals by project, user, action, vendor, model or status and see who is greedy and who is worthy.
- One static Go binary, one Postgres database, a small image (see Resource footprint). No queue, no cache, no UI.
- Bearer-token authentication with
writeandreadscopes, and Prometheus metrics. - Append-only and idempotent. Every event has a UUID; sending it twice is harmless.
- Fast writes. Batches of up to 500 events are inserted with a single
COPY. - Flexible reads. Time-bucketed (per day) summaries with any combination of filters and groupings, plus aggregation over your own metadata (e.g. everything one batch job spent).
- Two APIs. HTTP/JSON for curl and dashboards, gRPC for services.
- Scalable analytics with ClickHouse. Optional: events are copied from Postgres to ClickHouse in batches, without loss or double counting, and reports are served from ClickHouse with automatic TTL retention.
- Quick start
- Resource footprint
- Installation
- Sending events
- Finding the greedy and the worthy
- Command-line tool
- API reference
- API stability
- Configuration
- Security
- Operations
- Development
Requires Docker with the Compose plugin.
git clone https://github.com/ai-process/light-tokenmeter.git
cd light-tokenmeter
docker compose up --build -d
curl -s localhost:8080/readyz # {"status":"ready"}Record an event and read it back:
curl -s -X POST localhost:8080/v1/usage/events -H 'Content-Type: application/json' -d '{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "user-123", "project_id": "support-bot", "action_id": "answer-ticket",
"vendor": "openai", "model": "gpt-4o-mini", "status": "success",
"duration_ms": 1234, "tokens_input": 150, "tokens_output": 300
}'
curl -s "localhost:8080/v1/usage/summary?from=2020-01-01T00:00:00Z&to=2100-01-01T00:00:00Z&group_by_project_id=true&group_by_model=true"The schema is created automatically on startup.
The image coyl/light-tokenmeter:0.1.1 was ~28 MB uncompressed and ~11 MB to download for
linux/amd64 (Docker Hub manifest and docker image inspect). It was Alpine-based; from 0.1.2 the
runtime is distroless (static, nonroot). Memory, readiness and throughput have not been measured
yet.
Release images are published for linux/amd64 and linux/arm64:
docker pull coyl/light-tokenmeter:latest # or pin a release, e.g. coyl/light-tokenmeter:1.0.0It needs only a Postgres database:
docker run -d --name light-tokenmeter \
-p 8080:8080 -p 9090:9090 \
-e DATABASE_URL='postgres://user:pass@db-host:5432/tokenmeter?sslmode=require' \
coyl/light-tokenmeter:latestAlongside your own stack:
services:
light-tokenmeter:
image: coyl/light-tokenmeter:latest
restart: unless-stopped
environment:
DATABASE_URL: postgres://tokenmeter:${DB_PASSWORD}@db:5432/tokenmeter?sslmode=disable
depends_on: [db]
# no published ports: reach it from other containers at light-tokenmeter:8080 / :9090Kubernetes: liveness probe GET :8080/healthz, readiness probe GET :8080/readyz (checks the
database). The service is stateless, so run as many replicas as you like. Images are signed; see
Verifying a release.
Go 1.26+ and Postgres:
make build # → bin/light-tokenmeter
export DATABASE_URL='postgres://tokenmeter:tokenmeter@localhost:5432/tokenmeter?sslmode=disable'
./bin/light-tokenmeterOr make run. A .env file in the working directory is loaded if present (see env.example).
Any reasonably current Postgres works (16 is what the compose file uses). Create an empty
database and a user that may create tables; migrations run on every start and are embedded
in the binary. Table usage_events grows by one row per model call, so plan retention for
your volume (for example a periodic DELETE ... WHERE created_at < now() - interval '13 months').
An event describes one model call:
| Field | Type | Required | Description |
|---|---|---|---|
event_id |
UUID | yes | Idempotency key; a repeated id is ignored. |
user_id |
string ≤256 | yes | Who the call was for. |
project_id |
string ≤256 | yes | Which app or tenant. |
action_id |
string ≤256 | yes | What it was for, e.g. summarize-ticket. |
vendor |
string ≤64 | yes | openai, anthropic, google, … (free-form). |
model |
string ≤128 | yes | Model name. |
status |
string | yes | success, error, cancelled or timeout. |
occurred_at |
RFC 3339 | no | When it happened, from 1900 to April 2262; the server's clock is used when it is missing or the zero time. |
duration_ms |
int | no | Call latency. |
tokens_input, tokens_output |
int | no | Token counts. |
tokens_total |
int | no | Defaults to input + output; if given it must equal that sum. |
error_class |
string ≤256 | no | Normalised error class. |
vendor_http_status |
int | no | HTTP status returned by the vendor. |
meta |
map of string | no | Your own tags, e.g. {"job_id": "…"}: non-empty keys, at most 16 KiB of keys and values per event. |
cost_nanousd |
int ≥0 | no | Call cost in nano-USD (1e-9 USD), priced by the caller; stored as-is and never recomputed. |
cache_read_tokens, cache_write_tokens |
int ≥0 | no | Input tokens read from and written to the vendor's prompt cache. They are part of tokens_input, not added to it, so together they must not exceed it; an event that breaks this is refused. Anthropic reports them outside its input_tokens: send the sum of all three as tokens_input. |
Batches (POST /v1/usage/events:batch, or RecordUsageEvents over gRPC) take up to 500
events and report how many were created and how many were duplicates:
curl -s -X POST localhost:8080/v1/usage/events:batch -H 'Content-Type: application/json' -d '{
"events": [
{"event_id":"550e8400-e29b-41d4-a716-446655440001","user_id":"u1","project_id":"p1",
"action_id":"a","vendor":"openai","model":"gpt-4o-mini","status":"success",
"tokens_input":150,"tokens_output":300},
{"event_id":"550e8400-e29b-41d4-a716-446655440002","user_id":"u2","project_id":"p1",
"action_id":"a","vendor":"google","model":"gemini-2.5-flash","status":"error",
"error_class":"rate_limited","vendor_http_status":429}
]}'
# {"created_count":2,"duplicate_count":0}The service stores token counts and optional caller-provided costs (cost_nanousd), avoiding runtime pricing lookups during ingestion. You can also multiply token totals by your own price list when reading summaries.
GET /v1/usage/summary returns one row per day and per combination of the dimensions
you group by. from (inclusive) and to (exclusive) are required, RFC 3339.
Q='from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z'
# Which project burned the most?
curl -s "localhost:8080/v1/usage/summary?$Q&group_by_project_id=true"
# Which models does one project lean on?
curl -s "localhost:8080/v1/usage/summary?$Q&project_id=support-bot&group_by_model=true"
# Which actions are expensive per request, and which fail?
curl -s "localhost:8080/v1/usage/summary?$Q&group_by_action_id=true&group_by_status=true"
# The heaviest users of one model
curl -s "localhost:8080/v1/usage/summary?$Q&model=gpt-4o&group_by_user_id=true"Each row carries tokens_input, tokens_output, tokens_total, request_count,
sum_duration_ms, cost_nanousd (summed spend; divide by 1e9 for USD) and
cache_read_tokens / cache_write_tokens (the cached part of tokens_input), so you can
compute tokens per request, cost per request, average latency and error share yourself. The signals that usually separate greedy from worthy:
- high
tokens_total / request_counton a trivialaction_id(bloated prompts), - a large share of tokens spent on
status=errorortimeout(paying for failures), - an expensive model serving an
action_idthat a cheaper one handles equally well.
Filters: project_id, user_id, vendor, model, action_id, status (must be success, error, cancelled, or timeout; invalid values return 400). Groupings:
group_by_project_id, group_by_user_id, group_by_vendor, group_by_model,
group_by_action_id, group_by_status (each =true). Fields you do not group by are left out of the rows.
Responses group by day based on event time (occurred_at, falling back to created_at). If query results exceed 10,000 rows, the response returns truncated: true and caps results at 10,000.
If you tag events with a key such as job_id in meta, the gRPC call GetUsageByMeta
returns everything that job spent (tokens and cost_nanousd), split by vendor and model
and optionally by a second meta key (say task_id), plus an optional project_id filter. Lookups on meta.job_id
are indexed via a partial expression index (meta ? 'job_id'). Responses include
truncated: true if matching aggregate groups exceed 10,000 rows.
grpcurl -plaintext -d '{"meta_key":"job_id","meta_value":"job-42","group_by_meta_key":"task_id"}' \
localhost:9090 usage.v1.UsageService/GetUsageByMetagRPC reflection is on, so grpcurl needs no .proto files (with auth on, pass a token with
-H 'authorization: Bearer …').
tokenmeter reads the reports without hand-built query strings or dividing by 1e9, makes tokens
without openssl, and sends a test event. It talks gRPC to :9090; health uses HTTP :8080.
# a release archive (darwin/linux, amd64/arm64, signed checksums), or:
go install github.com/ai-process/light-tokenmeter/cmd/tokenmeter@latest
docker run --rm -e TOKENMETER_ADDR -e TOKENMETER_TOKEN coyl/tokenmeter-cli:latest usage summaryexport TOKENMETER_ADDR=localhost:9090 TOKENMETER_TOKEN=… # a read token for reports
tokenmeter usage summary --last 7d --group-by project # totals in USD; --daily per day
tokenmeter usage summary --from 2026-09-01 --to 2026-10-01 --model gpt-4o --group-by user
tokenmeter usage top --by cost --dimension action --limit 5
tokenmeter usage by-meta --key job_id --value job-42 --group-by task_id
tokenmeter usage export --last 30d --format csv --out usage.csv # daily rows, every dimension
tokenmeter token new --name ktms --scope write # the token, then its entry
tokenmeter event send --project p1 --user u1 --action check --vendor openai --model gpt-4o-mini
tokenmeter health
tokenmeter completion zsh > "${fpath[1]}/_tokenmeter" # also bash and fish
tokenmeter mcp # reports for AI assistants- Windows are
--last(24h,7d,4w; 7 days by default) or--from/--toin RFC 3339 orYYYY-MM-DD, all UTC. A window the server caps at 10,000 rows (truncated) is fetched in smaller parts and added up, so totals are complete; only an hour that is still capped is reported as incomplete. -o jsonkeeps the API's fields (cost_nanousdas a string),-o csvgives exact dollars.- Contexts work as in
ktms:tokenmeter context set prod --addr tm.example.com:443 --tls --token keychain, thentokenmeter context store-token prodreads the token without echo. They live in~/.config/tokenmeter/config.yaml(TOKENMETER_CONFIG); a token source isenv:VAR,file:PATHorkeychain. Flags win overTOKENMETER_ADDR,TOKENMETER_TLS,TOKENMETER_HTTPandTOKENMETER_TOKEN, which win over the current context; a context named with--contextorTOKENMETER_CONTEXTwins over them, so its token never goes elsewhere. - Exit codes: 0 done, 1 failed, 2 bad usage, 4 not authenticated or not allowed, 5 not found, 6 rejected as invalid, 7 unavailable (retry later).
pkg/clientis the Go client underneath:Recordin batches of 500,Summarywith the window splitting,ByMeta.
tokenmeter mcp lets Claude Code, Claude Desktop, Cursor and other MCP clients answer "what did
team X spend this week", "which actions are expensive per request" or "what did job-42 cost" from
the data here. Every tool only reads:
usage_summary(the summary's filters and groupings, totals ordaily),top_spenders(by cost, tokens or requests, with shares),cost_per_action(cost and tokens per request, latency and the failed share peraction_id),usage_by_meta(a job tagged inmeta),health;- amounts as exact
cost_usdand ascost_nanousd, windows in UTC, and atruncatedresult marked as not a total; capped windows are fetched in parts first, as in the CLI; - a prompt,
usage_report.
It needs a token with the read scope and refuses one that can also write, so an assistant's
server never records usage. Claude Code (.mcp.json), Claude Desktop and Cursor take:
{
"mcpServers": {
"tokenmeter": { "command": "tokenmeter", "args": ["mcp"],
"env": { "TOKENMETER_ADDR": "tm.example.com:443", "TOKENMETER_TLS": "true",
"TOKENMETER_CONTEXT": "prod" } }
}
}Together with Keystone's ktms mcp (its get_spend), an assistant can say what was spent and how
much of a cap is left. Over the network, tokenmeter mcp --listen 127.0.0.1:8766 serves
streamable HTTP at /mcp with Authorization: Bearer <token> from TOKENMETER_MCP_TOKEN (16+
characters); it speaks plain HTTP, so keep it on localhost or behind a TLS proxy.
| Method | Path | Description |
|---|---|---|
POST |
/v1/usage/events |
Record one event. |
POST |
/v1/usage/events:batch |
Record up to 500 events. |
GET |
/v1/usage/summary |
Aggregated usage, see above. |
GET |
/healthz |
Liveness, with the service's version. |
GET |
/readyz |
Readiness (database ping). |
Errors from /v1/usage/* are JSON {"error": "<message>", "code": "<code>", "details": "<optional>"},
with code invalid_json or validation_error (400), unauthenticated (401), forbidden (403),
request_too_large (413, over 16 MiB) or internal_error (500). A refused batch names the first
bad event as invalid event at index N: …. /readyz answers 503 with
{"status":"not ready","error":"database unavailable"}; unknown paths and methods get a plain
404 or 405. Usage routes need a bearer token when auth is on, see
Authentication.
usage.v1.UsageService, defined in proto/usage/v1/usage.proto:
RecordUsageEvent, RecordUsageEvents, GetUsageSummary, GetUsageByMeta. Go stubs live in
gen/usage/v1 (github.com/ai-process/light-tokenmeter/gen/usage/v1).
Invalid input is INVALID_ARGUMENT; a refused batch names the first bad event as
invalid event at index N: …, so a client can drop that event and resend the rest. Messages may
be up to 16 MiB.
From 1.0, light-tokenmeter follows semantic versioning: within 1.x nothing below changes incompatibly. Breaking changes wait for 2.0. Minor releases add features; patch releases fix bugs.
| Surface | What stays compatible in 1.x |
|---|---|
gRPC usage.v1 (UsageService) |
No RPC, field, enum value or message is removed or renamed, and no field number or type changes. New RPCs and fields may appear. buf breaking checks every pull request. |
HTTP /v1/usage/* |
Paths, query parameters, request and response fields of the events and summary endpoints. New fields and parameters may appear, so ignore unknown fields. |
| Errors | HTTP status codes and code values above; gRPC status codes; the invalid event at index N message prefix on both. New codes may appear, so treat an unknown one like its status. |
| Authentication | Authorization: Bearer, the write and read scopes and the TOKENMETER_TOKENS entry format, see Authentication. |
| Probes | /healthz and /readyz, their status codes and the status and clickhouse fields. |
| Metrics | Names, types and labels of the tokenmeter_* metrics in Metrics. New metrics and label values may appear. |
| Environment variables | Names and meaning of every variable in Configuration, and their defaults, except tuning knobs (CLICKHOUSE_BATCH_SIZE, CLICKHOUSE_FLUSH_INTERVAL, DB_MAX_CONNS, DB_MIN_CONNS), whose defaults may change in a minor release with a changelog note. |
| Storage | Any 1.x release upgrades the Postgres and ClickHouse schemas of an earlier one by itself at startup, with no manual steps. The tables are not an API, except as the recipes in this README use them. |
| Limits | 500 events per batch, 16 MiB per request, and the field and meta limits in Sending events never shrink. |
Not public, and free to change in any release: log message text (other than the prefix above),
the internal/ Go packages, the outbox's lease and retry timings, and resource numbers.
The tokenmeter CLI (commands, flags, exit codes, -o json|csv output), its MCP tools and
pkg/client are new in 1.1 and Experimental until 1.2: they may still change in a minor release, with a changelog
note.
| light-tokenmeter | Keystone (KTMS) | ktms-admin | Authentication |
|---|---|---|---|
| 1.x | 1.x | 1.x | On or off. With it on, KTMS needs USAGE_COLLECTOR_TOKEN (write) and ktms-admin TOKENMETER_TOKEN (read). |
| 1.x | 0.x | 0.x | Off only: releases before 1.0 send no token. |
usage.v1 stays compatible within 1.x, so any KTMS 1.x reports to any light-tokenmeter 1.x.
- Within 1.x, upgrade in one step from any earlier 1.x release: Postgres and ClickHouse
migrations run forward at startup and cover every release in between.
scripts/upgrade-test.shupgrades from the previous release and from the oldest release of the same major on every pull request and before an image is published, with both databases, and checks the totals. - Rolling upgrades work within 1.x: migrations only add, so replicas of the previous release keep serving on the migrated databases while new ones start; the upgrade test runs the old release again, on its own, after the new one migrated. A change that cannot be additive waits for 2.0.
- Downgrades are not supported. To go back, restore the Postgres backup taken before the upgrade (ClickHouse can be rebuilt from it, see ClickHouse).
- From 0.x, upgrade straight to 1.0; its release is tested from 0.1.0 and 0.1.2. Later releases are tested from 1.x ones only.
Nothing is experimental in 1.0. KTMS keeps a copy of usage.proto for its usage reports; its CI
fails when the copy differs from this repository's.
Environment variables (see env.example):
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
local dev URL | Postgres connection string. Always set it. |
GRPC_ADDR |
:9090 |
gRPC listen address. |
HTTP_ADDR |
:8080 |
HTTP listen address. |
METRICS_ADDR |
:9091 |
Prometheus /metrics and /healthz, see Metrics. Set it empty to turn them off. |
LOG_LEVEL |
info |
debug, info, warn, error. |
LOG_FORMAT |
json |
Log format: json (structured JSON on stdout) or console (colored human-readable). |
SHUTDOWN_TIMEOUT |
30s |
Graceful shutdown window. |
DB_MAX_CONNS / DB_MIN_CONNS |
25 / 5 |
Connection pool bounds per replica. |
TOKENMETER_TOKENS |
empty | Accepted tokens as name:scope[+scope]:sha256hex, comma-separated; see Authentication. Empty, with no file, turns auth off. |
TOKENMETER_TOKENS_FILE |
empty | A file of the same entries, one per line, added to TOKENMETER_TOKENS; for a mounted secret. |
SENTRY_DSN |
empty | Error reporting; empty disables it. |
SENTRY_ENVIRONMENT / SENTRY_RELEASE |
development / empty |
Sentry metadata. |
SENTRY_ENABLE_TRACING |
false |
Sentry performance tracing. |
SENTRY_DEBUG |
false |
Enable Sentry SDK debug logging. |
SENTRY_WITH_BREADCRUMBS |
false |
Include breadcrumbs in Sentry error reports. |
CLICKHOUSE_ENABLED |
false |
Copy events to ClickHouse and serve reports from it, see ClickHouse. |
CLICKHOUSE_ADDR |
localhost:9000 |
ClickHouse native TCP address. |
CLICKHOUSE_DATABASE |
tokenmeter |
ClickHouse database name. |
CLICKHOUSE_USER |
default |
ClickHouse user. |
CLICKHOUSE_PASSWORD |
empty | ClickHouse password. |
CLICKHOUSE_BATCH_SIZE |
5000 |
Most events copied to ClickHouse in one insert. |
CLICKHOUSE_FLUSH_INTERVAL |
1s |
How often waiting events are copied to ClickHouse; reports lag by about this much. |
See SECURITY.md for reporting vulnerabilities, verifying releases and running the service safely.
Every usage call needs a bearer token once TOKENMETER_TOKENS (or TOKENMETER_TOKENS_FILE)
lists at least one. A token has one or both scopes:
| Scope | Allows |
|---|---|
write |
POST /v1/usage/events, POST /v1/usage/events:batch, RecordUsageEvent, RecordUsageEvents |
read |
GET /v1/usage/summary, GetUsageSummary, GetUsageByMeta |
/healthz, /readyz and gRPC health (grpc.health.v1.Health, a liveness check) never need a
token. gRPC reflection accepts any configured token, so grpcurl works with -H below.
The service stores only SHA-256 hashes. Each entry is name:scope[+scope]:sha256hex; entries
are separated by commas or newlines, and in a file # starts a comment:
tokenmeter token new --name ktms --scope write # prints the token, then ktms:write:<hash>
# or by hand:
TOKEN=$(openssl rand -hex 32) # give this to the client
printf %s "$TOKEN" | openssl dgst -sha256 -r | cut -d' ' -f1 # put this in the config
TOKENMETER_TOKENS="ktms:write:<hash>,console:read:<hash>"Clients send Authorization: Bearer <token>, as an HTTP header or as gRPC metadata:
curl -H "Authorization: Bearer $TOKEN" 'localhost:8080/v1/usage/summary?from=…&to=…'
grpcurl -plaintext -H "authorization: Bearer $TOKEN" localhost:9090 usage.v1.UsageService/GetUsageSummary| Problem | HTTP | gRPC |
|---|---|---|
| No token, or an unknown one | 401 unauthenticated with WWW-Authenticate: Bearer |
UNAUTHENTICATED |
| The token lacks the scope | 403 forbidden |
PERMISSION_DENIED |
Rotation without downtime: add the new token's entry next to the old one and restart (or
roll) the service, switch the clients to the new token, then remove the old entry. Any number of
entries may be active at once, each token in one entry only. With LOG_LEVEL=debug every
accepted call logs the token's name, so you can see when the old one stops being used; refused
calls are logged at warn.
A TOKENMETER_TOKENS_FILE that is missing, malformed or holds no tokens stops the service at
startup rather than turning auth off.
With no tokens configured the service logs a warning at startup and lets every call in. Use that only on a private network you trust.
The service speaks plain HTTP and gRPC, so a token crosses the network in the clear. Outside a
private network, terminate TLS in front of it (Envoy, nginx, Caddy, a service mesh), and do not
publish ports 8080/9090 on a public interface. Events can contain whatever you put in user_id
and meta; avoid personal data you would not want in a database.
- Migrations run at startup and are embedded in the binary.
- Upgrades: deploy a newer image tag; see Versions and upgrades.
- Backups: Postgres holds everything (ClickHouse can be rebuilt from it); back it up like any other.
- Logs: structured JSON on stdout.
/metrics on METRICS_ADDR (:9091), with the Go and process metrics:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
tokenmeter_events_total |
counter | result: created, duplicate, invalid |
Events received. A refused batch counts all its events as invalid; a body that cannot be read shows only in tokenmeter_requests_total (400 or 413). |
tokenmeter_requests_total |
counter | transport (http, grpc), route (pattern or RPC), code |
API requests, including those refused by auth; probes are not counted. A client that gives up shows as Canceled or DeadlineExceeded. |
tokenmeter_request_duration_seconds |
histogram | transport, route |
API request duration: writes and report queries. |
tokenmeter_storage_errors_total |
counter | store (postgres, clickhouse), op |
Failed storage operations. |
tokenmeter_read_fallbacks_total |
counter | Reports answered from Postgres because ClickHouse failed. | |
tokenmeter_clickhouse_enabled |
gauge | 1 when CLICKHOUSE_ENABLED is set. |
|
tokenmeter_clickhouse_serving |
gauge | 1 while reports come from ClickHouse; 0 while not connected, backfilling, or after a failed ClickHouse query. | |
tokenmeter_clickhouse_copied_events_total |
counter | Events copied to ClickHouse. | |
tokenmeter_clickhouse_insert_failures_total |
counter | reason: unreachable, refused |
Failed ClickHouse inserts (retried or postponed, never dropped). |
tokenmeter_clickhouse_outbox_events |
gauge | Events waiting for ClickHouse, sampled every 15 s and counted up to 1 000 000. | |
tokenmeter_clickhouse_outbox_oldest_seconds |
gauge | Age of the oldest waiting event: how far ClickHouse reports trail Postgres. | |
tokenmeter_build_info |
gauge | version |
1, labelled with the running release. |
Suggested alerts:
groups:
- name: light-tokenmeter
rules:
- alert: TokenmeterDown
expr: up{job="light-tokenmeter"} == 0
for: 2m
- alert: TokenmeterWritesFailing # events are not being recorded
expr: sum(rate(tokenmeter_requests_total{route=~".*(events|RecordUsageEvent).*", code=~"5..|Internal|Unavailable"}[5m])) > 0
for: 5m
- alert: TokenmeterInvalidEvents # a client sends events that are refused
expr: sum(rate(tokenmeter_events_total{result="invalid"}[15m])) > 0
for: 15m
- alert: TokenmeterAuthRefusals # a client lost its token, or someone is probing
expr: sum(rate(tokenmeter_requests_total{code=~"401|403|Unauthenticated|PermissionDenied"}[10m])) > 0.1
for: 10m
- alert: TokenmeterClickHouseBehind # ClickHouse-served reports undercount
expr: max(tokenmeter_clickhouse_outbox_oldest_seconds) > 600 and on() max(tokenmeter_clickhouse_serving) == 1
for: 10m
- alert: TokenmeterClickHouseRefusing # a batch waits for a fix, see "ClickHouse"
expr: sum(increase(tokenmeter_clickhouse_insert_failures_total{reason="refused"}[30m])) > 0
- alert: TokenmeterReportsFromPostgres # ClickHouse configured but not serving
expr: tokenmeter_clickhouse_enabled == 1 and tokenmeter_clickhouse_serving == 0
for: 15mPostgres is always the record. With CLICKHOUSE_ENABLED=true:
- Writes go to Postgres as usual; each new event is also added, in the same statement, to a
clickhouse_outboxtable. A duplicateevent_idis added to neither. - Copying: every
CLICKHOUSE_FLUSH_INTERVALeach replica takes up toCLICKHOUSE_BATCH_SIZEwaiting events and inserts them into ClickHouse, then removes them from the outbox. Replicas take different batches. - Reads:
/v1/usage/summary,GetUsageSummaryandGetUsageByMetaare answered from ClickHouse, so they trail writes by about one flush interval. If ClickHouse fails a query, or is not reachable yet, the answer comes from Postgres. - Startup and readiness: the service starts and is ready without ClickHouse; it connects in
the background and
/readyzreports"clickhouse":"unavailable"meanwhile (or while it is down), without failing. - ClickHouse unreachable (or failing for reasons not about the rows): the replica keeps its batch, renews its two-minute lease, and retries with backoff up to 15 s apart. A batch still failing after five minutes is postponed like a refused one, in case the trouble is its own. Nothing is dropped, and writes keep working; the outbox drains once ClickHouse is back.
- ClickHouse refusing rows (a data error such as a type mismatch, often a changed schema): the batch is postponed, 30 s and then up to 10 minutes later, while the events behind it flow. It keeps its deduplication token and is never split or dropped, so it cannot be counted twice; an error with the batch ID and ClickHouse's message is logged on every refusal. Fix the cause and the batch goes through at its next retry.
- Restart or crash: waiting events stay in the outbox. A batch inserted but not yet removed
is taken over when its lease ends and inserted again under the same deduplication token;
ClickHouse ignores the repeat, as it remembers the last 100 000 inserts
(
non_replicated_deduplication_window; aReplicatedMergeTreeyou create yourself needsreplicated_deduplication_window).
Migrations need a ClickHouse user allowed to create and alter usage_events; until they apply,
the service records to Postgres and logs the error. Set CLICKHOUSE_ENABLED the same on every
replica: an event written by a replica with it off is never queued for ClickHouse.
The first time ClickHouse is turned on for a database, and its usage_events is still empty, the
last 90 days of Postgres history (what ClickHouse's TTL keeps) are queued for it, and reports
stay on Postgres until that history is copied. Turn ClickHouse on after an upgrade from 0.x, not
during the roll: old replicas would write to it directly and could be counted twice. Events recorded while it
was off later on are not copied; Postgres reports still include them. Versions before 1.0 kept
events in memory and could drop them; to rebuild ClickHouse from Postgres, empty its
usage_events table and run
INSERT INTO clickhouse_outbox (event_id) SELECT event_id FROM usage_events ON CONFLICT DO NOTHING
in Postgres.
make test # go test -race ./...
make build # bin/light-tokenmeter
make proto # regenerate gen/ (needs buf)
docker compose up --buildThe storage tests need Postgres on localhost:5433 (or DATABASE_URL) and ClickHouse on
localhost:9000 (or CLICKHOUSE_TEST_ADDR), and skip without them. Each test creates and drops a
database of its own (the Postgres role needs CREATEDB), so tests never touch other data; databases
left by an interrupted run are dropped by the next run after an hour. CI runs them with REQUIRE_DB=1, which turns a missing database into a
failure:
docker run -d --rm -p 5433:5432 -e POSTGRES_USER=tokenmeter -e POSTGRES_PASSWORD=tokenmeter postgres:17-alpine
docker run -d --rm -p 9000:9000 -e CLICKHOUSE_USER=tokenmeter -e CLICKHOUSE_PASSWORD=tokenmeter -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 clickhouse/clickhouse-server:24.3-alpine
REQUIRE_DB=1 make testLayout: cmd/server (entrypoint), internal/server (HTTP + gRPC), internal/service
(business logic), internal/repository (SQL), internal/domain (models and validation),
internal/database (pool and embedded migrations).
-
Move the
[Unreleased]entries ofCHANGELOG.mdunder a new version heading with today's date, in a pull request. -
After it is merged, tag main and push the tag:
git tag v1.0.0 && git push origin v1.0.0
The Release workflow then runs the upgrade test from earlier
releases and govulncheck, creates the GitHub Release from the changelog section, and publishes
the signed multi-arch image coyl/light-tokenmeter:1.0.0; 1.0 moves to it when it is the newest
release of 1.0.x, and latest when it is the newest release of all. Tags are vX.Y.Z only, with
no pre-releases; push one release tag at a time. The repository needs the secrets DOCKER_USERNAME and DOCKER_PASSWORD. Run
the upgrade test locally (needs Docker) with ./scripts/upgrade-test.sh.
Pull requests are welcome; see CONTRIBUTING.md.