Skip to content

Repository files navigation

playground event observatory

playground

πŸ”­ An event observatory for smart-home and IoT telemetry

CloudEvents in over HTTP β†’ Kafka β†’ PostgreSQL β†’ a server-rendered search UI. Three Scala 3 services, three different web stacks, one sbt 2 build.


Scala CI Docs Supply chain

Scala sbt JDK CloudEvents PostgreSQL Kafka

πŸ“– Documentation Β· πŸ› Decision record Β· βš™οΈ Operations Β· 🀝 Contributing


πŸ“‘ Table of contents


πŸ”­ What this is

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.


πŸ› Architecture

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
Loading

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.

The three services

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 libraries

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.


🎯 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.

🐘 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 (source, id) identity β€” so a redelivery is a no-op, not a duplicate row.

🧡 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 id, a source, a time or a partition key on a producer's behalf. Every threshold is a rejection threshold. That is what makes the clock-skew metric a real reading of the fleet.

πŸ•³οΈ 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.


πŸš€ Quickstart

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

Send your first event

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.


πŸ–₯️ What you get in the browser

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.


πŸ›  Development

Toolchain

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.

Commands

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 suite

Caution

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.

Testing

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.


πŸ“Š Observability

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.


πŸ“ Project layout

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)

πŸ“š Documentation

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

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:

  1. 🌿 Branch, and keep the change one idea wide.
  2. ✍️ Match the surrounding style β€” indentation syntax, 120 columns, and a comment that says why.
  3. βœ… sbt verify must pass; sbt verifyIt too if you touched anything under src/it.
  4. 🎨 sbt ferrite/tailwind if you touched a template's classes β€” the stylesheet is committed output.
  5. πŸ” Open a PR. Bug reports and feature requests have templates.

βš–οΈ Licence

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.

About

No description or website provided.

Topics

Resources

Contributing

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages