CloudEvents in over HTTP β Kafka β PostgreSQL β a server-rendered search UI. Three Scala 3 services, three different web stacks, one sbt 2 build.
π Documentation Β· π Decision record Β· βοΈ Operations Β· π€ Contributing
- What this is
- Architecture
- Design commitments
- Quickstart
- What you get in the browser
- Development
- Observability
- Project layout
- Documentation
- Contributing
- Licence
An event observatory: a system whose product is the event log, and whose primary feature is search over it. It ingests CloudEvents 1.0 over HTTP, streams them through Kafka, stores them verbatim in PostgreSQL, and serves a fast, server-rendered UI for exploring, searching and watching them live.
The design goal is closer to Grafana, Kibana or Home Assistant than to a CRUD application. Concretely, that means:
| π₯ Ingest | CloudEvents 1.0 in both content modes (binary and structured), plus batch documents. JWT-authenticated, AIP-shaped, OpenAPI-documented. |
| π Search | A versioned filter grammar compiled to parameterised SQL β free text, event type, source, device, room, person, tag, severity, JSON payload paths and time ranges, with faceting and keyset pagination. |
| π Overview | An hourly rollup materialized view behind a dashboard: volume, severity mix, top sources, charts. |
| π‘ Live tail | Server-Sent Events carrying server-rendered rows β the same filter grammar, streaming. |
| π©Ί Operate | Consumer pause/resume/restart/offset control, a DLQ you can inspect and replay, Prometheus metrics, health probes, and end-to-end traces. |
Unknown event types are a feature, not an error. An event type this system has never seen is still accepted, still persisted, still searchable, and still viewable in the UI.
flowchart LR
subgraph outside["Outside the system"]
P["π± IoT producers<br/>devices and gateways"]
B["π€ Operator<br/>in a browser"]
end
subgraph services["applications/ β three services, three stacks"]
W["π‘οΈ <b>wolfram</b><br/>Tapir on Vert.x 5<br/>ingest"]
C["βοΈ <b>cobalt</b><br/>Pekko Streams + Cask<br/>consume, persist, operate"]
F["π₯οΈ <b>ferrite</b><br/>Play 3 + Twirl/htmx<br/>search, overview, live tail"]
end
subgraph infra["Infrastructure"]
K["π Apache Kafka<br/>events.cloudevents.v1<br/>and its .dlq"]
DB["π PostgreSQL<br/>schema events"]
end
P -->|"POST /v1/events<br/>CloudEvents 1.0, bearer JWT"| W
W -->|"produce, binary content mode,<br/>keyed by partitionKey,<br/>traceparent injected"| K
K -->|"committable source"| C
C -->|"Flyway migrate on boot, then<br/>INSERT ... ON CONFLICT DO NOTHING"| DB
C -.->|"undecodable β dead letter,<br/>inspectable and replayable"| K
F -->|"SELECT only, read-only pool"| DB
F -->|"server-rendered HTML<br/>and an SSE live tail"| B
One W3C trace context is injected into the Kafka headers at ingestion and extracted by the consumer, so a single trace spans HTTP β Kafka β database.
Three deployable services, each on a different Scala 3 web stack. They are named after metals rather than after their frameworks, so a name does not have to change if a stack does.
| Service | Stack | Responsibility | |
|---|---|---|---|
| π‘οΈ | wolfram | Tapir on Vert.x 5 | HTTP ingestion. A JWT-authenticated, AIP-shaped /v1/events API with Swagger UI at /docs. Validates CloudEvents and publishes to Kafka. Owns no state, invents no identity, repairs no bad input. |
| βοΈ | cobalt | Pekko Streams Kafka + Cask | Consumes, decodes and persists events, and runs the Flyway migrations. Cask serves the admin surface: metrics, health, consumer lifecycle control and DLQ inspect/replay. |
| π₯οΈ | ferrite | Play 3 + Twirl/htmx/Alpine/Tailwind | The web application: search, the overview dashboard, the live tail. Reads PostgreSQL; never sees Kafka. |
applications/ holds exactly those three. New shared code becomes a library, not a fourth service.
The shared contracts live in modules/, as libraries with no main and no image:
| Module | Contents | |
|---|---|---|
| π | kernel | The domain: the CloudEvents Envelope, the Observation ADT, the search Filter grammar and its querystring codec. |
| π | eventing | The Kafka wire format β the only place that knows both the domain envelope and the wire encoding, so producer and consumer cannot disagree about it. Dead-letter envelope, trace propagation. |
| ποΈ | persistence | The Flyway schema, the Hikari pools, the Magnum repositories, the Filter β SQL compiler, the maintenance jobs. |
| π | observability | One Micrometer/Prometheus metric vocabulary and one OTel tracing setup, shared so a dashboard written against one service works against the others. |
Dependency arrows point strictly inward:
ferrite β kernel, persistence, observability
cobalt β kernel, eventing, persistence, observability
wolfram β kernel, eventing, observability
Important
modules/kernel must stay framework-free. It depends on circe and the standard library and nothing else β
and that is not a convention, it is a build-load assertion. Any compile-scoped dependency outside
io.circe / org.scala-lang fails the build.
|
π CloudEvents are the source of truth Events are stored verbatim as |
π Search is pure PostgreSQL JSONB with GIN, BRIN on time, partial indexes and a rollup materialized view. No Elasticsearch, no second datastore to keep in sync β and nothing to reindex. |
|
π At-least-once, made idempotent The consumer commits only after a durable write, and the write deduplicates on the CloudEvents |
π§΅ One trace, end to end A W3C trace context is injected into the Kafka record headers at ingestion and extracted by the consumer, so a single trace spans HTTP β Kafka β database. |
|
π« Reject; never invent defaults wolfram never mints an |
π³οΈ Errors defined out of existence An empty filter is not an error, a present-but-empty parameter is absent rather than invalid, and an unknown event type still stores and still renders. See CONTRIBUTING.md. |
deploy/docker-compose.yml brings up the whole system on a single host β Postgres, Kafka, the three services, an
OpenTelemetry collector, Prometheus and Grafana.
Prerequisites: JDK 25, sbt 2 (sdk env adopts both from .sdkmanrc) and a working Docker daemon.
# 1 Β· build the three images
sbt ";ferrite/Docker/publishLocal;cobalt/Docker/publishLocal;wolfram/Docker/publishLocal"
# 2 Β· configure β POSTGRES_PASSWORD, APPLICATION_SECRET and GRAFANA_ADMIN_PASSWORD are mandatory
cd deploy
cp .env.example .env # .env is gitignored
$EDITOR .env
# 3 Β· validate the interpolation and the mandatory vars, then go
docker compose config -q
docker compose up -d| Service | URL | |
|---|---|---|
| π₯οΈ | The UI | http://localhost:9000/events |
| π₯ | Ingestion | POST http://localhost:8081/v1/events (bearer token required) |
| π | Swagger UI | http://localhost:8081/docs |
| βοΈ | cobalt admin | http://localhost:8082/admin/β¦ (bearer token, admin:read / admin:write) |
| π | Prometheus | http://localhost:9090 |
| π | Grafana | http://localhost:3000 β the Event observatory dashboard is already provisioned |
Every /v1 operation is authenticated, so the smoke test needs a token minted with the deployment's own
AUTH_SECRET and the events:write scope β docs/operations.md Β§2 has a copy-pasteable
minter, or use wherever you already issue tokens.
curl -fsS -X POST localhost:8081/v1/events \
-H "authorization: Bearer $TOKEN" \
-H 'ce-specversion: 1.0' \
-H 'ce-id: smoke-1' \
-H 'ce-source: urn:worxbend:smoke' \
-H 'ce-type: com.worxbend.smoke.v1' \
-H "ce-time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-H 'content-type: application/json' \
-d '{"deviceId":"smoke","severity":"info","value":1}'200 with the created resource (AIP-133 returns the resource, not a receipt) β including a destination
naming the topic, partition and offset the broker wrote it to. The event appears at
http://localhost:9000/events within a second. Without the token: 401 with the AIP-193
{"error":{"status":"UNAUTHENTICATED",β¦}} envelope.
Warning
This is a single-host homelab deployment, and it is honest about it. One Kafka broker at replication factor 1, no TLS, traces logged and dropped rather than sent to a backend, and Play on a milestone release. None of that stops the stack coming up clean from a cold checkout; all of it matters before this runs anywhere that matters. The current list is Known limitations, and it is kept to things that are true today.
| Route | What it is |
|---|---|
GET / |
The overview β volume, severity mix and top sources, read from the hourly rollup materialized view. |
GET /events?β¦ |
The search list β the full filter grammar, faceted, keyset-paginated, served as a full page or as an htmx fragment of the same URL. |
GET /events/{eventUid} |
One event, including its verbatim CloudEvent. |
GET /live?β¦ |
The live tail β text/event-stream carrying server-rendered rows, filtered by the same grammar. |
Search parameters are q, type, source, device, room, person, tag, severity, data, from,
until β repeats are meaningful, because a facet is a multi-value selection β plus limit, sort and an opaque
cursor. The grammar is versioned (v=1) so a future change is detectable rather than silently misread.
sbt 2.0.3 Β· Scala 3.8.4 Β· JDK 25 Β· Play 3.1.0-M9. .sdkmanrc pins the JDK and sbt; run sdk env to adopt
them. Build definitions under project/ are themselves Scala 3.
Note
Play is pinned to a milestone deliberately. 3.1.0-M9 is the first Play line cross-published for sbt 2. The
stable 3.0.x line ships only an sbt 1 plugin, so "fixing" the milestone version means giving up sbt 2.
Sources use indentation-based syntax β -new-syntax -indent is on and -Werror promotes every warning to an
error, so braces, unused imports and discarded non-Unit values are compile failures rather than review comments:
| Flag | What it costs you |
|---|---|
-new-syntax -indent |
Braces are a compile error, not a style nit. 120 columns. |
-Wunused:all |
An unused import or parameter fails the build. |
-Wvalue-discard / -Wnonunit-statement |
Every Micrometer/OTel/Guice builder returns this, so bind it to val _ = or chain it into one expression. |
sbt verify # fmtCheck + headerCheck + Test/testFull β exactly what CI runs. Fast; no Docker.
sbt verifyIt # IT/testFull β the slow tier. Needs a working Docker daemon.
sbt fmt # scalafmt, build sources included
sbt headerCreate # stamp licence headers β never hand-write one
sbt doc # Scaladoc; -Werror applies, so a broken doc link fails the build
sbt wolfram/run # :8080 (HTTP_PORT). Needs AUTH_SECRET, or AUTH_ENABLED=false.
sbt cobalt/run # :8080 (HTTP_PORT)
sbt ferrite/run # :9000, Play dev mode
sbt ferrite/tailwind # regenerate ferrite's committed stylesheet from the Twirl templates
sbt ferrite/tailwindCheck # fail if it is stale β see docs/development.md Β§8
sbt "cobalt/testOnly com.worxbend.cobalt.BatchProcessorSuite" # one suiteCaution
The sbt 2 test trap. sbt 2 inverted sbt 1's naming: test is the incremental task and testFull
runs everything. Test/test will report success having executed zero tests. Always Test/testFull and
IT/testFull β which is why verify is spelled the way it is. Never run sbt clean.
Run sbt verify before handing work back: headerCheck fails on any file sbt-header has not stamped, and new
files are only stamped once they have been compiled or sbt headerCreate has run.
Two tiers, and they are separate on purpose:
| Tier | Command | Needs Docker | What it covers |
|---|---|---|---|
| β‘ Fast | sbt verify |
β | Formatting, licence headers, and every unit and property test. Also compiles, formats and header-checks src/it, so a broken integration tree still fails here. |
| π³ Integration | sbt verifyIt |
β | src/it/scala suites that provision their own Testcontainers. |
munit leads; ScalaTest appears only where Play's test helpers require it; ScalaCheck carries the properties β
FilterGenerators, WireGenerators and Generators already exist, so reuse them. Test the pure decision, not
the socket.
Every service exposes the same three operational endpoints, on its own port:
| Endpoint | Semantics |
|---|---|
GET /metrics |
Prometheus text exposition, from one shared Micrometer vocabulary. |
GET /health/live |
Always 200 {"status":"UP"}, Cache-Control: no-store. A dead process is a container problem. |
GET /health/ready |
200/503 with a detail β wolfram reports broker reachability, cobalt reports broker and database, ferrite reports whether the read pool hands out a connection. |
The compose stack ships an OpenTelemetry collector, Prometheus with 13 alerting rules across 4 groups, and a
provisioned Grafana dashboard. docs/operations.md Β§5 reads every metric and says which
one is the leading indicator for which failure β partition headroom, consumer lag, ingest rejections by reason,
the search-latency SLO.
playground/
βββ applications/
β βββ wolfram/ π‘οΈ Tapir on Vert.x 5 β ingest
β βββ cobalt/ βοΈ Pekko Streams + Cask β consume, persist, operate
β βββ ferrite/ π₯οΈ Play 3 + Twirl/htmx β search, overview, live tail
βββ modules/
β βββ kernel/ π the domain β framework-free, asserted at build load
β βββ eventing/ π the Kafka wire format
β βββ persistence/ ποΈ schema, pools, repositories, Filter β SQL
β βββ observability/ π metrics, tracing, log context
βββ deploy/ π³ docker-compose, Postgres and observability config
βββ docs/ π the MkDocs site β ADR, services, ops, event model
βββ project/ π§ the sbt 2 build definition (itself Scala 3)
The full site is published to worxbend.github.io/playground, Scaladoc
for every module included, and it is built with mkdocs build --strict so a broken link or stale anchor fails CI.
| Document | What it answers | |
|---|---|---|
| π | docs/adr/0000-architecture.md | The architecture contract β dependency table, schema DDL, index rationale, risks and their fallbacks. Read this first. |
| πΊ | docs/architecture/overview.md | Containers, the trust boundary, and the journey of one event. |
| π¦ | docs/event-model.md | What a CloudEvent must contain and what the filter grammar accepts. |
| π | docs/data/schema.md | Which column is generated from what, and which index answers which query. |
| π‘οΈβοΈπ₯οΈ | docs/services/ | One page per service β surface, failure modes, metrics. |
| π¨ | docs/operations.md | Runbooks, environment variables, metrics, and Β§8 Known limitations. |
| π§ | docs/development.md | Build, test tiers, module layout and the dependency rule. |
| π§ | docs/architecture/maintainers.md | Recipes for common changes, and the trap catalogue. |
This codebase documents why, not what. Scaladoc on a public type explains the decision and names the failure mode it avoids. A stale comment here is more dangerous than in a codebase nobody reads, because these are trusted β if you change behaviour, change the sentence that described it.
CONTRIBUTING.md is the design standard a change is held to, framed on A Philosophy of Software Design: deep modules, information hiding, defining errors out of existence, and a red-flag checklist to run your own diff against. It also has a section on the failure modes specific to agents β verify before asserting, a test that asserts nothing still looks green, report what you couldn't do rather than papering over it.
The short version:
- πΏ Branch, and keep the change one idea wide.
- βοΈ Match the surrounding style β indentation syntax, 120 columns, and a comment that says why.
- β
sbt verifymust pass;sbt verifyIttoo if you touched anything undersrc/it. - π¨
sbt ferrite/tailwindif you touched a template's classes β the stylesheet is committed output. - π Open a PR. Bug reports and feature requests have templates.
MIT β the licence sbt-header stamps on every source file and the value build.sbt publishes. See
LICENSE. Headers are applied automatically; never hand-write one.