Geospatial intelligence platform that fuses feed reports into one entity per object and streams the live picture to a CesiumJS map.
Open source
Architecture. Feeds enter through a Go ingest service and land on Kafka as protobuf observations. The entity service fuses reports that share an identity. Analytics and TimescaleDB keep their own views of that stream. PostGIS answers viewport queries, and live positions reach a CesiumJS map over WebSocket.
01Context
Tracking vessels, aircraft, vehicles, persons, and facilities means keeping every report, deciding which reports are the same object, and pushing a live picture to analysts. SENTINEL is that system: an Nx monorepo of Go services behind one API gateway, and an Angular 19 client on CesiumJS.
Kafka carries protobuf events between services. One ingest replica owns each feed, guarded by a Postgres lease. The lease's fencing token protects lease and status writes.
02Key decisions
Four decisions shaped SENTINEL. Each card gives the problem, the options where they are known, what was chosen, and what it cost.
Store every report, then fuse by identity
Problem
Feeds report the same object differently and at different times.
Chose
Every report is stored as an observation, with provenance. Reports that share a canonical identity (ICAO24, MMSI, NORAD id, or CoT uid) update one entity, and the newest fix wins. Reports that lose are kept as DUPLICATE or STALE_FIX. Secondary identities such as callsign and IMO attach to the entity; conflicts between them are counted, never merged. Feeds never overwrite fields an operator owns.
Cost
Anything without a canonical identity is keyed per feed and never fuses.
Rewrite the backend in Go
Problem
The backend was mixed. NestJS and TypeScript ran the gateway, entity, track, alert, link-analysis, and search services. Python ran analytics. Ingest was already Go.
Options
Fix the NestJS and Python services in place, or port them to Go alongside the existing Go ingest.
Chose
Port to Go. The reason was ops weight, and the port confirmed it. The port turned up three things: 576 installed npm packages (40 direct), a Python analytics service that "never ran as deployed" (its environment names did not match compose, and it needed a Redis that was not in the stack), and a dev auth bypass hiding a role mismatch (the NestJS controllers checked analyst, and Keycloak issues sentinel-analyst).
Cost
A first attempt in April 2026 was shelved for six months. The gateway and analytics ports then landed within days of each other in October: the gateway on 2026-10-14 (PR #25) and analytics on 2026-10-17.
Schema migrations: decided, then reversed in two days
Problem
The sentinel.entities table is shared by four services. Who owns the schema?
Options
Per-service startup DDL, rejected because four services share the table. Initdb scripts, rejected because they "never update an existing volume". golang-migrate, chosen in the design spec.
Chose
Two days later, idempotent per-area SQL files replaced it, with "versioned migrations start at the release". The reason was baseline churn. Before the first release the schema kept changing, so maintaining a numbered baseline was busywork. Two baseline fixes on the same day in PR #25 illustrate it.
Cost
No schema rollback. Rolling back a deploy leaves the schema as it is, because the files only add.
One protobuf contract, three languages
Problem
The wire format carried an opaque platform_data bytes field, and had no way to tell an unknown altitude from sea level.
Chose
Typed blocks replace platform_data, and altitude is optional. The contract lives in proto/sentinel/v1 and is generated for Go, TypeScript, and Python. Golden fixtures written by the Go test are decoded by Jest and pytest, which keeps the three languages in step. The old JSON stream ran alongside, behind KAFKA_LEGACY_* flags, while services migrated.
Cost
Python had to move off betterproto to get optional fields. Every contract change commits regenerated code in three languages.
03Outcome
Docker Compose runs the full stack. Helm and Kustomize deploy it. Live positions stream to the 3D map over WebSocket, with link-analysis graphs, geofence alerts, and behavioral analytics as their own services. The same picture can leave over TAK, or as GeoJSON, KML, and GeoServer layers, under the viewer's clearance.
PostGIS answers viewport queries. TimescaleDB hypertables hold track history. OpenSearch is the search index, rebuilt from entity events. Keycloak issues OIDC tokens. Roles and a clearance ceiling, from UNCLASSIFIED through TOP_SECRET, gate every read, the WebSocket, exports, and GIS layers.