Skip to content

Tracker Connections

Nayor edited this page Sep 17, 2026 · 2 revisions

c2c_tracking (repo c2corg/c2c_tracking) handles OAuth linking and activity sync with Strava, Garmin, Suunto, Decathlon, Polar and Coros. It runs as a sidecar next to c2c_ui on ui0/ui1, behind HAProxy at tracking.camptocamp.org (two replicas for redundancy, automatic failover via /health checks every 2s).

How it works

Auth: c2c_tracking doesn't issue its own JWTs - it verifies tokens signed by v6_api. TRACKING_JWT_SECRET_KEY must be set to the exact same value as v6_api's jwt_secret_key (shared HMAC secret). A mismatch fails every authenticated call (/users/:id/status, /users/:id/activities) with a real 401 - looks like a CORS problem from the frontend but isn't.

Sync, per vendor:

  • Strava / Suunto / Decathlon / Coros: last ~30 activities pulled on account link, then a webhook fires on each new activity.
  • Garmin: no pull call exists; linking calls garminApi.backfillActivities(days, ...), which asks Garmin to replay N days of history asynchronously via the normal webhook.
  • Polar: webhook-only, nothing pulls past activity history.

All vendors except Garmin silently drop any activity whose GPS track couldn't be parsed (only an info-level log, by design - no point storing a trackless activity). A parsing bug in a vendor's format can therefore cause total, silent data loss for that vendor until someone notices.

Two replicas, shared state: webhook subscription ids/secrets live in the shared DB, not per-instance. Only one replica needs to successfully register a subscription; either replica can then handle incoming webhooks. A "... couldn't be requested, maybe another webhook is already registered" warning on one replica at startup is expected noise if the other replica logs "Found matching ... webhook subscription" - not a sign of breakage.

Recovering missed activities

Confirmed incident, Strava only: Strava changed its activity-streams response format at some point before 2025-09; the old parsing code threw on every fetch, so between then and v2.5.2 (deployed 2026-09) no Strava activity was synced at all, silently. Strava keeps full history server-side, so it's recoverable.

A backfill script exists per vendor (scripts/backfill-*.ts, driven by a shared scripts/backfill-common.ts):

BACKFILL_DELAY_MS=1000 npm run backfill:strava
npm run backfill:suunto
npm run backfill:decathlon
npm run backfill:coros
BACKFILL_GARMIN_DAYS=90 npm run backfill:garmin
  • Strava / Suunto / Decathlon / Coros: re-syncs, for every user with a linked account, their most recent activities (each vendor's default page size, 30 for Strava - matches MAX_ACTIVITIES_PER_USER, nothing further back is recoverable without adding pagination). Safe to re-run - deduped by vendor+activity id.
  • Garmin: different shape - no synchronous "list activities" pull exists, only garminApi.backfillActivities(), which asks Garmin to replay historical webhook notifications asynchronously. backfill:garmin chunks the requested window (BACKFILL_GARMIN_DAYS, default 30) into Garmin's ~90-day-per-request limit; nothing is recovered synchronously, the data arrives via the normal webhook afterwards.
  • Suunto/Decathlon/Coros: built as general recovery tools - unlike Strava, no specific bug was confirmed for them. Worth running as a precaution, not because of a known incident.
  • Polar: deliberately not built. Polar's AccessLink API only exposes data from the 90 days following a user's original account link, and only new data at that - already-elapsed history isn't retrievable through any endpoint, including their "transactions" flow. For anyone linked more than 90 days ago, that window has already closed for good, so a backfill script's value here is narrow. Revisit only if it turns out to matter for a meaningful number of recently-linked users.

BACKFILL_DELAY_MS (default 1000ms) paces requests between users to stay under each vendor's rate limits.

Gotcha: compose changes need BOTH a git pull on the host AND a recreate, not a restart

Two distinct things have to both be current for a config change (e.g. the dns: block below) to actually take effect:

  1. The infra repo checkout on the production host needs git pull - the compose file lives there, not in the tracking image. Pulling a new tracking image and recreating the container still uses whatever docker-compose.yml is already on disk.
  2. docker compose ... restart reuses the existing container as-is - it does not pick up dns: or other container-level compose settings even if the file on disk is current. Use docker compose ... up -d <service> to actually recreate it.

Bit us after adding a DNS-resolver bypass to tracking (Docker's embedded DNS couldn't resolve external hosts like api.strava.com) - one replica kept failing with getaddrinfo EAI_AGAIN until both steps were actually done. To verify it took effect:

docker compose -f composition/production/ui0/docker-compose.yml exec tracking cat /etc/resolv.conf
docker compose -f composition/production/ui0/docker-compose.yml exec tracking getent hosts www.strava.com

Gotcha: a merged fix isn't a deployed fix

c2c_tracking's version tag (e.g. v2.5.2) is cut from main at a point in time - anything merged to main afterwards (like a bugfix PR) sits unreleased until someone runs npm version and cuts a new tag, then the infra repo's compose file is bumped to that tag and the container is recreated. Seeing an error in prod that matches an already-merged fix almost always means "not deployed yet," not "the fix didn't work" - check git log <deployed-tag>..main on c2c_tracking before re-diagnosing.

Gotcha: a failed tracker-status check looks identical to "no tracker connected"

c2c_ui's outing editor (MapInputRow.vue) only offers "pick a synced activity" when GET /users/:id/status succeeds and reports at least one vendor as configured; any error there (401, network, ...) used to be swallowed silently, so it fell back to local-GPX-only with zero indication anything was wrong. This is exactly what made the JWT-secret-mismatch incident above hard to notice from the frontend. Now logged via console.error at minimum - check the browser console before assuming "no trackers connected" when a user reports the option missing.

Clone this wiki locally