Skip to content
fdaniel02Public

About

Running statistics from Garmin Connect

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

RunStats

CI Quality Gate Status

Statistics from my runs, downloaded from Garmin Connect and shown in a web app. Runs in the k3s homelab cluster.

Components

  • runstats: Blazor Server app (.NET). Holds the UI and the sync, and owns the PostgreSQL database.
  • garmin-gateway: small Python service that talks to Garmin Connect through python-garminconnect. It has no database; it only passes Garmin's responses on to the app.

A Kubernetes CronJob triggers the sync; it can also be started from the UI.

The work is planned in the GitHub issues.

Project layout

RunStats.slnx
src/RunStats/                the app (Blazor Server)
  Components/                app shell: App, routes, layout, error pages
  Features/<Screen>/         one folder per screen (Dashboard, Runs, Pace, Heatmap)
  wwwroot/js/                the scripts for the heatmap, the charts and the pace calculator (tests: tests/js, Node's test runner)
  wwwroot/lib/leaflet/       Leaflet 1.9.4, the map library, kept in the repo (BSD 2-Clause)
  Data/                      DbContext, entities, migrations, startup and time zone helpers
  Garmin/                    client for the Garmin gateway
  Sync/                      the sync with Garmin and its API
  Processing/                turns a run's raw data into stats, laps, samples and its track
tests/RunStats.Tests/        unit, component (bUnit) and PostgreSQL tests (xUnit v3)
gateway/                     the Garmin gateway (Python, FastAPI), see below
.github/workflows/ci.yml     build and test on every pull request and push to main, publish the image from main
.github/dependabot.yml       weekly update pull requests for NuGet, uv (gateway) and GitHub Actions

NuGet versions are locked in packages.lock.json (enabled in Directory.Build.props); CI restores in locked mode. After changing a package, commit the updated lock file too.

The lock file also holds a package the SDK adds implicitly, whose version follows the SDK. So when you change the SDK version in global.json, regenerate the lock files in the same pull request:

dotnet restore --force-evaluate

The Docker image builds with the newest .NET 10 SDK and runtime (sdk:10.0 and aspnet:10.0) and restores without locked mode, so it picks up .NET patches on every build without depending on global.json.

Build and test

dotnet build
dotnet run --project src/RunStats
dotnet test --coverlet

The heatmap's JavaScript is tested with Node's built-in test runner (Node 24, no packages to install):

node --test "tests/js/*.test.mjs"

Tests run on Microsoft Testing Platform (set in global.json). --coverlet writes an OpenCover report to TestResults/coverage.opencover.*.xml; the settings are in tests/RunStats.Tests/testconfig.json.

Some tests run against a real PostgreSQL in a container (Testcontainers, image postgres:18), so Docker must be running.

Database migrations

dotnet-ef is a local tool (dotnet tool restore). It needs a connection string to build the app, but doesn't connect for adding a migration:

$env:ConnectionStrings__Default = "Host=unused;Database=unused"
dotnet ef migrations add <Name> --project src/RunStats

A test fails when the model has changes without a migration.

Configuration

The app needs the connection string ConnectionStrings:Default. It is not stored in appsettings.json; the app refuses to start without it.

Garmin gateway address

GarminGateway:BaseUrl is where the app reaches the gateway: http://garmin-gateway:8080/ (the Service name in the cluster) in appsettings.json, http://localhost:8081/ in appsettings.Development.json.

Heatmap map tiles

The heatmap draws the routes on CARTO's dark map ("Dark Matter"), which needs a free key from carto.com/basemaps/apikey: the setting Heatmap:CartoApiKey (user-secrets locally, Heatmap__CartoApiKey in the container). Without it, the map uses OpenStreetMap's tiles, darkened with CSS. The browser loads the tiles itself, so the key is visible in the page, as with every map key.

Leaflet is kept in wwwroot/lib/leaflet rather than loaded from a CDN, so Dependabot doesn't update it; to update, replace the files with a newer release from cdnjs and check them against cdnjs' SRI hashes.

Upcoming races

The dashboard's upcoming races come from a Notion database, through a read-only internal connection that has access to that database only. Two settings connect it: Notion:Token (the connection's API token) and Notion:DataSourceId (the database's data source). Use user-secrets locally and Notion__Token and Notion__DataSourceId in the container. Without them, the sync skips this step and the card stays empty.

The database needs these properties: Race (title), Datum (date), Distance (number, km), Type (select), Registered (checkbox), City (text), Link (URL) and Target Time (text: 1:45:30, 45:30, or 45 for minutes).

Time zone

"This month", "this year" and weekly totals use the time zone in the TimeZone setting (an IANA id, default Europe/Amsterdam in appsettings.json), not the server's. Override it in Docker with -e TimeZone=....

Warnings

The warnings (#15) compare the last days or weeks with the weeks before. Their thresholds are settings in the Warnings section (-e Warnings__FastRatio=... in Docker); the app refuses to start with a value out of range. The defaults, in WarningSettings, were tuned on the history up to 2026-10 to about ten warnings in 14 months:

Setting Default Meaning
FastPaceSecondsPerKm, VeryFastPaceSecondsPerKm 310, 255 Faster than 5:10/km is fast, faster than 4:15/km very fast
FastRatio, FastMinMinutes 3, 45 Fast minutes in the last 7 days against the weekly average of the 4 weeks before
VeryFastRatio, VeryFastMinMinutes 2, 8 The same for very fast minutes
VolumeRatio, VolumeMinMinutes 1.5, 400 Running time in the last 4 weeks against the 12 weeks before
LongRunRatio, LongRunMinMinutes 1.3, 75 The longest run of the last 7 days against the longest of the 4 weeks before
HardRunFastMinutes, HardRunMinutes, HardRunsWithinHours 20, 75, 72 Two hard runs (this many fast minutes, or this long) this close together
FormBandWidthSecondsPerKm, FormBandMinMinutes 15, 5 Form is compared in pace bands this wide with this much time on both sides
StrideLengthIncreasePercent, CadenceDropSpm 4, 3 Form change at the same pace, last 2 weeks against the 8 before (outdoor runs)
BalanceShiftPoints, BalanceMinMinutes 1.5, 20 Left/right balance change on strap runs, same periods

Logging

Logs go to stdout as one JSON object per line, for a log collector (e.g. Grafana Alloy → Loki) to pick up. Locally (Development) they use the plain console format. Each log message has a fixed event id (see src/RunStats/Log.cs).

Traces and metrics are sent over OTLP when OTEL_EXPORTER_OTLP_ENDPOINT is set.

At startup the app applies database migrations. While PostgreSQL is unreachable it tries 15 times, 4 seconds apart, and logs each failed attempt as a warning. If the host doesn't answer at all, each attempt also waits for the connect timeout (15 s by default). Errors that waiting won't fix (wrong password, missing database, no permission to connect) stop it at once. A startup failure is logged as Critical and the process exits with code 1.

Local development

Run PostgreSQL in Docker; the app creates the database and its tables on the first start:

docker run -d --name runstats-db -p 5432:5432 -e POSTGRES_USER=runstats -e POSTGRES_PASSWORD=<password> postgres:18

The connection string lives in user-secrets (stored in %APPDATA%\Microsoft\UserSecrets\, outside the project):

dotnet user-secrets set --project src/RunStats "ConnectionStrings:Default" "Host=localhost;Database=runstats;Username=runstats;Password=<password>"

User-secrets are only loaded when ASPNETCORE_ENVIRONMENT=Development (the default in launchSettings.json).

The cluster's database isn't reachable from the LAN. For a one-off look or fix, forward it to this machine; it stays reachable on localhost:5433 while the command runs. The second command prints the login's password:

kubectl port-forward -n postgres svc/postgres-rw 5433:5432
kubectl get secret -n postgres postgres-runstats -o jsonpath='{.data.password}' | base64 -d; echo

Docker

Every push to main publishes the image to GitHub Container Registry, tagged sha-<short commit sha> and latest, after the tests pass and a Trivy scan finds no fixable critical vulnerabilities:

docker pull ghcr.io/fdaniel02/runstats:latest

Or build the image yourself from the repository root:

docker build -t runstats .

The container runs as the non-root app user.

Pass the connection string as an environment variable (__ stands for :), preferably from a file so the password stays out of your shell history:

docker run -d -p 8080:8080 --env-file runstats.env runstats

runstats.env contains a single line, without quotes:

ConnectionStrings__Default=Host=<db-host>;Database=runstats;Username=runstats;Password=<password>

Keep that file outside the project folder, so it can't end up in the image or in git.

Sync

A sync reads Garmin's whole activity list through the gateway and keeps the runs: type running (id 1) and its sub types (parent type id 1: treadmill, trail, track, obstacle runs, ...). It compares each run's list entry with the stored one:

  • new run: downloads its detail, laps, heart rate zones, weather and FIT file and stores them as they came (RunRawData), together with a Runs row with the basics from the list
  • changed list entry (e.g. renamed, marked as a race): downloads and stores the run again
  • unchanged: nothing is downloaded
  • gone from Garmin: deleted, only when the whole list was read

Each run is stored in its own transaction, so a sync that stops halfway (rate limit, failed login, app stopping) keeps the runs it stored, and the next sync continues from there. Downloading a run takes about 6 s, because the gateway spaces its calls to Garmin.

After the downloads, the sync processes the raw data of every run whose ProcessorVersion is below RunProcessor.Version: the stats in Runs (from Garmin's summary; VO2max from the list entry), RunLaps (from the splits), RunSamples (the FIT file, decoded by the gateway) and RunTracks (the GPS positions simplified to 3 m, as an encoded polyline with a bounding box). Raise RunProcessor.Version when the processing changes: the next sync then processes every stored run again, without downloading anything. A run whose raw data can't be processed gets the reason in Runs.ProcessingError and isn't tried again until the version changes; if the gateway itself is unreachable, the sync stops and the next one continues.

Last, the sync updates PersonalRecords from Garmin's records, keeping the running ones (by Garmin's record type: 1 km, 1 mile, 5 km, 10 km, half marathon, marathon, longest run). Records Garmin no longer reports are removed; UpdatedAtUtc changes only when a record's value, run or time changes. Garmin leaves a record's local time empty, so AchievedAtLocal is its UTC start shifted by the activity's offset.

It then stores Garmin's latest race predictions in RacePredictions, one row per distance, keyed by the same record type. A distance Garmin doesn't report keeps its last prediction. Garmin can return up to a year of daily predictions per request, so no history is kept here.

Finally, it copies the races dated today or later from Notion into UpcomingRaces, removing the ones no longer there. If Notion can't be read, the sync logs a warning and still succeeds, and the dashboard keeps the last copy. An unreadable target time is logged, and the race is shown without one.

The dashboard adds a predicted time to each race. A race within 0.5% of 5 km, 10 km, a half or a full marathon shows Garmin's prediction. Between those distances it's interpolated on log-log axes, and beyond them it's extended from the nearest prediction with Riegel's formula (exponent 1.06); both are marked ~ as estimates. Trail and obstacle races and races without a distance get no prediction.

Only one sync runs at a time. Each is recorded in SyncRuns with its progress and result; a sync that was running when the app stopped is marked as interrupted at the next start.

curl.exe -X POST "http://localhost:5310/api/sync?trigger=manual"   # 202, Location: /api/sync/{id}
curl.exe http://localhost:5310/api/sync/1                          # status, counts and error

trigger is schedule (default) or manual. A request while a sync runs returns that sync ("started": false).

Telemetry: a Sync span (the gateway's spans join its trace), log events 10–19 and 22–26, and the metrics runstats.syncs (by trigger and outcome), runstats.sync.duration, runstats.sync.runs (by change: added, updated, deleted, processed, processing_failed) and runstats.sync.last_success (Unix time of the last successful sync).

Deployment

RunStats runs in the homelab's k3s cluster; its manifests are in cluster-gitops (workloads/runstats/). The database is runstats on the cluster's PostgreSQL, owned by the login runstats, which has no rights elsewhere. Both are created by the cluster (cluster-gitops, infrastructure/postgres), not by the app; the app only applies its migrations. The cluster archives the database to object storage continuously, so it can be restored to any point in time within 30 days.

The size metrics (runstats.db.size for the whole database, runstats.db.table.size per table, indexes and long values included) are read at start and after each sync.

Garmin gateway

gateway/ is the only part that talks to Garmin Connect. It returns Garmin's responses unchanged; the app decides what to keep. It needs uv (winget install --id astral-sh.uv -e), which also installs the Python version from gateway/.python-version if it's missing. Dependencies are locked in gateway/uv.lock.

cd gateway
uv sync
uv run ruff check
uv run ruff format --check
uv run pytest --cov

The tests use a fake Garmin client; they never call Garmin.

Endpoints

Endpoint Returns
GET /activities?start=0&limit=100 A page of activities, newest first (limit up to 1000)
GET /activities/{id} The full summary of one activity
GET /activities/{id}/splits Laps
GET /activities/{id}/hr-zones Time in heart rate zones
GET /activities/{id}/weather Weather during the activity
GET /activities/{id}/fit The original FIT file (404 for activities imported without one)
GET /personal-records Personal records
GET /race-predictions Garmin's latest finish time predictions for 5 km, 10 km, half marathon and marathon
POST /fit/samples Decodes the FIT file in the request body into one sample per second (see below)
GET /healthz/live, GET /healthz/ready Up / logged in to Garmin

/fit/samples doesn't call Garmin: the app sends a FIT file it has stored, so runs can be processed again without downloading them. It uses fitdecode (MIT), not Garmin's FIT SDK, whose license doesn't allow distributing it in a public image. Each sample has its offset in seconds from the first record, position in semicircles, distance, speed, altitude, heart rate, cadence in steps per minute (FIT counts strides, with the fraction in a separate field), step length, ground contact time, vertical oscillation and ratio, power and temperature; missing values are null. When a second has more than one record, the last one is used. Files over 20 MB get 413, files that aren't valid FIT get 422.

Errors: 404 when Garmin doesn't know the activity, 502 when a Garmin call fails, and 503 with Retry-After while the gateway waits after a rate limit or a failed login.

Login and limits

At startup the gateway logs in: with the saved token in GARMIN_TOKEN_DIR if there is one, otherwise with the email and password (the account has no two-factor authentication). Garmin refreshes the token itself; when that fails, the gateway logs in again once. The token file holds the Garmin session, so keep it as private as the password.

Calls to Garmin run one at a time, at least GARMIN_MIN_SECONDS_BETWEEN_CALLS apart. After a rate limit (HTTP 429) the gateway doesn't call Garmin for GARMIN_RATE_LIMIT_COOLDOWN_SECONDS; after a failed login it doesn't try again for GARMIN_LOGIN_FAILURE_COOLDOWN_SECONDS, because repeated failed logins can get the account locked.

Setting Default
GARMIN_EMAIL, GARMIN_PASSWORD none
GARMIN_TOKEN_DIR /data
GARMIN_MIN_SECONDS_BETWEEN_CALLS 1
GARMIN_RATE_LIMIT_COOLDOWN_SECONDS 300
GARMIN_LOGIN_FAILURE_COOLDOWN_SECONDS 900
HOST 127.0.0.1 (0.0.0.0 in the Docker image)
OTEL_EXPORTER_OTLP_ENDPOINT none: no traces or metrics
OTEL_SERVICE_NAME garmin-gateway
PORT 8080

Logs are JSON on stdout with the same field names as the app's; health check requests aren't logged. Log lines written during a request carry its TraceId and SpanId.

Telemetry

With OTEL_EXPORTER_OTLP_ENDPOINT set (e.g. http://alloy:4318), traces and metrics go to that collector over OTLP/HTTP. Requests are traced (health checks aren't); a request from the app continues the app's trace. Each call to Garmin is a span Garmin <operation> with its outcome and the time it waited for the throttle. Spans and metrics hold operation names, outcomes and timings only: no credentials, tokens, Garmin data or error messages.

Metric Attributes
garmin.calls garmin.operation, garmin.outcome (ok, not_found, unauthorized, rate_limited, error)
garmin.call.duration (s, without the throttle wait) same
garmin.rate_limits garmin.operation
garmin.logins garmin.login.method (saved_token, password), garmin.outcome (ok, failed, rate_limited, error)

Run locally

Put the credentials in a file outside the repository, e.g. %USERPROFILE%\.runstats\gateway.env:

GARMIN_EMAIL=...
GARMIN_PASSWORD=...
docker build -t runstats-garmin-gateway gateway
docker run -d --name garmin-gateway -p 8081:8080 --read-only `
  --env-file $env:USERPROFILE\.runstats\gateway.env -v garmin-gateway-data:/data runstats-garmin-gateway

The image is published as ghcr.io/fdaniel02/runstats-garmin-gateway together with the app's. It runs as user 10001 and only writes to /data.

About

Running statistics from Garmin Connect

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages