EU Air Traffic
Live and historical European airspace: ADS-B positions, OpenSky movements, schedules and weather stream through Kafka into a Bronze and Silver lake, a dbt warehouse on DuckDB and a read-only serving copy behind a public dashboard.
- aircraft tracked
- ~2,000 live
- lake cadence
- 15 min
- kafka topics
- 5
- dbt models + tests
- 103
- test suite
- 120+ tests
- silver datasets
- 11
- opensky credits
- 2.7k / 4k day
The full path, from six upstream sources to the dashboard. Open the image for the full-size animated version.
Most flight trackers show a map and stop there. We keep the receipts: we count aircraft movements ourselves, then place them beside the same month's official Eurostat passenger totals for each airport. The live numbers can be checked against an outside source instead of taken on faith.
Our collector runs on a two-core VPS and does nothing but collect. Positions, flight movements, schedules, weather and fuel estimates leave it as Kafka records, and every transformation (deduplication, the warehouse build, the published lake) happens in scheduled jobs. The dashboard stays current without anyone watching it.
What we run
- Live map. Around 2,000 aircraft with callsign, type, altitude, speed and vertical rate, route lines, METAR and TAF stations, and search by callsign, registration, type or airport.
- Live analytics. Airspace composition, altitude bands, operators, and carbon intensity from the OpenAP kinematic model with measured and estimated values side by side.
- Business analysis. Delays, punctuality, cancellations, an airport leaderboard, airline rankings, route performance, weather impact and seasonal trends.
- Official benchmark. Eurostat monthly passengers per airport, cross-checked against the movements we observed.
- SQL workbench. The serving copy queried in the browser through a read-only token.
- Ops. Pipeline step report, data-quality report, dbt lineage and freshness.
How a cycle runs

