pkgproxy design: stats
The read-only stats API, and the default socket path both sides agree on. Part of the pkgproxy design, whose index says which file holds each § number.
- 13. Stats API
- 13.1 A second listener, not a third gateway route
- 13.2 Recording: hits, fetches, and joined requests
- 13.3 Snap name/revision attribution: a JSON-walk side channel, not a cache key
- 13.4 Persistence: periodic checkpoint, not per-request durability
- 13.5 Endpoints
- 13.6 Windowed reports:
-r/?since=and the per-key bucket ladder
- 15. A well-defined default stats socket path, for both sides
13. Stats API
A read-only JSON REST API (internal/statsapi) reports what the cache is
actually doing: one top-N-by-bytes-saved list of cache entries across
every proxy, each tagged with its type — individually cached objects for
apt, go modules, LXD and Docker (there are far too many distinct objects
cached over a long-running instance to summarize any other way), and one
rollup per snap name for snaps.
This section records the design and why it is shaped this way. The wire
format, as clients see it, is documented in
statsapi.md; how the handler, the windowed query and
pkgproxyctl stats behave in detail, with code and test references, is
in statsapi-internals.md.
13.1 A second listener, not a third gateway route
The API is served on its own listener, -stats-socket-path (always on;
the default path is described in §15), rather than being multiplexed onto the
main listener the way the proxy sides share one via internal/gateway.
Two reasons:
internal/gateway’s dispatch relies on request shape (and fixed path prefixes) to tell the sides apart; a reporting route on the same listener would claim a shape that untrusted client traffic can reach.- A read-only reporting surface and a mutating admin surface have different risk profiles, and neither belongs next to untrusted apt/snapd traffic. The admin API is on a third socket of its own (§16.9), so this one stays read-only by construction.
13.2 Recording: hits, fetches, and joined requests
internal/stats.Recorder tracks two counters per cache key (per sha3-384
hash for snap blobs): Hits (served without this request contacting the
origin) and Fetches (this request did contact the origin, whether that
produced a fresh body or a 304 revalidation). A request that joins
another’s in-flight fetch, fill or revalidation counts as a hit: the
question is “did this request avoid a fetch”, not “was a fetch involved
anywhere”. The request that started it counts as a fetch, however many
joined it. singleflight’s own “shared” bit cannot tell the two apart, as
it is true for the starter too once anyone joined, so internal/flight
reports joined instead: whether this caller waited for another’s call.
Each hit or fetch records the object’s size, from the metadata already in
hand, and how many of its bytes the response carries:
httprange.BodyLength, worked out from the request and the very
validators the response is served with, so it is known before the body
is sent. That is the whole object for a GET, the range for a 206, and
nothing for a HEAD, a 304 or a 416. A hit adds it to the key’s Saved,
which is what bytes-saved reports: a HEAD, or a client’s revalidation
answered with 304 from the cache, is a hit that saved nothing, and a
client that downloads by Range (some fetch everything that way) saves
what it got. A client that hangs up halfway still counts in full, as
the bytes are counted before they are sent. Counting is
deliberately not a store.Store concern (a hit counter on
ObjectMeta): that would rewrite metadata on every hit and push a
proxy-level concern into a backend-agnostic interface an S3 backend
(§7.1) would also have to support. The handlers feed internal/stats
explicitly.
Not every hit is a download saved. Content (apt’s pool/** and
dists/**/by-hash/**, Go modules’ .zip and .mod, LXD’s image files,
snap and Docker blobs) is fetched once and served unchanged, so a hit
spares the client the download. Metadata (apt’s other dists/** files,
Go modules’ list, @latest and .info, LXD’s *.json index and
catalogs) is revalidated as it ages, and a client that has it mostly
asks only whether it changed, so a hit spares little. Both are recorded
alike and ranked together, and stats.IsContent(kind, key) tells them
apart by key when a report is made: the checkpoint has nothing to say
about it, so one written by any version is classified the same way.
What the cache saved, on the cache page and in pkgproxyctl stats, adds
up content only; each also shows the metadata served, apart and briefly.
13.3 Snap name/revision attribution: a JSON-walk side channel, not a cache key
Snap blobs are cached purely content-addressed
(sha3-384/<hex>, §11.4), so nothing about the key or its metadata
carries a snap’s name. The only place a name and a hash appear together is
a relayed API response, which the rewriter already walks
(§11.3); the walk also carries the nearest enclosing "name" and
"revision" down to each download it reports. This is a permissive,
best-effort heuristic, not a per-endpoint schema, consistent with the
rewriter leaving unknown fields alone. A mis-attribution only affects
reporting, never caching: a blob never associated with a name is still
counted, under the empty "" name.
13.4 Persistence: periodic checkpoint, not per-request durability
Counts are kept in memory and written to <cache-dir>/stats.json every
-stats-checkpoint-interval, as a plain JSON file: not a store.Store
object (a single mutable snapshot has no content-hash or immutability
semantics), and not written per request (a fleet-wide apt update can hit
one pool file many times a second). A clean shutdown checkpoints once
more; an unclean exit can lose up to one interval of counts, an accepted
trade-off since the counts are not durability-critical. The file has a
version. A file without one (version 0) has no Saved, and is read
as every hit having sent the whole object: size times hits.
13.5 Endpoints
Every route of pkgproxy’s own API, here and on the admin socket
(§16.9), is under /pkgproxy/v1/
(statsapi.Prefix, adminapi.Prefix). A bare /v1/ is what the
proxied protocols use (the snap store serves /v1/, Docker /v2/), so
the prefix names pkgproxy and keeps a route unambiguous wherever it is
served. The bare /v1/... paths answer 404.
Read-only by construction: net/http.ServeMux’s method-prefixed
patterns ("GET /path") return 405 for any method other than GET (and the
HEAD a GET pattern also accepts), so there’s no separate check needed to
keep this a read-only surface. The full request and response reference is
statsapi.md; statsapi-internals.md
§3–§5 has the parsing and edge cases.
GET /pkgproxy/v1/statsis one list of cache entries across every proxy, ranked by bytes saved, each with atypefield so a client can render or filter by proxy without separate endpoints. Apt, go module, LXD and Docker entries are one per cached object (see §17.3 for the LXD key shape and §19.6.1 for Docker’s). Snap entries are one rollup per snap name, because snap blobs are cached by hash and a name spans many revisions (§13.3).?since=windows it (§13.6). Each entry also says whether it is content or metadata (content, see below).GET /pkgproxy/v1/inforeports the daemon’s listen addresses, whether it serves the setup page, its build version (forpkgproxyctl version), and the offline state (§21), so thatpkgproxyctl setupprints the ports the daemon actually uses (§18) andpkgproxyctl openfinds the setup page (§20.5). It isn’t a stat, but it is read-only, and this socket is the one pkgproxyctl can reach without privileges; unlike a state file it can’t go stale. The offline state is here because the admin socket that controls it is not meant for everyone (gitlab.com/zygoon/pkgproxy#66).
13.6 Windowed reports: -r/?since= and the per-key bucket ladder
pkgproxyctl stats -r <since> and the API’s ?since= answer “what changed
since this point in time” for any key, so that one workload’s cost (an
apt-get install on freshly imaged machines) can be read without
subtracting all-time totals by hand.
Two designs are rejected:
- Periodic full-snapshot ring buffer (diff the closest checkpoint against the live counters): resolution is capped by the sampling interval, and the interval/retention trade-off is arbitrary.
- Full per-event access trace (
{time, key}per hit or fetch): exact, but it grows without bound and needs its own rotation and retention.
Instead each cache key (reports have always been per object, and a
windowed one should be too) has a fixed-size, multi-resolution bucket
ladder, the RRDtool/collectd pattern: recent activity at fine
resolution, consolidated into coarser fixed-size tiers as it ages (the
tiers are ladderTiers in internal/stats/ladder.go). Buckets are rolled
lazily, on the next touch of the key, not on a ticker: each bucket stores
its own start time, so a quiet key costs nothing. A query reads one tier,
never mixing two (a coarser tier already holds what a finer one still
keeps), plus the finer tiers’ open buckets, and a since older than the
ladder reaches is clamped and the clamped time returned, never silently
answered for a narrower window. statsapi-internals.md §4.3 has the
query in detail.
Because a ladder is fixed size, it persists through §13.4’s checkpoint as more fields in the same JSON, with no new file, rotation or replay. As the checkpoint marshals a copy of the maps outside the lock, that copy must deep-copy each ladder, not share its slices with the live map.
15. A well-defined default stats socket path, for both sides
Both -stats-socket-path (the daemon) and -socket-path (pkgproxyctl stats) are optional and resolve through the same
defaultStatsSocketPath() when omitted, so neither packaging nor an
operator has to keep two paths in step by hand:
- Inside a snap (
SNAP_COMMONset):$SNAP_COMMON/stats.sock. That directory is the snap’s own and reachable by every app in it with no plug, whether the snap is this project’s or another one bundling pkgproxy, and a bundledpkgproxyctlsees the sameSNAP_COMMONas its daemon. - Otherwise:
/run/pkgproxy/stats.sock, the FHS/systemdRuntimeDirectory=convention: runtime state is ephemeral and separate from the persistent cache. The daemon creates/run/pkgproxyif needed; unprivileged, without it pre-created, startup fails with a clear permission error. It is a system-daemon default, and a local test run points-cache-dirand-stats-socket-pathsomewhere writable.