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).
Live: gghstats.hermesrodriguez.com
- Demo
- Features
- Repository page charts
- Quick start
- Install
- Web UI assets (developers)
- Usage
- Examples
- Configuration
- Web UI languages (i18n)
- Environment file
- Custom UI theme (optional)
- HTTP API (JSON)
- Typical scenarios
- Deployments
- Troubleshooting
- Release workflow
- Security and quality
- Database
- Community standards
- Star History
- Acknowledgments
- License
- 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
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.
cp .env.example .env
# Edit .env: set GGHSTATS_GITHUB_TOKEN (and optionally GGHSTATS_FILTER, GGHSTATS_PORT, etc.)
docker compose up -d --buildOpen 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.
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.0go install github.com/hrodrig/gghstats/cmd/gghstats@latest- Binary archives: Releases (pick OS/arch; verify
checksums.txt). - OCI image:
ghcr.io/hrodrig/gghstats:v0.5.0orghcr.io/hrodrig/gghstats:latest(image tag matches the Git release tag; multi-arch manifest).
git clone https://github.com/hrodrig/gghstats.git
cd gghstats
make installFavicons 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.icoCommit everything under assets/favicons/ together so all icons stay in sync.
export GGHSTATS_GITHUB_TOKEN="ghp_your_token"
gghstats serveServer 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 withGGHSTATS_METRICS=false) - Listen port:
GGHSTATS_PORT(default8080) orgghstats serve --port <port> - First stderr line on start: version, build date,
GOOS/GOARCH, listen address, masked GitHub token (XXXX....YYYY); then slog atGGHSTATS_LOG_LEVEL(defaultinfo). Every structured slog line is prefixed withgghstatsso it is easy to grep in shared log streams.
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.csvGGHSTATS_GITHUB_TOKEN=ghp_xxx \
GGHSTATS_DB=./data/gghstats.db \
GGHSTATS_SYNC_INTERVAL=30m \
gghstats serveUse 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.csvmake release-check STRICT_RELEASE=1Snapshot 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 snapshotOn a real release, push tag v<VERSION> (must match VERSION) so GoReleaser and CI use that semver.
All runtime configuration uses env vars (serve) or flags (fetch/report/export).
- Template:
.env.example— copy to.envand fill in secrets..envis gitignored (dotfiles are excluded by default in this repo). - Compose:
docker composeloads.envfrom the project directory automatically.
| 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  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 |
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).
| 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.
Spanish-first instance (no selector click required for new visitors):
GGHSTATS_DEFAULT_LOCALE=es
GGHSTATS_ENABLED_LOCALES=en,es,deForce 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).
Example: Portuguese (Brazil) as pt-br.
-
Copy the canonical file (same key set as English):
cp internal/i18n/locales/en.json internal/i18n/locales/pt-br.json
-
Translate values only — keep every key ID unchanged (
nav.repositories,h2h.help_p1, …). Use\"inside JSON strings for embedded quotes (same asen.json/es.json). -
Leave formula lines in English (copy verbatim from
en.json):h2h.help_formula_shareh2h.help_formula_score
Prose around the formulas is translated; notation stays
share_A,score_A, etc. -
Do not translate technical placeholders:
owner/repoin search fields, API field names, or repo names in charts. -
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-brGGHSTATS_DEFAULT_LOCALEmust appear inGGHSTATS_ENABLED_LOCALES. -
Optional code tweaks (only when needed):
File When internal/i18n/i18n.goLangAttrMap locale code → BCP 47 for <html lang="…">(e.g.pt-br→pt-BR; already stubbed)internal/server/locale.gobuildLocaleLinksShort sidebar label (e.g. pt-br→PT); if omitted, the code usesstrings.ToUpper(code) -
Browser-only strings: if you add keys used from
web/static/app.js, append the key names tojsI18nPayloadininternal/server/locale.goand translate them in every locale file. -
Verify key parity and tests:
go test ./internal/i18n/... -
Smoke-test in the browser:
/?lang=pt-br,/h2h?lang=pt-br, a repo page, 404 — confirm sidebar highlight and chart legends.
- 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.
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:
- Copy one of the five official example themes from
contrib/themes/(for a stock-Bootstrap feel useexample-bootstrap-plain.css; or write your own CSS targetingbody.app-brutalistandhtml[data-bs-theme="dark"] body.app-brutalist). - Place the file where the process can read it (e.g. mount
./data/custom-theme.cssin Docker to/data/custom-theme.css). - Set
GGHSTATS_CUSTOM_CSS=/data/custom-theme.css(absolute or relative path; relative paths resolve from the process working directory). - 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):
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).
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.
- Go to GitHub → Settings → Developer settings → Personal access tokens (classic or fine-grained, see below).
- Create the token and store it only in env / secret storage (
GGHSTATS_GITHUB_TOKEN).
| 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).
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 withrepofor 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.
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"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.
| 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"}| Purpose | shields.io-style SVG badge for embedding in a repository README (). |
| 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[](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.
| 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'| 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/syncWhen 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.
| 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/reposExample 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
}
]
}| 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.
export GGHSTATS_FILTER="your-github-user/*"
gghstats serveexport GGHSTATS_FILTER="your-github-user/*,!fork,!archived"
gghstats serveFull 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/reposgghstats export --repo your-github-user/my-app --days 30 --output traffic-30d.csvProduction 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).
Set GGHSTATS_GITHUB_TOKEN in your shell or .env file before running serve.
- 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).
Set another listen port via env or flag:
export GGHSTATS_PORT=9090
gghstats serve
# or: gghstats serve --port 9090Confirm 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- Branch policy: day-to-day development on
develop; tagged releases are cut frommain. VERSIONfile: semantic version withoutv(for example0.3.2). Must match the static Version badge at the top of this README.- Git tags: annotated tag with
vprefix (for examplev0.3.2), on the commit you want released.
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 artifactsFor the next release after 0.5.2, bump VERSION, update the badge and CHANGELOG, then tag main with the matching v* tag.
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- Update
CHANGELOG.md(move[Unreleased]into the new version section). - Keep
VERSION(nov), README Version badge, and CHANGELOG in sync; the OCI tag uses the samevprefix as the Git tag. Deployment image pins live in gghstats-selfhosted. - Ensure CI and Security workflows are green before pushing the release tag.
- Docker:
Dockerfileis for localmake docker-build/docker-scan. GoReleaser usesDockerfile.release(pre-built Linux binaries; same pattern as multi-arch release images).
make tools
make lint
make test
make security
make release-checkSecurity tooling:
govulncheckgocyclo(complexity gate)grype(filesystem image/source scanning)
SQLite path comes from GGHSTATS_DB. Main tables: repos, views, clones, referrers, paths, stars.
- Upserts are idempotent
- Startup migration uses
PRAGMA user_version
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 serveinstance perGGHSTATS_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.
Clarification — not a separate DB write lock.
sync.Coordinatoruses async.Mutexso only one full sync cycle runs at a time (startup, scheduled tick, orPOST /api/v1/sync). That is application-level mutual exclusion between sync runs, not a mutex around everyUpsert*.- Inside a run,
sync.Runiterates 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.
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(default1h) starts the next cycle only when the previous one finished; if a run is still in progress, the tick is skipped (ErrInProgress). SetGGHSTATS_SYNC_ON_STARTUP=falseto skip the blocking full sync at process start (UI uses existing DB; trigger sync via the dashboard orPOST /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,403rate-limit responses,Retry-After, or exponential backoff ininternal/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.
- 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.
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.
MIT


