{"id":47627931,"url":"https://github.com/cplieger/registry-stats","last_synced_at":"2026-07-05T01:01:40.786Z","repository":{"id":342717910,"uuid":"1174909556","full_name":"cplieger/registry-stats","owner":"cplieger","description":"Track how many times your container images are pulled — with a ready-made Grafana dashboard","archived":false,"fork":false,"pushed_at":"2026-06-27T20:29:36.000Z","size":623,"stargazers_count":1,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-27T22:19:50.185Z","etag":null,"topics":["analytics","auto-discovery","distroless","docker","docker-hub","download-stats","ghcr","golang","grafana","homelab","monitoring","registry","wildcard"],"latest_commit_sha":null,"homepage":null,"language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cplieger.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-07T01:23:11.000Z","updated_at":"2026-06-27T20:26:28.000Z","dependencies_parsed_at":"2026-06-03T01:00:26.572Z","dependency_job_id":null,"html_url":"https://github.com/cplieger/registry-stats","commit_stats":null,"previous_names":["cplieger/docker-registry-stats","cplieger/registry-stats"],"tags_count":61,"template":false,"template_full_name":null,"purl":"pkg:github/cplieger/registry-stats","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cplieger%2Fregistry-stats","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cplieger%2Fregistry-stats/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cplieger%2Fregistry-stats/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cplieger%2Fregistry-stats/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cplieger","download_url":"https://codeload.github.com/cplieger/registry-stats/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cplieger%2Fregistry-stats/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35140189,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["analytics","auto-discovery","distroless","docker","docker-hub","download-stats","ghcr","golang","grafana","homelab","monitoring","registry","wildcard"],"created_at":"2026-04-01T22:59:30.926Z","updated_at":"2026-07-05T01:01:39.544Z","avatar_url":"https://github.com/cplieger.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# registry-stats\n\n[![Image Size](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/registry-stats/badges/size.json)](https://github.com/cplieger/registry-stats/pkgs/container/registry-stats)\n![Platforms](https://img.shields.io/badge/platforms-amd64%20%7C%20arm64-blue)\n![base: Distroless](https://img.shields.io/badge/base-Distroless_nonroot-4285F4?logo=google)\n[![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/registry-stats/badges/coverage.json)](https://github.com/cplieger/registry-stats/actions/workflows/coverage.yml)\n[![Mutation](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/registry-stats/badges/mutation.json)](https://github.com/cplieger/registry-stats/issues?q=label%3Agremlins-tracker)\n[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13219/badge)](https://www.bestpractices.dev/projects/13219)\n[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/registry-stats/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/registry-stats)\n[![SBOM](https://img.shields.io/badge/SBOM-SPDX-1D4ED8)](https://github.com/cplieger/registry-stats/releases)\n\nTrack how many times your container images are pulled — with a ready-made Grafana dashboard.\n\n## What it does\n\nWhen you publish a container image to Docker Hub or GitHub Container Registry (GHCR), each registry tracks how many times that image has been downloaded — but there's no built-in way to see those numbers over time, compare trends, or get alerts. Registry Stats solves this by polling the registries on a schedule and exposing the download counts as Prometheus metrics for dashboards and alerting.\n\n- **Prometheus metrics** (`/metrics`) — pull counts as gauges, scraped by any Prometheus-compatible collector for native Grafana dashboards\n- Supports both explicit repos (`myuser/myapp`) and owner wildcards (`myuser/*`) to automatically discover and track all public repos for an owner. Wildcards are resolved on each poll cycle, so newly published images are picked up automatically.\n\n### Why this design\n\n- **Stateless** — no on-disk persistence required. The app polls registries and exposes current counts as Prometheus metrics. Time-series history lives in your Prometheus/Mimir backend.\n- **Minimal dependencies** — no non-`cplieger` runtime deps beyond `golang.org/x/sync`; the `cplieger` `httpx` / `health` / `metrics` libraries supply retry/backoff, the health probe, and Prometheus exposition. Small, auditable supply chain.\n- **Distroless, rootless container** — runs as `nonroot` on `gcr.io/distroless/static-debian13` with no shell or package manager, minimising attack surface.\n- **Public repos only** — avoids credential management entirely.\n\n### Limitations\n\n- **Public repositories only.** Docker Hub uses the unauthenticated API.\n  GHCR download counts are scraped from public package pages. Private\n  repositories and packages are not supported.\n- **GHCR scraping is fragile.** Download counts and package listings\n  are extracted from GitHub's HTML, not an official API. If GitHub\n  changes their page structure, scraping will break. The container\n  logs a clear error with a link to open an issue when this happens.\n- **No historical backfill.** The registries only expose current totals.\n  Time-series data is built by your Prometheus backend as scrapes\n  accumulate.\n\n## Quick start\n\nThe image is published to both `ghcr.io/cplieger/registry-stats` and `docker.io/cplieger/registry-stats` — use whichever registry you prefer.\n\n```yaml\nservices:\n  registry-stats:\n    image: ghcr.io/cplieger/registry-stats:latest\n    container_name: registry-stats\n    restart: unless-stopped\n\n    environment:\n      TZ: \"Europe/Paris\"\n      DOCKERHUB_REPOS: \"\"  # owner/repo or owner/* format, comma-separated\n      GHCR_REPOS: \"\"  # owner/package or owner/* format, comma-separated\n      LOG_LEVEL: \"info\"\n      POLL_INTERVAL_HOURS: \"1\"  # 0 = collect once then serve\n\n    ports:\n      - \"9100:9100\"\n```\n\n## Configuration reference\n\n### Environment variables\n\n| Variable              | Description                                                                                                                                                                                              | Default        | Required |\n| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------- |\n| `TZ`                  | Container timezone                                                                                                                                                                                       | `Europe/Paris` | No       |\n| `DOCKERHUB_REPOS`     | Comma-separated list of Docker Hub repositories to track. Use `owner/repo` for specific repos or `owner/*` to auto-discover all public repos for an owner (e.g. `myuser/*,otheruser/specific-app`)       | ``             | No       |\n| `GHCR_REPOS`          | Comma-separated list of public GHCR packages to track. Use `owner/package` for specific packages or `owner/*` to auto-discover all public packages for an owner (e.g. `myuser/*,otheruser/specific-app`) | ``             | No       |\n| `LOG_LEVEL`           | Logging verbosity: `debug`, `info`, `warn`, or `error`. Unrecognized values fall back to `info`                                                                                                          | `info`         | No       |\n| `POLL_INTERVAL_HOURS` | Hours between collection cycles. Set to 0 to collect once and then only serve metrics (no recurring polls). Wildcards are re-expanded on each cycle, picking up newly published images                   | `1`            | No       |\n| `ENABLE_METRICS`      | Enable Prometheus metrics endpoint                                                                                                                                                                       | `true`         | No       |\n| `LISTEN_ADDR`         | TCP listen address for the HTTP server in `host:port` form. The port must match the published container port                                                                                             | `:9100`        | No       |\n\n### Ports\n\n| Port   | Description                                        |\n| ------ | -------------------------------------------------- |\n| `9100` | HTTP server (Prometheus metrics + health endpoint) |\n\n## API reference\n\nThe HTTP server listens on port 9100.\n\n### Endpoints\n\n#### `GET /api/health`\n\nReturns `{\"status\":\"ok\"}` when healthy, or `{\"status\":\"unready\",\"reason\":\"...\"}` with HTTP 503\nwhen the most recent collect cycle returned no data (every configured registry failed, or no repos\nare configured). The marker is set healthy as soon as the HTTP API is listening, so a slow first\ncollect cannot trip the Docker healthcheck grace window; it flips to 503 only once a collect cycle\ncompletes with an empty result. Used as the Docker healthcheck endpoint.\n\n#### `GET /metrics`\n\nPrometheus text format metrics. Includes:\n\n- `registrystats_image_pulls_total{registry,owner,repo}` — current pull count per image\n- `registrystats_image_tags{registry,owner,repo}` — tag count per image\n- `registrystats_http_requests_total{method,path,status}` — HTTP request counters\n- `registrystats_http_request_duration_seconds` — request latency histogram\n- `registrystats_collects_total{source}` — total collect runs per source (successful + failed; `collect_errors_total` is the failed subset, so `collect_errors_total / collects_total` is the per-source failure ratio)\n- `registrystats_collect_errors_total{source}` — failed collects per source\n- `registrystats_collect_duration_seconds` — collect cycle duration histogram\n- `process_goroutines`, `process_heap_bytes`, `process_uptime_seconds` — runtime metrics\n\nDisabled when `ENABLE_METRICS=false`.\n\n## Grafana integration\n\nRegistry Stats exposes Prometheus metrics at `/metrics`. The included\n`grafana-dashboard.json` uses PromQL and requires only a standard\nPrometheus datasource — no plugins needed.\n\n### Setup\n\n1. Add a scrape target for `registry-stats:9100` in your\n   Prometheus/Alloy/Grafana Agent config\n2. Import `grafana-dashboard.json` in Grafana\n3. Select your Prometheus/Mimir datasource when prompted\n\n**Alloy example:**\n\n```alloy\nprometheus.scrape \"registry_stats\" {\n  targets         = [{ __address__ = \"registry-stats:9100\" }]\n  forward_to      = [prometheus.remote_write.default.receiver]\n  scrape_interval = \"60s\"\n  job_name        = \"registry-stats\"\n  metrics_path    = \"/metrics\"\n}\n```\n\n**Prometheus example (`prometheus.yml`):**\n\n```yaml\nscrape_configs:\n  - job_name: registry-stats\n    scrape_interval: 60s\n    static_configs:\n      - targets: [\"registry-stats:9100\"]\n```\n\nThe dashboard shows cumulative downloads, daily deltas, package\noverview, and tracked package count — all via standard PromQL.\n\n## Healthcheck\n\nThe container includes a built-in Docker healthcheck using a marker file at `/tmp/.healthy`. The marker is created as soon as the HTTP API is listening, then refreshed after every collection cycle: a cycle that collects at least one repo keeps the marker present, and a cycle in which every configured registry fails removes it. The `health` subcommand (`/registry-stats health`) checks for this file and exits 0 when healthy. The first collect runs in the background so a slow initial poll (GHCR paces each package by a few seconds) cannot exceed the Docker healthcheck grace window and trigger a restart loop: the container reports healthy on boot, then reflects the first cycle's real outcome once it finishes. If both registries are unreachable on first boot the marker flips to unhealthy after that cycle and recovers on the next successful poll. Partial failures are tolerated: one successful repo keeps the container healthy. Wildcard expansion failures alone do not cause unhealthy status if explicit repos still succeed.\n\n## Security\n\n**No vulnerabilities found.** All scans clean across the full scanner battery.\n\n| Tool                                                                | Result                              |\n| ------------------------------------------------------------------- | ----------------------------------- |\n| [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) | No vulnerabilities in call graph    |\n| [golangci-lint](https://golangci-lint.run/) (gosec)                 | 0 issues                            |\n| [trivy](https://trivy.dev/)                                         | 0 vulnerabilities (distroless base) |\n| [grype](https://github.com/anchore/grype)                           | 0 vulnerabilities                   |\n| [gitleaks](https://github.com/gitleaks/gitleaks)                    | No secrets detected                 |\n| [semgrep](https://semgrep.dev/)                                     | 1 info (false positive)             |\n| [hadolint](https://github.com/hadolint/hadolint)                    | Clean                               |\n\nPrometheus metrics endpoint designed for internal scraping.\nNo authentication required (standard for internal metrics APIs).\nRuns as `nonroot` on a distroless base image with no shell. The\nHTTP client follows redirects only\nwithin a host allowlist (`httpx.DockerGitHubRedirectPolicy`:\n`docker.com` / `github.com` / `githubusercontent.com`, 5-hop\ncap) so a compromised or misconfigured upstream cannot bounce\nthe polling request to an arbitrary third-party host (the\nregistries legitimately redirect to their own CDNs/blob\nstores).\n\n**Details for advanced users:** URL path segments validated via\n`isSafeURLSegment` (rejects `/%\\?#@:`). Response bodies capped\nvia `io.LimitReader` (10 MB JSON, 2 MB HTML). HTTP server sets\nall five timeouts. Retry-After response headers are honoured on\n429/503 responses (capped at the configured retry backoff\nceiling). A GHCR page that exceeds the HTML body cap is treated\nas a format-change signal, not silently truncated. Semgrep flags\n`math/rand/v2` usage, which is correct for jitter timing (not\ncrypto).\n\n### Hardened deployment\n\nTo lock the container down further, layer these directives onto\nthe Quick start service:\n\n```yaml\n    read_only: true\n    cap_drop:\n      - ALL\n    security_opt:\n      - no-new-privileges:true\n    tmpfs:\n      - \"/tmp:size=1m,mode=1777,noexec,nosuid,nodev\"\n```\n\n`read_only: true` makes the root filesystem read-only, so the\nfile-marker health probe needs a writable `/tmp`; the tmpfs\nsupplies it. `size=1m` is ample for the bare `/tmp/.healthy`\nmarker, the only thing registry-stats writes to disk.\n\n## Dependencies\n\nAll dependencies are updated automatically via [Renovate](https://github.com/renovatebot/renovate) and pinned by digest or version for reproducibility.\n\n| Dependency         | Source                                                           |\n| ------------------ | ---------------------------------------------------------------- |\n| golang             | [Go](https://hub.docker.com/_/golang)                            |\n| Distroless static  | [Distroless](https://github.com/GoogleContainerTools/distroless) |\n| golang.org/x/sync  | [Go stdlib](https://pkg.go.dev/golang.org/x/sync)                |\n| pgregory.net/rapid | [pkg.go.dev](https://pkg.go.dev/pgregory.net/rapid)              |\n\n## Credits\n\nThis is an original tool that builds upon [Docker Hub API](https://docs.docker.com/docker-hub/api/latest/).\n\n## Contributing\n\nIssues and pull requests are welcome. Please open an issue first for\nlarger changes so the approach can be discussed before implementation.\n\n## Disclaimer\n\nThis project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.\n\nThis project was built with AI-assisted tooling using [Claude Opus](https://www.anthropic.com/claude) and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.\n\n## License\n\nThis project is licensed under the [GNU General Public License v3.0](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcplieger%2Fregistry-stats","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcplieger%2Fregistry-stats","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcplieger%2Fregistry-stats/lists"}