Atrax analytics ETL — operations
Design + implementation of the fuel-consumption / ecodriving analytics layer over Atrax GPS/telematics data. Tracked in #6138.
This doc covers the implemented piece (Track B — the Parquet analytics export) and points
to the design for the rest. The full multi-track design lives in the ## Code-change-design
comment on #6138.
⚠️ Hard dependency — #5921 (read first)
Tracks A, C, D (anything reading avg_consumption_l100km, fuel_used_liters, fuel_level_*,
ecodriving scores) are blocked on #5921: the Atrax GET /api/exploitation and
POST /api/statistics/daily/drivers endpoints reject every request with an
invalid-signature/hash error, so the n8n collectors atrax-daily-stats-collector
(Wi3ZJcPw0YbxLXwa) and atrax-ecodriving-daily (zdzyIQ8UBsDAF0wI) have never populated
p24_gps_daily_stats / p24_driver_ecodriving_daily.
The ETL below is built and ready anyway — the moment #5921 unblocks and those tables start
filling, the nightly export begins producing non-empty Parquet with no further change. Until
then it runs cleanly and exports 0 rows for the blocked tables (expected, not an error).
p24_atrax_drivers_daily is not blocked (its collector needs no signature) and exports
normally.
Track B — Parquet analytics export (scripts/nightly-etl/export_atrax.py)
What it does
Reads the per-day Atrax daily tables out of Supabase (PostgREST, paginated) and writes one Snappy-compressed Parquet file per table per day to Wasabi:
s3://p24-infra/reporting/atrax/{table}/{YYYY-MM-DD}.parquet
This is the analytics-friendly denormalized copy for repeat querying via the planned
report-query-api (FastAPI + DuckDB httpfs, strategia §5) — so future fuel/ecodriving
analysis does not re-hit Supabase or the Atrax API. It is separate from and does not
replace scripts/atrax-archival.py, which keeps the raw JSON audit trail (10-year retention)
at s3://p24-infra/et-operational-platform/atrax-data-sources/….
Exported tables (each filtered to the target day on its date column):
| Table | Date column | Notes |
|---|---|---|
p24_gps_daily_stats | stat_date | blocked on #5921 (empty until then) |
p24_driver_ecodriving_daily | stat_date | blocked on #5921 (empty until then) |
p24_atrax_drivers_daily | stat_date | flowing — exports normally |
p24_gps_daily_anomalies | stat_date | Track C output, optional — a missing table is a warning, not a failure |
Empty result → the write is skipped (no schema-less empty Parquet is written). Re-running a day overwrites the same object key (idempotent).
Usage
# Default: exports yesterday (UTC)
python3 scripts/nightly-etl/export_atrax.py --env-file /opt/p24-infra/monitoring/.env
# Specific day
python3 scripts/nightly-etl/export_atrax.py --date 2026-08-10 --env-file /opt/p24-infra/monitoring/.env
# Dry-run (fetch + report counts, no upload; Wasabi creds not required)
python3 scripts/nightly-etl/export_atrax.py --dry-run --env-file /opt/p24-infra/monitoring/.env
# Subset of tables
python3 scripts/nightly-etl/export_atrax.py --tables p24_atrax_drivers_daily --env-file …Environment
Reuses existing keys — no new secrets (per #6138 acceptance):
| Var | Source |
|---|---|
SUPABASE_URL, SUPABASE_SERVICE_KEY (alias SUPABASE_SERVICE_ROLE_KEY) | monitoring .env |
P24_INFRA_WASABI_ACCESS_KEY / _SECRET_KEY (alias WASABI_ACCESS_KEY / _SECRET_KEY) | same Wasabi keys atrax-archival.py uses |
WASABI_BUCKET / WASABI_ENDPOINT / WASABI_REGION | optional overrides |
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL | optional; error alert |
GH_TOKEN / GITHUB_TOKEN | optional; file a GH issue on error |
Dependencies
scripts/nightly-etl/requirements.txt — requests, boto3, certifi, pyarrow. pyarrow
is imported lazily inside rows_to_parquet_bytes() so the module stays importable (and
unit-testable) on hosts/CI without it.
Error handling (Error Notification Standard)
On a required-table failure the run exits non-zero and, best-effort, posts a Discord embed via
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL and files a bug issue in radieu/p24-infra.
One failing table does not abort the others.
Tests
scripts/tests/test_export_atrax.py — date resolution, S3 key building, table-subset
selection, env validation, paginated fetch, optional-vs-required missing-table handling, and
export dispatch. The Parquet round-trip is pytest.importorskip("pyarrow")-guarded (skipped in
the scripts CI job, which does not install pyarrow).
Deployment status — SCHEDULED on vps-i1 (#6570)
Live since 2026-08-20 (#6570 — the export/retention half of #6194 Handoff 2, split off from
the bms-4 norm-jobs schedule in PR #6568 because it cannot run on bms-4). This is the canonical,
single schedule for the whole export: Track B’s 4 tables + #6194’s 4 route/driver norm tables
+ the --prune-snapshots retention prune.
Host: vps-i1 (not bms-4). export_atrax.py writes to the Wasabi reporting bucket
(s3://p24-infra/reporting/atrax/…), which needs P24_INFRA_WASABI_ACCESS_KEY /
P24_INFRA_WASABI_SECRET_KEY. Those live in vps-i1’s monitoring/.env; bms-4’s generic
WASABI_ACCESS_KEY is a different key Wasabi rejects (InvalidAccessKeyId) against that bucket.
Cron: /etc/cron.d/atrax-etl (root, 0644), 01:00 UTC daily, after the nightly collectors:
# /etc/cron.d/atrax-etl on vps-i1
0 1 * * * root /usr/bin/python3.12 /opt/p24-infra/scripts/nightly-etl/export_atrax.py \
--prune-snapshots --env-file /opt/p24-infra/monitoring/.env >> /var/log/atrax-etl.log 2>&1Interpreter pin — /usr/bin/python3.12 is mandatory. vps-i1’s default python3 is 3.9
(and lacks pyarrow); the sibling norm chain (PR #6568) established 3.12 as the required
interpreter for this stack. Runtime deps (requests, boto3, certifi, pyarrow>=14,<21) are
installed into the system python3.12 site (python3.12 -m pip install -r scripts/nightly-etl/requirements.txt, as root).
Deploy verification (2026-08-20, #6570):
- Dry-run + real run against a real day (2026-08-20):
p24_gps_fuel_snapshotsexported 5460 rows →s3://p24-infra/reporting/atrax/p24_gps_fuel_snapshots/2026-08-20.parquet(confirmed by S3 list). The other 7 tables loggedempty/missing(expected — their producers have not populated them yet: trips/anomalies/norm first run via PR #6568’s bms-4 chain on 2026-08-21; daily_stats/ecodriving stay empty until #5921). --prune-snapshotson that run loggedverified-empty, nothing to prunefor 2026-07-16 (nothing has aged past the 35-day window yet — snapshots only began 2026-08-20), i.e. it deleted nothing, which is correct.- The §5a “archive-then-verify-then-delete” gate — a delete is issued only after the archived
row count equals the live source count, and is skipped on a count mismatch — is covered by
unit tests (
scripts/tests/test_export_atrax.py::test_prune_snapshots_*, incl. the mismatch failure path). A live failure-path test is intentionally not run — it would require corrupting the production source table.
First real prune: p24_gps_fuel_snapshots began accumulating 2026-08-20; the 35-day
retention prune has nothing to delete until ~2026-09-24. Until then the table grows in Supabase
and the nightly prune step is a verified no-op.
Remaining follow-ups:
dev_r_servicesregistration — attempted at deploy; see the compliance note below.- Add the
report-query-api+run_etl.shorchestrator +ReportingETLStalePrometheus alert from strategia §5 (out of scope for #6138 Track B — the single exporter). - Optional:
logrotatefor/var/log/atrax-etl.log(one small append per day — low priority).
Tracks A / C / D / E / F
Designed in the #6138 ## Code-change-design comment; not implemented in the Track B PR
(A/C/D blocked on #5921, E is a separate design spike, F is an et-operational-platform UI
issue). See that comment for the schema/logic of the consumption norm, anomaly detection,
alerting rollup, per-route ecodriving, and the dashboard request.