Skip to content

hrodrig/gghstats

gghstats

gghstats — self-hosted GitHub traffic beyond the 14-day window

Version Release CI codecov gghstats clones Go 1.26.3 License: MIT pkg.go.dev Go Report Card deps.dev

Repo: github.com/hrodrig/gghstats · Releases: Releases

Self-hosted dashboard and CLI for GitHub repository traffic stats. GitHub only keeps traffic for 14 days; gghstats keeps historical data indefinitely in SQLite.

If you want your own self-hosted deployment (Docker Compose, Traefik with TLS, Helm, optional Prometheus/Grafana/Loki), use the companion repo gghstats-selfhosted — it lists the supported options and example manifests.

Releases: GitHub Releases ship binaries (tarballs/zip + checksums). Multi-arch container images (linux/amd64, linux/arm64) are on GHCR as ghcr.io/hrodrig/gghstats:v<version> (same v prefix as the Git tag, e.g. v0.6.0) and :latest. Pushing a v* tag on main triggers the Release workflow (GoReleaser). Day-to-day work happens on develop (see Release workflow).

Demo

Live: gghstats.hermesrodriguez.com

GGHSTATS dashboard — repository metrics and neobrutalist UI

Table of contents

Features

  • Collects views, clones, referrers, popular paths, and star history
  • Auto-discovers repositories (or filters by org/repo rules)
  • Web dashboard with Chart.js graphs
  • Web UI languages (i18n): English (default), Spanish, German, French, and Brazilian Portuguese — sidebar EN | ES | DE | FR | PT, cookie gghstats_locale, env defaults (see Web UI languages)
  • Head to Head (H2H) at /h2h — compare two repos with weighted share scores (0–100, sum to 100); open How the H2H score is calculated on that page for the formula
  • JSON API for external integrations
  • CLI mode for fetch/report/export
  • Single binary, SQLite storage, no external DB dependency
  • Docker image on GHCR; Compose / Helm examples live in gghstats-selfhosted

Repository page charts (Clones & Views)

On each repository’s detail page, the Clones and Views bar charts are stacked from GitHub’s daily traffic API:

Segment Meaning GitHub field
Lower (theme primary color) Unique visitors or cloners that day uniques
Upper (theme info color) Total views or total clones that day count

Exact colors depend on light/dark theme (Bootstrap --bs-primary / --bs-info, overridden in the app’s neo-brutalist CSS). Chart legends (e.g. Unique / Count) follow the active UI locale. Use the tooltip on each bar for values.

Back to top

Quick start

Docker Compose (build from this repo)

cp .env.example .env
# Edit .env: set GGHSTATS_GITHUB_TOKEN (and optionally GGHSTATS_FILTER, GGHSTATS_PORT, etc.)
docker compose up -d --build

Open http://localhost:8080.

The template .env.example lists variables for the Go binary and this dev Compose file. Production (Traefik, published image, Helm, observability) is in gghstats-selfhosted.

Plain Docker

docker run -d \
  -e GGHSTATS_GITHUB_TOKEN=ghp_xxx \
  -e GGHSTATS_FILTER="your-github-user/*" \
  -p 8080:8080 \
  -v ./data:/data \
  --name gghstats \
  ghcr.io/hrodrig/gghstats:v0.5.0

Back to top

Install

Go install

go install github.com/hrodrig/gghstats/cmd/gghstats@latest

Pre-built binary and container

  • Binary archives: Releases (pick OS/arch; verify checksums.txt).
  • OCI image: ghcr.io/hrodrig/gghstats:v0.5.0 or ghcr.io/hrodrig/gghstats:latest (image tag matches the Git release tag; multi-arch manifest).

Build from source

git clone https://github.com/hrodrig/gghstats.git
cd gghstats
make install

Web UI assets (developers)