- The collector polls each source on its own interval (positions every 15 seconds and published in five-minute batches, movements every 30 minutes, schedules rotating through hubs) and writes batched records keyed by aircraft or flight.
- Every 15 minutes a scheduled job drains Kafka from its last consumer offset and lands the window in Bronze as Parquet.
- Silver updates incrementally: records are deduplicated across providers, typed, and loaded into a star schema in DuckDB.
dbt buildruns 103 models and tests and produces the marts the site reads.- The publisher pushes changed files to the Hugging Face dataset, the full warehouse to MotherDuck, and a bounded copy to Turso through a shadow table that swaps in atomically.
- The dashboard reads Turso for history and the collector's live API for the last few minutes.
Two serving stores, on purpose
MotherDuck holds the full warehouse, but its user model cannot issue a browser a scoped read-only token. Turso can, so the site reads a derived copy there: small enough to hold cheaply, safe to expose, refreshed every cycle. Paging totals come from a precomputed site_summary row, so a browser poll never scans a fact table.
That copy can span several Turso databases, usually one per free-tier account. TURSO_TARGETS maps each table to its targets, and a table listed under more than one target is mirrored and versioned on each side. When an account is down or out of quota the publisher keeps going, and the browser fails over to a healthy copy while the failed one cools down. There is no Turso replication in the mix, so every target is an independent database that can be rebuilt from the warehouse at any time.
Budgets are enforced in code
Every upstream free tier has a hard limit. The pipeline treats those limits as part of the design instead of something to discover at 3am.
- Kafka, five topics per cluster. One topic per domain. Weather (METAR, TAF, forecast) and reference data share a topic with a
_kinddiscriminator that the sink splits back into datasets. - OpenSky, daily credit budget.
/flights/allcovers both ends of a flight in one request, plus live departures for four hubs and a nightly arrivals backfill, around 2.7k of 4k daily credits. - AirLabs, 1,000 calls per month. Rotating hubs and a persisted monthly counter that stops at the cap. IATA to ICAO codes resolve from bundled data, with no extra calls.
- Turso, row read and write budget. Aggregates precomputed into a one-row lookup, static tables uploaded only when a content hash changes, growth watermark-synced, and tables the site no longer needs dropped from the copy.
- Object storage, commit budget. Silver is partitioned per source. Only changed files are pushed, and unchanged files are acknowledged as no-ops.
- CI runners, shared and ephemeral. Frontend-only pushes skip the lake, and the job fails fast instead of retrying silently.
- VPS, two cores. The box only collects; all transformation runs in CI.
Running unattended
Most of the work went into failure handling rather than the happy path. Each item below has a test.
- Silent success. Some steps logged an error and exited 0, including an upload rejected by a trailing space in a repo id and a publish that never wrote. Failures are loud now, and credentials that are set but empty raise.
- Fresh-boot assumptions. A cadence check compared
monotonic()against0.0, which only works when uptime is longer than the interval. Missing state now means the work is due. - Destructive retries. A failed lake pull could push one window over the full Silver history, and publishers could wipe serving tables mid-run. Pulls now fail the job, and static tables load into a shadow table and swap atomically.
- Double counting. OpenSky movements and AirLabs schedules describe the same flight with different ids. The marts deduplicate on callsign, date and endpoint, and only average delays that are actually known.
- Timezone traps. Casting
TIMESTAMPTZtoTIMESTAMPshifts by the session offset, which silently re-reads old rows and skips boundary ones. - Queue starvation. Every push entered one single-writer concurrency group until frontend-only pushes were excluded.
Data sources
- OpenSky Network for flight movements (OAuth2 client credentials)
- adsb.lol for live positions, with airplanes.live and OpenSky as fallbacks
- AirLabs for schedules with planned times and delays
- aviationweather.gov for METAR and TAF, Open-Meteo for forecast
- Eurostat
avia_paoafor official monthly passengers per airport - OurAirports, OpenFlights and ICAO 8643 for reference data, built once by a script
- OpenAP (TU Delft) for the kinematic fuel-flow model, precomputed into a 37-type lookup grid
Stack
| Part | Technology |
|---|---|
| Collection | Python, one module per source |
| Event bus | Apache Kafka, five topics |
| Lake | Parquet on the Hugging Face dataset hub |
| Warehouse | DuckDB and dbt, served from MotherDuck |
| Serving | Turso with read-only tokens |
| Dashboard | React and Vite on Cloudflare Pages |
| Deploy | systemd and Docker Compose on a VPS |
| CI | GitHub Actions, a 15-minute schedule |
| Tests | pytest plus dbt tests |
Links
- Live dashboard: airtraffic.eu.cc
- Live API health: vps.swadhin.cv/health
- Dataset: swadhinbiswas/air-traffic, MIT licensed
- Source: github.com/swadhinbiswas/eu-air-traffic
- DOI: 10.5281/zenodo.22790201
The hard part
What made it hard
Provider budgets come in different units
OpenSky counts credits per day, AirLabs counts calls per month, Turso counts rows read and written, and CI minutes are ephemeral and shared. A cron schedule cannot respect any of those. Each source ended up with its own guard. AirLabs carries a persisted monthly counter that stops the source at its cap. The lake job compares content hashes and treats an unchanged file as a no-op. The cost of getting this wrong is invisible: the pipeline keeps running while the numbers quietly stop moving.
A publish must never leave the site half-written
Early versions wrote serving tables in place. A failed run could wipe a table halfway through, and a bad retry could push one Bronze window over the full Silver history. Static tables now load into a shadow table and swap atomically, and a failed pull fails the job instead of continuing with partial state. The swap is the part I would keep in any rewrite.
Two providers describe the same flight
OpenSky movements and AirLabs schedules refer to the same aircraft with different ids and different rounding. Counting both inflates every total. The marts deduplicate on callsign, date and endpoint, and delays are averaged only when they are actually known. Unknown stays NULL rather than becoming zero.
Failure paths needed tests more than the happy path
The bugs that hurt were quiet: a step that exited 0 after logging an error, a cadence check that only worked after long uptime, a timezone cast that re-read old rows and skipped boundary ones. Every one of them now has a test, and the tests took less time to write than the debugging did.
Outcome
What exists today
- A public dashboard at airtraffic.eu.cc with a live map, analytics, business views, a benchmark against Eurostat and a read-only SQL workbench
- A live API behind Caddy TLS, healthchecked
- A dataset on Hugging Face: Bronze windows plus eleven Silver snapshots, documented and MIT licensed
- dbt lineage and a data-quality report published on the site
- 120+ pytest tests and 103 dbt models and tests running in CI
- An archived release with a DOI (v0.1.0) and a JOSS manuscript draft
- A one-command Docker deployment that runs a full lake cycle anywhere
What I'd do differently
If I built it again
- Merge on a primary key from the start. Snapshot replacement was the fast path and it caused the worst failure mode, where one bad window could overwrite history.
- Partition and compact earlier. Small-file pressure shows up at volume, and retrofitting compaction into a running pipeline is harder than starting with it.
- Keep run state out of process memory. Cooldowns and watermarks moved to durable storage only after a restart reset them.
- Test failure paths before the happy path. The interesting behavior lives in 401s, 429s, exhausted budgets and lag.
- Keep the two-store serving design. Separating the warehouse I trust from the small copy a browser may read kept the public surface limited to what Turso can scope with a read-only token.