Skip to content

playground event observatory

An event observatory for smart-home and IoT telemetry. It ingests CloudEvents over HTTP, streams them through Kafka, stores them in PostgreSQL, and serves a fast server-rendered UI for exploring, searching and monitoring them.

The design goal is closer to Grafana, Kibana or Home Assistant than to a CRUD application: the event log is the product, and search over it is the primary feature.

Who talks to this system, and what crosses its boundary? A C4-style level-1 context view — Kafka, PostgreSQL, Prometheus and Grafana are all inside the box. One level down is the container view.

flowchart LR
  P["IoT producers<br/>devices and gateways"] -->|"CloudEvents 1.0 over HTTP,<br/>with a bearer token"| SYS
  I["Token issuer<br/>outside this repository"] -.->|"signs the tokens wolfram and<br/>cobalt verify; neither mints one"| SYS
  SYS["<b>playground event observatory</b><br/>ingest, store, search, watch"] -->|"server-rendered HTML,<br/>an SSE live tail, and dashboards"| B["Operator in a browser"]

Start here

If you want to know Read
What the containers are, who can reach them, and which way the module arrows point Architecture at a glance
Where an event went, or why one is missing The journey of one event
The HTTP contract for producing events, and every rejection it can answer with wolfram
Why the consumer is stalled, how to pause it, what is sitting in the DLQ cobalt
How search, the overview dashboard and the live tail are rendered ferrite
What a CloudEvent must contain, and what the filter grammar accepts Event model
Which column is generated from what, and which index answers which query Database schema
How to run the stack, or what to do at 3 a.m. Development · Operations
Why any of this is the way it is, with the alternatives and why they lost Decision record

The three services

Service Stack Responsibility
wolfram Tapir on Vert.x 5 HTTP ingestion. Validates CloudEvents and publishes to Kafka. Owns no state.
cobalt Pekko Streams Kafka Consumes, decodes and persists events, and runs the Flyway migrations. Cask serves its admin, metrics and health surface.
ferrite Play 3 + Twirl/HTMX The web application: PostgreSQL, search, and the UI. Never sees Kafka.

Named after metals rather than their frameworks, so a name does not have to change if a stack does.

applications/ holds exactly those three deployables. The shared contracts live in modules/kernel (the domain), eventing (the Kafka wire format), persistence (schema, pools, the filter-to-SQL compiler) and observability (one meter vocabulary and one tracing setup) — as libraries with no main and no image. The arrows between all seven, and the rule they encode, are in the module dependency graph.

Design commitments

CloudEvents are the source of truth. Events are stored verbatim as jsonb; every queryable column is GENERATED ALWAYS AS … STORED from that raw document, so a projection cannot drift from the payload it describes. An event type this system has never seen is still persisted, still searchable, and still viewable.

Search is pure PostgreSQL. JSONB with GIN, BRIN on time, partial indexes and a rollup materialized view — no second datastore. The operational cost of running a search cluster alongside the database is real, and the query shapes here do not need one.

At-least-once, made idempotent. The consumer commits only after a durable write, and the write deduplicates on the CloudEvents identity, so a redelivery is a no-op rather than a duplicate row.

One trace, end to end. A W3C trace context is injected into Kafka headers at ingestion and extracted by the consumer, so a single trace spans HTTP → Kafka → database.

The domain stays framework-free. modules/kernel depends on circe and the standard library and nothing else, and a require in build.sbt fails the build at load time if that ever changes.

Further reading

  • Architecture decision record — the full contract: dependency table, schema DDL, index rationale, and the risks with their fallbacks.
  • Scaladoc — generated API documentation for every module.

If you are here to change something

  • Maintainer's handbook — where each kind of change goes, what it will break, and the traps that have already caught somebody. Start here for "add an endpoint", "add a filter", "add a metric", "add a migration".
  • Class index — all 245 types, grouped by the module that owns them, answering the question Scaladoc cannot: which of these do I need, and what does it sit next to.