Stats API internals
Developer notes for the read-only stats API. The client-facing reference, which is what a dashboard author reads, is statsapi.md; keep it in step with any change described here. The reasons behind the design (a separate listener, how hits are classified, snap name attribution, checkpointing, the bucket ladder and the alternatives it replaced) are in design/stats.md §13 and §15. This document covers how the code behaves today and where to find it.
1. Where the code lives
| Piece | Location |
|---|---|
HTTP handler, routes, since parsing, Info |
internal/statsapi/statsapi.go |
Report types and all-time report (Entry, TopByBytesSaved) |
internal/stats/report.go |
Windowed report (TopSinceByBytesSaved, clampSince) |
internal/stats/since.go |
Per-key bucket ladder (ladderTiers, ladder.sum) |
internal/stats/ladder.go |
Counters, recording, checkpointing (Recorder) |
internal/stats/stats.go |
| Default socket path | cmd/pkgproxy/statssocket.go |
Daemon wiring (listener, Handler, info) |
cmd/pkgproxy/cmd_pkgproxy.go |
pkgproxyctl stats and ctlClient |
cmd/pkgproxy/cmd_stats.go |
/pkgproxy/v1/info consumers |
cmd/pkgproxy/cmd_setup.go (applyDaemonInfo), cmd/pkgproxy/cmd_open.go, cmd/pkgproxy/cmd_version.go |
2. Listener and socket path
The daemon always serves the stats API; there is no way to turn it off.
-stats-socket-path (or PKGPROXY_STATS_SOCKET_PATH, through
applyEnvDefaults in cmd/pkgproxy/envflags.go) picks the path. When it
is empty, RunFlag fills it from defaultStatsSocketPath():
$SNAP_COMMON/stats.sockwhenSNAP_COMMONis set. For the pkgproxy snap that is/var/snap/pkgproxy/common/stats.sock;snap/snapcraft.yamldeliberately passes no-stats-socket-path. The flag is not one of thesnap setkeys insnap/local/bin/pkgproxy-env./run/pkgproxy/stats.sockotherwise.
pkgproxyctl stats -socket-path resolves through the same function, so
the two sides agree without configuration (design/stats.md §15 has the reasoning).
The listener comes from listenAt(ctx, socketPath, ""), shared with the
main and admin listeners (design.md §12): it creates the parent directory
(0755), removes a stale socket file, and clears the umask around
net.Listen, so the socket is 0777 and any local user can connect. The
http.Server around it sets only ReadHeaderTimeout (from
-read-header-timeout) and a BaseContext; there is no authentication.
The mutating admin API is on a different socket (-admin-socket-path,
internal/adminapi) so that this one stays read-only by construction; see
the defaultAdminSocketPath doc comment in statssocket.go.
3. Routing and methods
statsapi.Handler holds a *stats.Recorder and an Info, and builds its
http.ServeMux lazily, once (sync.Once), with two patterns:
GET /pkgproxy/v1/stats -> serveStats (statsapi.StatsPath)
GET /pkgproxy/v1/info -> serveInfo (statsapi.InfoPath)
Both paths are built from statsapi.Prefix, /pkgproxy/v1, which the
admin socket’s routes share (adminapi.Prefix; a test keeps the two
equal). The prefix names pkgproxy so that no route reads as one of the
proxied protocols’ own: the snap store serves /v1/ and Docker /v2/.
pkgproxyctl builds its requests from the same constants.
Consequences of using method-prefixed ServeMux patterns, none of which
the handler code implements itself:
- A
GETpattern also matchesHEAD, soHEADreturns 200 with headers and no body. - Any other method on a registered path gets
405 Method Not Allowed, withAllow: GET, HEADand the standard bodyMethod Not Allowed\n. - Any other path, including
/pkgproxy/v1/stats/,/pkgproxy/v1/stats/snapsand the bare/v1/statsand/v1/info, gets404 page not found\n.
Successful responses go through writeJSON: Content-Type: application/json (httpheader.JSON), compact JSON from json.Encoder
with a trailing newline. The encode error is discarded; by the time it
could happen the status line is already written.
There is no 500 path. Recorder methods are safe on a nil receiver (they
return nothing) and take their own lock, so a handler without a recorder
serves empty reports.
4. GET /pkgproxy/v1/stats
4.1 Query parameters
limit: parsed with strconv.Atoi. Only a positive integer replaces
defaultStatsLimit (200); anything else, including 0, a negative number
or garbage, silently falls back to 200. There is no upper bound. (The
report functions treat limit <= 0 as unbounded, but the API never passes
one.)
since: parseSince reads it with strconv.ParseInt(v, 10, 64).
- Missing or empty:
ok == false, the all-time report. - Not a base-10
int64(including out of range):400 Bad Request, written byhttp.Errorwith the textinvalid since="<value>": <strconv error>. The message is built withl10n.D.Errorfand has a Polish translation inpl.po, butError()returns the English text and the handler has no request locale to translate with, so clients always see English. - Otherwise
time.Unix(sec, 0). Negative values and times in the future are accepted; the first is clamped (below), the second yields no entries.
4.2 Response shape
Without since: Recorder.TopByBytesSaved(limit), a bare []stats.Entry.
With since: Recorder.TopSinceByBytesSaved(since, limit), wrapped in
statsapi.SinceReport{Since, Entries}. In both cases a nil slice is
replaced with an empty one so the JSON is [], never null.
SinceReport.Since is the time.Time actually used. time.Time’s JSON
form is RFC 3339 with a fraction only when it is non-zero, in the time’s
zone. time.Unix returns local time, so an unclamped value is whole
seconds in the daemon’s local zone; a clamped value is computed from the
recorder’s clock (time.Now() in the daemon) and carries nanoseconds,
for example 2026-08-24T10:32:51.065531098+02:00.
stats.Entry fields and JSON keys:
| Field | JSON key | apt, gomod, lxd, docker | snap |
|---|---|---|---|
Type |
type |
TypeApt, TypeGoMod, TypeLxd, TypeDocker |
TypeSnap |
Key |
key |
the cache key of one object | the snap name, "" for blobs never associated with a name |
Content |
content |
stats.IsContent(Type, Key) |
true |
SizeBytes |
size-bytes |
the object’s size | size of the revision with the latest ladder touch (ladder.lastTouch), not a sum |
Hits |
hits |
served without this request contacting the origin | summed over revisions |
Fetches |
fetches |
requests that contacted the origin (200 or 304) | hits plus fetches, summed over revisions: a download count |
BytesSaved |
bytes-saved |
Saved: what the hits sent, by httprange.BodyLength |
summed over revisions |
Keys per type, as recorded by the handlers:
- apt:
resolved.Keyfrominternal/proxy, the normalized cache key with the archive host, e.g.deb.debian.org/debian/pool/main/h/hello/hello_2.10-3_amd64.deb. Index files underdists/**are recorded the same way aspool/**files;contentis false for them unless they are underby-hash/(cachekey.KeyPolicyisPolicyImmutable). - gomod: the module proxy object key, e.g.
golang.org/x/text/@v/v0.3.0.zip, for all five endpoints;contentis true for.zipand.mod. - lxd:
<remote>/<path>(design/lxd.md §17.3), for image files and for the*.jsonindex and catalogs, whosecontentis false. - docker:
statsKeyininternal/registryproxy/blob.go,<repository>@<digest>; a layer shared by several images is counted under each repository (design/docker.md §19.6.1). - snap: the name from
Recorder.AssociateSnap, fed by the JSON walk ininternal/snapproxy(design/stats.md §13.3).
Entries with zero hits in the reported period are dropped
(appendEntries, appendSnapEntries and their Since variants).
sortAndLimitByBytesSaved sorts by BytesSaved descending, then Type
and Key ascending, so the order is stable, and then truncates.
4.3 Windowed reports
Each key’s counters carry a Trace *ladder, touched on every recorded
serve. ladderTiers is
1-minute buckets x30 rows -> the last 30m
1-hour buckets x48 rows -> the last 2d
1-day buckets x60 rows -> the last 60d
and TopSinceByBytesSaved answers a query in three steps:
- Clamp.
clampSincemovessinceforward toladderHorizon(now)(nowminus the coarsest tier’s span, 60 days) if it is older. The clamp is the same for every key. The clamped value is what is returned asSinceReport.Since. Only the windowed view loses precision: the all-time counters are never pruned. - Pick one tier.
ladder.sumchooses the finest tier whose horizon (now - Resolution*Rows) is not aftersince, and sums only that tier’s rows and open bucket whose end is aftersince. It never mixes two tiers’ rows, since a coarser tier already holds cascaded totals of what the finer one still keeps. - Add the finer tiers’ open buckets. A bucket cascades into its parent
only when it closes, so every finer tier’s open bucket is activity the
chosen tier does not have yet. Leaving it out would drop exactly the
most recent requests whenever
sinceforces a coarser tier.
Buckets are aligned to their resolution (alignedStart) and a bucket
counts when its end is after since, so the effective start is since
rounded down to the chosen tier’s resolution. The returned Since is not
rounded; it is the (possibly clamped) requested time.
For snaps the windowed rollup still picks SizeBytes by the global latest
touch, not the latest touch inside the window.
How the ladder is rolled, persisted with the checkpoint, and cloned for
Checkpoint() is in design/stats.md §13.6.
5. GET /pkgproxy/v1/info
statsapi.Info is {"addr": ..., "lxd_tls_addr": ..., "setup_page": ..., "version": ...},
filled once at startup by pkgproxyCmd.info(lxdTLS): Addr is -addr as
given (not the resolved listener address), LXDTLSAddr is
-lxd-tls-addr, or offSentinel ("off") when no LXD TLS server was
started, SetupPage is -setup-page, and Version is pkgproxyVersion()
(cmd/pkgproxy/version.go): the main module version the go command
records in the binary, or (devel) and the VCS revision. Offline is the one field
filled per request: serveInfo copies Info and sets it from
Handler.Offline.State() (internal/offline), the mode and the breakers
read under one lock, upstreams sorted by host and never null. A nil
Handler.Offline leaves it out. The admin socket’s GET /pkgproxy/v1/offline
builds its own adminapi.OfflineState from the same snapshot.
It has four consumers. pkgproxyctl version (cmd/pkgproxy/cmd_version.go)
decodes only version, prints it next to its own, and says the daemon is
another build only when both versions are known and differ; a missing
field means a daemon from before 0.9.0. pkgproxyctl open
(cmd/pkgproxy/cmd_open.go) decodes it into its own openInfo, whose SetupPage is a *bool so that
a daemon from before the field (as during a snap refresh) counts as
serving the page; it retries for up to 5 s while the socket is missing or
refuses connections, and turns Addr into a loopback URL (design/setup-page.md
§20.5). The third is applyDaemonInfo in cmd/pkgproxy/cmd_setup.go, used
by the pkgproxyctl setup subcommands. It returns early when -addr (and
-lxd-tls-addr, where the subcommand has it) were given explicitly, always
dials defaultStatsSocketPath() (setup has no -socket-path), ignores
every error so setup still works with no daemon running, and only replaces
a value the daemon reported as non-empty. The third is pkgproxyctl stats,
which reads it after /pkgproxy/v1/stats for its text report only (not -json),
ignores any error, and prints the mode and the open upstreams when the mode
is not never. Why this lives on the stats
socket is in design/stats.md §13.5.
6. The pkgproxyctl client
6.1 Transport
ctlClient{socketPath} in cmd_stats.go is shared with the admin-API
commands (cmd_cache.go’s doJSON, cmd_offline.go), which point it at
the admin socket instead.
httpClient()returns anhttp.ClientwhoseTransport.DialContextignores the network and address and always dialsunixtosocketPath. Request URLs use the placeholder hosthttp://pkgproxy-stats.ctlDialTimeout(5s) is both the dialer timeout and the client’s overall timeout, so a wrong or stale socket fails fast.getJSON(ctx, path, v)issues the GET. A transport error becomescannot reach pkgproxy's stats socket (<path>): <err>; any status but 200 becomescannot use pkgproxy's stats socket: <path> answered HTTP <code>(the body is not read); otherwise the body is decoded into the sharedstats.Entryorstatsapi.SinceReporttypes, with no parallel client-side structs.
6.2 pkgproxyctl stats
Flags: -socket-path, -limit (default defaultCtlLimit, 20, smaller
than the API’s 200 because it sizes a terminal table), -json, and -r.
-r goes through parseSinceFlag(raw, now), which tries, in order:
Unix seconds (strconv.ParseInt), RFC 3339 (time.RFC3339), and a Go
duration meaning “ago” (time.ParseDuration, so 30m, 2h, but not
1d). Failure is invalid -r "<raw>": not a Unix timestamp, RFC3339 timestamp, or duration, before any request is made. The result is sent as
?limit=<n>&since=<unix-seconds>; without -r only ?limit=<n> is sent.
-json re-encodes what it received with two-space indentation: a flat
array, or {"since": ..., "entries": [...]} with -r.
Otherwise renderReport prints:
- the title, and with
-ra “Showing changes since” line in local time. IfSinceReport.Sinceis more than one second after the requested time (the daemon clamped it), it adds “(history only reaches back this far — your requested<ts>was older)”; - the total bytes saved, broken down by type, adding up only the entries
whose
Contentis true, and, when the entries include metadata, a line with the metadata served, not counted in that total; - one table (TYPE, KEY, SIZE, HITS, FETCHES, SAVED) with a TOTAL row, or
noHitsYetMsgwhen there are no entries. A metadata entry’s SAVED is in parentheses, and the TOTAL row’s SAVED leaves it out, as the total above does; its HITS and FETCHES count every entry.
An entry from a daemon before 0.9.0 has no content field;
stats.Entry.UnmarshalJSON fills it in with stats.IsContent, so the
totals come out as a newer daemon’s would.
entryKeyLabel shortens KEY for display only: apt shows path.Base of the
key, docker shortens @sha256:<hex> to 12 hex digits (shortDigestKey),
an empty snap name shows as (unknown), and other types are unchanged.
formatBytes uses 1024-based units labelled KB, MB, and so on.
7. Tests
internal/statsapi/statsapi_test.go:
TestServeStats_ReturnsJSONArrayAcrossAllTypes: one array, every type.TestServeStats_EmptyIsJSONArrayNotNull:[], notnull.TestServeStats_DefaultAndExplicitLimit: 200 by default,?limit=.TestServeStats_SinceReturnsWindowedReport: theSinceReportenvelope.TestServeStats_InvalidSinceIs400.TestNonGETMethodsAreRejected: 405 for other methods.TestUnknownPathIs404.TestServeInfo.
internal/stats/ladder_test.go covers the ladder, including
TestLadder_CoarserTierIncludesUncascadedFinerOpenBuckets (step 3 above),
TestLadder_SinceOlderThanHorizonIsClamped,
TestLadder_KeyWithNoActivityInWindowIsOmitted and
TestCheckpoint_ConcurrentWithRecordDoesNotRaceOnLadder (run under
-race). internal/stats/stats_test.go covers the all-time report: order,
limit, snap rollups, the "" bucket, nil-receiver safety and checkpoints.
cmd/pkgproxy/cmd_stats_test.go covers the client: parseSinceFlag,
renderReport (including the clamp note and the empty placeholder),
ctlClient.getJSON (round trip, non-200, missing socket), and the
pkgproxyctl stats command end to end against a fake daemon on a socket
(statsDaemon), with -json, -r and a bad -r.
8. Changing the API
The API has no formal compatibility promise (see the Stability section of statsapi.md), but scripts do read it. When changing it:
- Prefer adding fields, types or endpoints over renaming or removing them. Clients are told to ignore what they don’t know.
- Update statsapi.md, this document, and design/stats.md §13 when the design changes, in the same change.
- Add a NEWS entry for anything a client can notice.
- Keep
pkgproxyctl statsdecoding the sharedstatsandstatsapitypes rather than its own copies, so the two cannot drift.