The pkgproxy stats API
pkgproxy reports what its cache is doing through a small, read-only JSON
API. It is the same data pkgproxyctl stats shows, and you can read it
from your own dashboards and scripts. One list covers every kind of
download pkgproxy caches: apt packages, snaps, Go modules, LXD images and
Docker images.
If all you need is the numbers as JSON, pkgproxyctl stats -json prints
them without any of the steps below.
Where to find it
The API is served over HTTP on a UNIX socket, separate from the port apt and the other clients use. Nothing about it is reachable over the network.
| How pkgproxy runs | Socket |
|---|---|
| From the pkgproxy snap | /var/snap/pkgproxy/common/stats.sock |
| On its own, as a system daemon | /run/pkgproxy/stats.sock |
When pkgproxy is bundled inside another snap, the socket is stats.sock in
that snap’s common directory (/var/snap/<snap>/common/stats.sock).
To put the socket somewhere else, start pkgproxy with
-stats-socket-path <path> (or set PKGPROXY_STATS_SOCKET_PATH). Tell
pkgproxyctl stats about it with -socket-path <path>.
The socket is open to every local user. The API only reads, so anyone on the machine can see what is in the cache but cannot change it.
Asking it something
Any HTTP client that can talk to a UNIX socket will do. With curl:
curl --unix-socket /run/pkgproxy/stats.sock http://localhost/pkgproxy/v1/stats
The host name in the URL is ignored; localhost is as good as any.
Some examples:
# the 10 entries that saved the most, of all time
curl --unix-socket /run/pkgproxy/stats.sock 'http://localhost/pkgproxy/v1/stats?limit=10'
# only what happened in the last hour
curl --unix-socket /run/pkgproxy/stats.sock "http://localhost/pkgproxy/v1/stats?since=$(date -d '1 hour ago' +%s)"
# the addresses the daemon listens on, and whether it is offline
curl --unix-socket /run/pkgproxy/stats.sock http://localhost/pkgproxy/v1/info
Every successful answer is 200 OK with a JSON body
(Content-Type: application/json).
Endpoints
| Path | Returns |
|---|---|
/pkgproxy/v1/stats |
The cache entries that saved the most bandwidth, biggest first. |
/pkgproxy/v1/info |
The addresses the daemon listens on, and whether it is offline. |
Both take GET (and HEAD). There is nothing to write.
GET /pkgproxy/v1/stats
Query parameters, both optional:
limit=N: return at mostNentries. The default is 200. A value that is not a positive whole number is ignored and the default is used.since=T: count only what happened fromTuntil now, whereTis a time in Unix seconds (as printed bydate +%s). Withoutsince, the counts cover all time. An emptysince=is the same as leaving it out.
Without since, the answer is a JSON array of entries:
[
{"type":"gomod","key":"golang.org/x/text/@v/v0.3.0.zip","content":true,"size-bytes":6349244,"hits":2,"fetches":1,"bytes-saved":12698488},
{"type":"apt","key":"deb.debian.org/debian/pool/main/h/hello/hello_2.10-3_amd64.deb","content":true,"size-bytes":53080,"hits":1,"fetches":1,"bytes-saved":53080}
]
With since, the same array is wrapped in an object that also says which
start time the counts really cover:
{"since":"2026-10-23T10:23:00+02:00","entries":[
{"type":"gomod","key":"golang.org/x/text/@v/v0.3.0.zip","content":true,"size-bytes":6349244,"hits":2,"fetches":1,"bytes-saved":12698488}
]}
When nothing qualifies, the list is empty: [], or "entries":[]. It is
never null.
Each entry has these fields:
type: which kind of download:apt,snap,gomod,lxdordocker.key: what was downloaded (see below).content:truefor content, such as a package, a module or an image, andfalsefor metadata, the indexes that say what content there is (see below).size-bytes: its size in bytes.hits: how many times it was served from the cache, without going to the internet.fetches: how many times pkgproxy went to the internet for it, to download it or to check that its copy was still current. For snaps, see below.bytes-saved: how much the cache served of it: what the hits sent. See below.
The key depends on the type:
apt: the package or index file, including the archive it came from, such asdeb.debian.org/debian/pool/main/h/hello/hello_2.10-3_amd64.debordeb.debian.org/debian/dists/bookworm/InRelease.gomod: the Go module proxy file, such asgolang.org/x/text/@v/v0.3.0.zip, orgolang.org/x/text/@v/list.lxd: the image file, or the index or catalog that lists the images, as<remote>/<path>, such aspkgproxy-ubuntu/streams/v1/index.json.docker: an image layer or config as<repository>@<digest>, such aslibrary/debian@sha256:…. A layer pulled through two repositories shows up once for each.snap: the snap’s name. One entry covers every cached revision of that snap. Itssize-bytesis the size of the revision served most recently,fetchescounts all downloads of the snap (from the cache or not), andbytes-savedadds up what each revision saved. Downloads pkgproxy could not match to a name are counted under the empty name"".
A hit saves what it sent from the cache: the whole file for a GET, the
part asked for by a Range request, and nothing for a HEAD or for an
answer that the client’s copy is still current (304), though each of
these counts as a hit.
Only content is a download saved. Metadata changes, so pkgproxy checks
it with the internet as it ages, and a client that already has it mostly
asks only whether it changed; serving it from the cache saves little
downloading, however often it is served. content tells the two apart,
by key:
apt: content is underpool/, or an index underdists/named by its hash (by-hash/). Every other file underdists/is metadata.gomod: content is a.zipor.modfile.list,@latestand.infoare metadata.lxd: content is an image file. A file ending in.jsonis metadata.snapanddocker: only content is counted.
The ranking is the same for both: by bytes-saved, so metadata served
often can rank above content. A client that adds up what the cache saved
should add only the entries whose content is true, as
pkgproxyctl stats and the cache page do.
Only entries that were served from the cache at least once appear. Content
downloaded just once is cached but has saved nothing yet, so it is left out.
Entries are sorted by bytes-saved, biggest first.
How far back since reaches
pkgproxy keeps a short history for every cache entry: to the minute for
the last 30 minutes, to the hour for the last 2 days, and to the day for
the last 60 days. The counts start at the beginning of the minute, hour
or day that since falls in, using the finest of the three that reaches
back far enough. So a since within the last 30 minutes is exact to the
minute, one within the last 2 days to the hour, and an older one to the
day.
A since older than 60 days is moved forward to 60 days ago. The since
field in the answer is the start time that was really used, so compare it
with what you asked for: if it is later, the history did not reach back
that far. All-time totals (no since) are never trimmed.
The since field is an RFC 3339 time in the daemon’s local time zone. It
may carry a fraction of a second, for example
2026-08-24T10:32:51.065531098+02:00.
GET /pkgproxy/v1/info
{"addr":":3142","lxd_tls_addr":":3143","setup_page":true,"version":"v0.9.0"}
-
addr: the address apt, snapd, Go and Docker clients connect to, as given to pkgproxy’s-addr(:3142by default). -
lxd_tls_addr: the HTTPS address for LXD clients, as given to-lxd-tls-addr(:3143by default), or"off"when it is turned off. -
setup_page: whether the daemon serves its setup page at/onaddr(-setup-page,trueby default). Daemons before 0.9.0 leave it out; treat a missing field astrue. -
version: the version of the running pkgproxy, aspkgproxyctl versionshows it: a release such asv0.9.0, or a Go pseudo-version naming the commit of a development build. Daemons before 0.9.0 leave it out. -
offline: whether pkgproxy is refusing to contact any upstream server right now. Unlike the fields above, which are fixed when the daemon starts, it is read when you ask. Daemons before 0.9.0 leave it out; treat a missing field as “not known”, not asnever.mode:"never"(contact upstream servers as needed),"store-overloaded"(contact them, but leave alone for a while any that showed trouble), or"offline"(serve only from the cache and contact nothing). More modes may be added; treat one you do not know as one that may refuse upstream servers.upstreams: instore-overloaded, each upstream host pkgproxy is leaving alone for now, sorted by host; otherwise empty. It is nevernull. Each hashost,stateand, while open,retry_in_seconds. Thestateis"open": requests that need the host are refused until the next attempt, inretry_in_seconds(whole seconds, rounded up); or"half-open": one request is being let through to see whether the host has recovered.
The
offlineobject may gain fields, for example one saying why a mode is in effect; ignore the ones you don’t know. Changing the mode needs the admin socket: seepkgproxyctl offline -h.
While an upstream is being left alone:
{"addr":":3142","lxd_tls_addr":":3143","setup_page":true,
"offline":{"mode":"store-overloaded","upstreams":[
{"host":"api.snapcraft.io","state":"open","retry_in_seconds":42},
{"host":"deb.debian.org","state":"half-open"}]}}
pkgproxyctl setup uses this to print client settings with the right ports,
pkgproxyctl open to find the setup page to open, pkgproxyctl stats
to show the offline mode when it is not never, and pkgproxyctl version
to compare the daemon’s build with its own.
Errors
Errors come back as plain text, not JSON:
| Status | When | Body |
|---|---|---|
400 Bad Request |
since is not a whole number |
invalid since="<value>": strconv.ParseInt: parsing "<value>": invalid syntax |
404 Not Found |
The path is not one of the endpoints above (a trailing / counts) |
404 page not found |
405 Method Not Allowed |
Anything other than GET or HEAD |
Method Not Allowed |
A wrong limit is not an error; it falls back to the default.
If the socket does not exist or refuses the connection, pkgproxy is not
running, or it was started with a different -stats-socket-path.
Stability
There is no formal compatibility promise yet. The paths carry a /v1
prefix, but this API has changed before: an earlier version had separate
paths for snaps and for apt packages, and new type values such as
docker were added later. Ignore fields and types you don’t know, and
check NEWS when you upgrade.
For how the API works inside, see docs/statsapi-internals.md.