Favicons and the web app manifest live under assets/favicons/ and are embedded at build time via assets/embed.go (go:embed favicons/*). The HTTP server exposes each file under /static/<filename> (see table). Other UI assets (CSS, Bootstrap) remain under web/static/ via web/embed.go.

File Role
assets/favicons/favicon.svg Source artwork (vector). Edit this when changing the mark; regenerate the raster files below.
assets/favicons/favicon-16x16.png PNG 16×16 (tabs, legacy).
assets/favicons/favicon-32x32.png PNG 32×32.
assets/favicons/favicon.ico Multi-size ICO (16 + 32).
assets/favicons/apple-touch-icon.png 180×180 (iOS / “Add to Home Screen”).
assets/favicons/android-chrome-192x192.png 192×192 (PWA / Android).
assets/favicons/android-chrome-512x512.png 512×512 (PWA splash / install).
assets/favicons/manifest.json Web app manifest (/static/manifest.json; linked from layout.html).
assets/gghstats-main-theme-bootstrap-plain.png Documentation only: screenshot of the optional Bootstrap-plain theme (contrib/themes/example-bootstrap-plain.css); not embedded in the binary or served by the app.

Regenerating rasters after you change favicon.svg: from the repository root, with librsvg (rsvg-convert) and ImageMagick (magick) on your PATH:

SVG=assets/favicons/favicon.svg
rsvg-convert -w 16  -h 16  "$SVG" -o assets/favicons/favicon-16x16.png
rsvg-convert -w 32  -h 32  "$SVG" -o assets/favicons/favicon-32x32.png
rsvg-convert -w 180 -h 180 "$SVG" -o assets/favicons/apple-touch-icon.png
rsvg-convert -w 192 -h 192 "$SVG" -o assets/favicons/android-chrome-192x192.png
rsvg-convert -w 512 -h 512 "$SVG" -o assets/favicons/android-chrome-512x512.png
magick assets/favicons/favicon-16x16.png assets/favicons/favicon-32x32.png assets/favicons/favicon.ico

Commit everything under assets/favicons/ together so all icons stay in sync.

Back to top

Usage

Server mode (recommended)

export GGHSTATS_GITHUB_TOKEN="ghp_your_token"
gghstats serve

Server behavior:

  • Runs initial sync when database is empty
  • Re-syncs on schedule (default 1h)
  • Serves dashboard on http://localhost:8080
  • Stores data in ./data/gghstats.db
  • Liveness/readiness: GET /api/v1/healthz{"status":"ok"} (no auth; Kubernetes-style)
  • Prometheus: GET /metrics (disable with GGHSTATS_METRICS=false)
  • Listen port: GGHSTATS_PORT (default 8080) or gghstats serve --port <port>
  • First stderr line on start: version, build date, GOOS/GOARCH, listen address, masked GitHub token (XXXX....YYYY); then slog at GGHSTATS_LOG_LEVEL (default info). Every structured slog line is prefixed with gghstats so it is easy to grep in shared log streams.

CLI mode

gghstats fetch --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN"
gghstats report --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN"
gghstats export --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN" --output traffic.csv

Back to top

Examples

Start server with explicit DB path and interval

GGHSTATS_GITHUB_TOKEN=ghp_xxx \
GGHSTATS_DB=./data/gghstats.db \
GGHSTATS_SYNC_INTERVAL=30m \
gghstats serve

Fetch/report/export for one repository

Use your repository as owner/repo (example below uses a placeholder).

gghstats fetch --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN"
gghstats report --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN" --days 14
gghstats export --repo your-github-user/my-app --token "$GGHSTATS_GITHUB_TOKEN" --days 30 --output traffic-30d.csv

Run strict pre-release checks (includes container scan)

make release-check STRICT_RELEASE=1

Local release dry-run flow

Snapshot and test-release versions come from the repo VERSION file (for example 0.5.0 → artifacts 0.5.0-next), not from the latest git tag. Same pattern as pgwd.

make snapshot        # GoReleaser snapshot → dist/ (no Docker; no publish)
make test-release    # same version source; --skip=publish; still no Docker on snapshot

On a real release, push tag v<VERSION> (must match VERSION) so GoReleaser and CI use that semver.

Back to top

Configuration

All runtime configuration uses env vars (serve) or flags (fetch/report/export).

Environment file

  • Template: .env.example — copy to .env and fill in secrets. .env is gitignored (dotfiles are excluded by default in this repo).
  • Compose: docker compose loads .env from the project directory automatically.

Environment variables (serve)

Variable Default Description
GGHSTATS_GITHUB_TOKEN (required) GitHub personal access token
GGHSTATS_DB ./data/gghstats.db SQLite database path
GGHSTATS_HOST 0.0.0.0 Bind address
GGHSTATS_PORT 8080 Listen port
GGHSTATS_FILTER * Repo filter expression
GGHSTATS_INCLUDE_PRIVATE false Include private repos
GGHSTATS_SYNC_INTERVAL 1h Sync frequency
GGHSTATS_SYNC_ON_STARTUP true Full sync when the process starts; set false to serve immediately using existing SQLite data
GGHSTATS_API_TOKEN (none) If set, GET /api/repos requires matching x-api-token header (see HTTP API (JSON))
GGHSTATS_BADGE_PUBLIC true Set to false to require x-api-token on badge URLs (breaks ![…](url) in GitHub READMEs unless you use a proxy)
GGHSTATS_BADGE_CACHE_SECONDS 300 Cache-Control: max-age for badge SVG responses
GGHSTATS_PUBLIC_URL (none) Optional public base URL for embed snippets (e.g. https://gghstats.example.com); if unset, uses the request Host
GGHSTATS_LOG_LEVEL info debug, info, warn, or error (slog only; startup banner always prints)
GGHSTATS_METRICS (enabled) Set to false to disable GET /metrics
GGHSTATS_METRICS_PER_REPO false Set to true to expose per-repo Prometheus gauges (owner, repo labels); higher cardinality
GGHSTATS_CUSTOM_CSS (none) Optional regular .css file: loaded after built-in app.css at /theme/custom.css so you can tone down neo-brutalism or replace accents (see Custom UI theme)
GGHSTATS_DEFAULT_LOCALE en Default dashboard language when no cookie, ?lang=, or Accept-Language match (see Web UI languages)
GGHSTATS_ENABLED_LOCALES en,es,de Comma-separated locales shown in the sidebar selector and accepted from ?lang= / cookie

Web UI languages (i18n)

Scope: dashboard HTML and a small set of browser UI strings (sync modal, theme toggle label, chart legends). Not translated: HTTP API JSON, CLI output, structured logs, or embed badge SVG text.

Shipped locales: en, es, de, fr, pt-br (enable via GGHSTATS_ENABLED_LOCALES; default list includes all five).

How the active locale is chosen

Priority Source
1 Query ?lang=es (bookmarkable; sets cookie on response)
2 Cookie gghstats_locale (1 year, Path=/, SameSite=Lax)
3 Accept-Language header (first tag that matches an enabled locale)
4 GGHSTATS_DEFAULT_LOCALE

Theme (light/dark) stays in localStorage (gghstats-theme); language uses the cookie so the first HTML response is already translated.

Operator examples

Spanish-first instance (no selector click required for new visitors):

GGHSTATS_DEFAULT_LOCALE=es
GGHSTATS_ENABLED_LOCALES=en,es,de

Force English for one visit: open https://gghstats.example.com/?lang=en.
Permalink in Spanish: https://gghstats.example.com/h2h?lang=es&a=owner/repoA&b=owner/repoB.

In gghstats-selfhosted Compose/Helm, set the same variables next to GGHSTATS_GITHUB_TOKEN and the image tag (infra repo documents env placement under run/common/.env.example).

Adding a new locale (contributors)

Example: Portuguese (Brazil) as pt-br.

  1. Copy the canonical file (same key set as English):

    cp internal/i18n/locales/en.json internal/i18n/locales/pt-br.json
  2. Translate values only — keep every key ID unchanged (nav.repositories, h2h.help_p1, …). Use \" inside JSON strings for embedded quotes (same as en.json / es.json).

  3. Leave formula lines in English (copy verbatim from en.json):

    • h2h.help_formula_share
    • h2h.help_formula_score

    Prose around the formulas is translated; notation stays share_A, score_A, etc.

  4. Do not translate technical placeholders: owner/repo in search fields, API field names, or repo names in charts.

  5. Enable the locale in config:

    GGHSTATS_ENABLED_LOCALES=en,es,de,pt-br
    # optional default for a PT-BR-first install:
    GGHSTATS_DEFAULT_LOCALE=pt-br

    GGHSTATS_DEFAULT_LOCALE must appear in GGHSTATS_ENABLED_LOCALES.

  6. Optional code tweaks (only when needed):

    File When
    internal/i18n/i18n.go LangAttr Map locale code → BCP 47 for <html lang="…"> (e.g. pt-brpt-BR; already stubbed)
    internal/server/locale.go buildLocaleLinks Short sidebar label (e.g. pt-brPT); if omitted, the code uses strings.ToUpper(code)
  7. Browser-only strings: if you add keys used from web/static/app.js, append the key names to jsI18nPayload in internal/server/locale.go and translate them in every locale file.

  8. Verify key parity and tests:

    go test ./internal/i18n/...
  9. Smoke-test in the browser: /?lang=pt-br, /h2h?lang=pt-br, a repo page, 404 — confirm sidebar highlight and chart legends.

Key conventions

  • Nested JSON flattened to dot keys: nav.repositories, chart.legend_unique.
  • H2H help: one key per paragraph in JSON; shared formula lines stay English.
  • Missing translation in a non-English locale → fallback to English for that key.

Back to top

Custom UI theme (optional)

The shipped look is neo-brutalist on purpose—not every user or org wants heavy borders and loud chrome. If you prefer something flatter, calmer, or closer to your brand, you can supply your own CSS and keep the same binary and data layout.

Self-hosted installs can override colors and chrome without rebuilding:

  1. Copy one of the five official example themes from contrib/themes/ (for a stock-Bootstrap feel use example-bootstrap-plain.css; or write your own CSS targeting body.app-brutalist and html[data-bs-theme="dark"] body.app-brutalist).
  2. Place the file where the process can read it (e.g. mount ./data/custom-theme.css in Docker to /data/custom-theme.css).
  3. Set GGHSTATS_CUSTOM_CSS=/data/custom-theme.css (absolute or relative path; relative paths resolve from the process working directory).
  4. Restart gghstats serve. The layout adds <link href="/theme/custom.css?…"> after /static/app.css. The query string bumps when the file’s modification time changes.

Bootstrap-plain example (example-bootstrap-plain.css with GGHSTATS_CUSTOM_CSS): repository index in light mode — closer to stock Bootstrap (sans-serif, thin borders, no offset shadows):

gghstats dashboard with Bootstrap-plain optional theme

If the variable is set but the path is not a readable regular file, startup logs a warning and the UI stays default (no extra link).

Token setup

Create a GitHub personal access token the app will use for /user/repos and repository traffic (views, clones, referrers, paths) plus stars and related metadata.

  1. Go to GitHub → Settings → Developer settings → Personal access tokens (classic or fine-grained, see below).
  2. Create the token and store it only in env / secret storage (GGHSTATS_GITHUB_TOKEN).

Classic tokens (“Generate new token (classic)”)

Scope When to use it
repo Recommended default if you sync private repositories (GGHSTATS_INCLUDE_PRIVATE=true) or you hit 403 on traffic endpoints. Full repo covers private repos, traffic, and listing for repos your account can access (subject to GitHub’s own rules).
public_repo Only public repositories and GGHSTATS_INCLUDE_PRIVATE is not true. Narrow with GGHSTATS_FILTER if needed. Traffic APIs require push/admin on each repo; for repos you own, this scope is often enough for public traffic. If traffic calls fail with 403, switch to repo.

Optional: read:org if you rely on organization membership to see org repos not returned by default (uncommon for a personal token on your own org).

Fine-grained tokens

Create at Fine-grained tokens. Pick the resource owner (user or org), then either only selected repositories or all this token may access. Grant read-only (or higher) permissions that allow:

  • Listing and reading those repositories (metadata / contents as required by GitHub for your setup).
  • Access to traffic metrics for each repo (GitHub’s permission names change over time; if sync logs show 403 on /traffic/*, widen repository permissions or use a classic token with repo for that account).

Fine-grained tokens cannot be mixed with classic scope names; follow GitHub’s UI for the minimum set that allows traffic reads on your repos.

References

Filter examples

Replace your-github-user with your GitHub username or organization, and my-app / other-repo / legacy-repo with your real repository names.

GGHSTATS_FILTER="your-github-user/*"
GGHSTATS_FILTER="your-github-user/my-app,your-github-user/other-repo"
GGHSTATS_FILTER="*,!fork"
GGHSTATS_FILTER="*,!archived"
GGHSTATS_FILTER="your-github-user/*,!fork,!archived"
GGHSTATS_FILTER="*,!your-github-user/legacy-repo"

HTTP API (JSON)

gghstats exposes a small read-only JSON surface for probes and integrations. There is no generic REST CRUD layer; everything else is the HTML UI or the CLI.

GET /api/v1/healthz

Purpose Liveness / readiness style probe (same path string as many Kubernetes configs).
Auth None — public.
Response 200 with body {"status":"ok"} and Content-Type: application/json.
curl -sS http://localhost:8080/api/v1/healthz
# {"status":"ok"}

GET /api/v1/badge/{owner}/{repo}

Purpose shields.io-style SVG badge for embedding in a repository README (![label](url)).
Auth Public by default (GGHSTATS_BADGE_PUBLIC unset or not false). Set GGHSTATS_BADGE_PUBLIC=false to require the same x-api-token as /api/repos (not usable from GitHub image embeds without a proxy).
Response 200 image/svg+xml with Cache-Control: public, max-age=… (default 300s).
Alias Same handler for …/repo.svg.

Query parameters:

Parameter Values Default
metric clones, clones_30d, views, stars clones
style flat, flat-square flat
label Custom left label (URL-encoded) Metric name (clones, clones 30d, …)

Semantics match the web UI / GET /api/repos: clones and views are lifetime sums in SQLite; clones_30d is the rolling 30-day UTC window; stars is the latest synced metadata value.

curl -sS 'http://localhost:8080/api/v1/badge/your-user/your-repo?metric=clones' -o /tmp/badge.svg
[![gghstats clones](https://gghstats.example.com/api/v1/badge/your-user/your-repo?metric=clones)](https://gghstats.example.com/your-user/your-repo)

On each repository page, the Embed badge card builds this Markdown (metric selector + copy button). Optional GGHSTATS_PUBLIC_URL sets the host in snippets when the app sits behind a reverse proxy.

GET /api/v1/repos/{owner}/{repo}/traffic

Purpose Daily clone and view time series for one repository (for Grafana, scripts, or external charts).
Auth Same as GET /api/repos: requires GGHSTATS_API_TOKEN and header x-api-token. Returns 404 when the API is disabled (token unset).
CORS Access-Control-Allow-Origin: * on success.

Query parameters:

Parameter Meaning
days Rolling window in UTC calendar days, inclusive of today. Default 30. Use 0 for all dates stored in SQLite for this repo. Maximum 3660.

Response (200):

Field Type Meaning
name string owner/repo
days number Echo of the days query (after defaulting).
from, to string YYYY-MM-DD bounds used for the query (inclusive).
clones array Daily clone rows: date, count, uniques (GitHub traffic semantics).
views array Daily view rows: same shape.

Missing days in the window are omitted (not zero-filled). This matches the repo detail charts, which only plot days with rows in the database.

curl -sS -H "x-api-token: $GGHSTATS_API_TOKEN" \
  'http://localhost:8080/api/v1/repos/your-user/your-repo/traffic?days=30'

POST /api/v1/sync and GET /api/v1/sync

Purpose Manual sync with GitHub (same job as the scheduler): list repos, refresh metadata, pull traffic.
Auth Same as GET /api/repos: GGHSTATS_API_TOKEN + x-api-token. Returns 404 when the API is disabled.
Concurrency Only one sync runs at a time. POST while a run is active returns 409 with sync_in_progress. The scheduler skips its tick if a manual sync is running.

POST /api/v1/sync — starts a background full sync (all repos matching GGHSTATS_FILTER); responds 202 Accepted with {"status":"started","scope":"all"} when accepted.

POST /api/v1/sync?repo=owner/name — syncs only that repository (fast; does not wait for the full list). Response includes "scope":"repo" and "repo":"owner/name".

GET /api/v1/sync — status JSON:

Field Meaning
running true while a sync is in progress
scope all or repo while running
repo owner/name when scope is repo
last_started_at, last_finished_at RFC3339 timestamps (UTC) of the last run
last_error Non-empty if the last run failed
# Sync all repos (respects GGHSTATS_FILTER)
curl -sS -X POST -H "x-api-token: $GGHSTATS_API_TOKEN" \
  http://localhost:8080/api/v1/sync

# Sync one repo only
curl -sS -X POST -H "x-api-token: $GGHSTATS_API_TOKEN" \
  'http://localhost:8080/api/v1/sync?repo=your-user/your-repo'

# Poll status
curl -sS -H "x-api-token: $GGHSTATS_API_TOKEN" \
  http://localhost:8080/api/v1/sync

When GGHSTATS_API_TOKEN is set, the sidebar shows Sync all on the index and Sync this repo on a repository page. The first click opens a modal to enter the token; it is stored in sessionStorage (same origin only). After a successful single-repo sync, the repo page reloads to refresh charts.

GET /api/repos

Purpose Snapshot of all non-hidden repositories in the local SQLite DB with aggregate counters.
Auth Required when GGHSTATS_API_TOKEN is set: send header x-api-token: <value> matching that env var exactly. If GGHSTATS_API_TOKEN is unset, requests to this path return 404 Not Found (API disabled by default).
CORS Successful responses include Access-Control-Allow-Origin: * so browser dashboards on another origin can read the JSON (you still must keep the API token secret).
Sort order Items are always returned in total_views descending (see handleAPIRepos in the server code). This is independent of the web index sort= query parameter.
Errors 401 with JSON {"error":"unauthorized"} if the token header is missing or wrong. 500 with JSON {"error":"…"} on database or encoding failures.

Response shape (200):

Field Type Meaning
total_count number Count of repos in items.
total_stars number Sum of stars across repos.
total_forks number Sum of forks across repos.
total_views number Sum of total_views across repos.
total_clones number Sum of total_clones across repos.
items array One object per repository (see table below).

Each element of items matches RepoSummary JSON tags:

Field Type Notes
name string owner/repo
description string
stars, forks, watchers, issues, prs number From last GitHub metadata sync.
fork boolean
parent_full_name string Upstream if fork (may be empty / omitted).
archived boolean
total_views, total_uniques number Lifetime sums of daily GitHub view traffic stored in SQLite.
total_clones, clone_uniques number Lifetime sums of daily clone traffic.
clones_1d number Clone count for the latest UTC day with data among today and yesterday (GitHub often omits today's bucket until later).
clones_7d number Sum of daily clone counts in the last 7 calendar days (UTC); missing days count as 0.
clones_30d number Sum of daily clone counts in the last 30 calendar days (UTC); missing days count as 0.

Example request:

curl -sS -H "x-api-token: $GGHSTATS_API_TOKEN" http://localhost:8080/api/repos

Example response (truncated to one repo):

{
  "total_count": 1,
  "total_stars": 10,
  "total_forks": 2,
  "total_views": 150,
  "total_clones": 42,
  "items": [
    {
      "name": "your-github-user/my-app",
      "description": "Example",
      "stars": 10,
      "forks": 2,
      "watchers": 3,
      "issues": 1,
      "prs": 0,
      "fork": false,
      "archived": false,
      "total_views": 150,
      "total_uniques": 80,
      "total_clones": 42,
      "clone_uniques": 12,
      "clones_1d": 2,
      "clones_7d": 5,
      "clones_30d": 7
    }
  ]
}

GET /metrics

Purpose Prometheus text / OpenMetrics exposition for scraping.
Auth None — treat network access like any other unauthenticated metrics endpoint.
Disabled When GGHSTATS_METRICS=false, the route is omitted (returns 404).

Domain series (besides HTTP and Go runtime): gghstats_repos_total, gghstats_db_size_bytes, gghstats_last_sync_timestamp_seconds, gghstats_sync_duration_seconds, gghstats_github_api_requests_total, gghstats_github_rate_limit_remaining. Refreshed on each scrape and after each successful sync.

Per-repo gauges (optional, GGHSTATS_METRICS_PER_REPO=true): gghstats_repo_stars, gghstats_repo_forks, gghstats_repo_clones, gghstats_repo_views, gghstats_repo_clones_1d, gghstats_repo_clones_7d, gghstats_repo_clones_30d — same semantics as the dashboard (1d)/(7d)/(30d) columns. Use with gghstats-selfhosted observability.

See Security and quality for the local tooling that scans this surface in CI.

Back to top

Typical scenarios

Track all repositories for one owner

export GGHSTATS_FILTER="your-github-user/*"
gghstats serve

Exclude forks and archived repositories

export GGHSTATS_FILTER="your-github-user/*,!fork,!archived"
gghstats serve

Protect API with token

Full field list, error codes, and probe endpoint are documented under HTTP API (JSON).

export GGHSTATS_API_TOKEN="my-api-token"
gghstats serve
curl -H "x-api-token: my-api-token" http://localhost:8080/api/repos

Generate periodic CSV report

gghstats export --repo your-github-user/my-app --days 30 --output traffic-30d.csv

Back to top

Deployments

Production and optional observability (Traefik + TLS, Prometheus / Grafana stack, Helm) live in a separate repository so release versioning applies to the application only. For self-hosted setups, start here:

github.com/hrodrig/gghstats-selfhosted

Clone that repo on your server, copy .env.example.env, and follow its README for the deployment path you choose. For the optional metrics/logs stack, see run/docker-compose/observability/README.md (on the default branch).

Back to top

Troubleshooting

GGHSTATS_GITHUB_TOKEN is required

Set GGHSTATS_GITHUB_TOKEN in your shell or .env file before running serve.

Dashboard shows no repositories

  • Wait for the initial sync to finish.
  • Verify filter rules (GGHSTATS_FILTER) are not excluding all repos.
  • Confirm token scopes allow listing repos and reading traffic (see 403 note there).

Port 8080 already in use

Set another listen port via env or flag:

export GGHSTATS_PORT=9090
gghstats serve
# or: gghstats serve --port 9090

API returns 401 unauthorized

Confirm request header exactly matches configured token. For 404 on /api/repos, the API is disabled until you set GGHSTATS_API_TOKEN (see HTTP API (JSON)).

curl -H "x-api-token: $GGHSTATS_API_TOKEN" http://localhost:8080/api/repos

Back to top

Release workflow

  • Branch policy: day-to-day development on develop; tagged releases are cut from main.
  • VERSION file: semantic version without v (for example 0.3.2). Must match the static Version badge at the top of this README.
  • Git tags: annotated tag with v prefix (for example v0.3.2), on the commit you want released.

Default: publish from GitHub Actions (no local GoReleaser required)

Pushing a tag matching v* runs .github/workflows/release.yml: make release-check, then goreleaser release --clean with GITHUB_TOKEN (releases + GHCR).

# 1) On develop: land changes, bump version if needed
git checkout develop
make release-check                    # optional: STRICT_RELEASE=1 (adds docker image scan)
make test-release                     # optional: dry-run GoReleaser (VERSION → *-next; no publish)

# 2) Update VERSION, README version badge, CHANGELOG; commit on develop

# 3) Merge into main (PR or fast-forward), then tag and push
git checkout main && git pull origin main
git merge --ff-only develop           # or: merge via GitHub PR
git push origin main

git tag -a v0.3.2 -m "Release 0.3.2"
git push origin v0.3.2                # triggers Release workflow — builds and publishes artifacts

For the next release after 0.5.2, bump VERSION, update the badge and CHANGELOG, then tag main with the matching v* tag.

Optional: publish from your machine

If you run GoReleaser locally instead of relying on CI, checkout main at the tagged commit, export GITHUB_TOKEN (or GH_TOKEN) with repo and packages access to push GHCR, then:

make release                          # runs release-check then goreleaser release --clean

Developer checklist

  • Update CHANGELOG.md (move [Unreleased] into the new version section).
  • Keep VERSION (no v), README Version badge, and CHANGELOG in sync; the OCI tag uses the same v prefix as the Git tag. Deployment image pins live in gghstats-selfhosted.
  • Ensure CI and Security workflows are green before pushing the release tag.
  • Docker: Dockerfile is for local make docker-build / docker-scan. GoReleaser uses Dockerfile.release (pre-built Linux binaries; same pattern as multi-arch release images).

Back to top

Security and quality

make tools
make lint
make test
make security
make release-check

Security tooling:

  • govulncheck
  • gocyclo (complexity gate)
  • grype (filesystem image/source scanning)

Back to top

Database

SQLite path comes from GGHSTATS_DB. Main tables: repos, views, clones, referrers, paths, stars.

  • Upserts are idempotent
  • Startup migration uses PRAGMA user_version

Concurrency (reads while sync writes)

Clarification for operators and contributors — not a scalability guarantee.

  • WAL mode is enabled when the database is opened (?_journal_mode=WAL), so the HTTP UI and API can read while the background sync writes daily traffic rows.
  • SQLite allows many readers with WAL, but still one writer at a time per database file (not row-level locking like PostgreSQL).
  • At most one sync cycle runs at a time (sync.Coordinator): scheduled, startup, and manual sync share that lock; a tick is skipped if a run is already in progress.
  • Single process, single DB file is the intended deployment: one gghstats serve instance per GGHSTATS_DB. Do not point multiple writers at the same SQLite file.
  • Consistency during a long sync: each repo is upserted independently; the index may briefly show a mix of old and new rows until the run finishes. There is no snapshot transaction across the whole repo list.
  • Pragmatic scope: sync time is dominated by the GitHub API, not SQLite; typical self-hosted load (one dashboard, periodic sync) fits this model. Very high write concurrency or multi-instance writes would need extra tuning (for example busy_timeout) or a different store — out of scope for the default design.

Sync serialization (coordinator)

Clarification — not a separate DB write lock.

  • sync.Coordinator uses a sync.Mutex so only one full sync cycle runs at a time (startup, scheduled tick, or POST /api/v1/sync). That is application-level mutual exclusion between sync runs, not a mutex around every Upsert*.
  • Inside a run, sync.Run iterates repos sequentially (for _, repo := range repos); each repo triggers several GitHub GETs, then SQLite upserts. There is no worker pool or parallel repo sync.
  • SQLite still enforces one writer at a time; the coordinator avoids overlapping sync goroutines, and the sequential loop avoids multiplying concurrent writers from a single process.

GitHub API usage and rate limits

Clarification — no built-in backoff today.

  • Authentication: a personal access token via GGHSTATS_GITHUB_TOKEN (Authorization: Bearer … on REST calls). There is no GitHub App or OAuth flow in-tree.
  • Scheduler: GGHSTATS_SYNC_INTERVAL (default 1h) starts the next cycle only when the previous one finished; if a run is still in progress, the tick is skipped (ErrInProgress). Set GGHSTATS_SYNC_ON_STARTUP=false to skip the blocking full sync at process start (UI uses existing DB; trigger sync via the dashboard or POST /api/v1/sync).
  • Per repo, a typical sync issues several requests (metadata, open PRs, views, clones, referrers, paths; optional full stargazer history when star sync is enabled). Failures on individual endpoints are logged and the repo loop continues (slog.Warn, no abort of the whole run).
  • No explicit handling of 429, 403 rate-limit responses, Retry-After, or exponential backoff in internal/github. A non-200 response becomes an error for that call; traffic endpoints are best-effort per repo.
  • Pragmatic scope: for a personal or small-org PAT and hourly (or slower) sync, GitHub limits are usually enough. Very large repo lists, aggressive intervals, or star-history on huge repos can hit limits — then increase the interval, narrow GGHSTATS_FILTER, or expect partial data until a later run succeeds.

Back to top

Community standards

  • License: LICENSE
  • Contributing: CONTRIBUTING.md
  • Code of conduct: CODE_OF_CONDUCT.md
  • Security policy: SECURITY.md
  • Changelog: CHANGELOG.md
  • CODEOWNERS: .github/CODEOWNERS

Thanks for using and contributing to gghstats.

Back to top

Star History

Star History Chart

Back to top

Acknowledgments

Hats off to ghstats by vladkens: a self-hosted GitHub traffic dashboard in Rust that also keeps historical traffic beyond GitHub’s short default window, with SQLite and a small deployment story. gghstats is a separate Go implementation and design, but that project deserves credit as important prior work in the same problem space.

Thanks also to git-clone-stats by taylorwilsdon: a self-hosted GitHub clone and traffic analytics stack in Python with SQLite (or Firestore), a minimal HTML/JS dashboard, and shields.io-style badges for README embeds. The badge endpoint and “copy Markdown” embed flow in gghstats follow a similar idea; this project is independent Go code, not a port.

Back to top

License

MIT

About

Self-hosted GitHub traffic stats dashboard and CLI beyond 14 days

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors