A distributed order-processing simulator that makes event-driven architecture visible. Click a scenario and watch an order saga propagate through Kafka β published, consumed, compensated, dead-lettered, replayed β streamed to the browser in real time.
It is not a real e-commerce platform. It is a simulator whose product is the live visualization of how independent services choreograph through an event log.
π Live demo: eventtracer.cloudblick.com β hosted end to end on a Hetzner VPS.
Most backends say "we use Kafka." Few can show what actually happens between services. EventTracer turns the normally-invisible event flow into something you can watch, pause on, and explain β including the failure modes that separate a tutorial from real distributed- systems understanding: compensation, consumer lag, dead-letter queues, idempotency, and replay. It's built as a learning / portfolio project to demonstrate those patterns end to end, from the broker to the browser.
- Choreographed saga across five domain services with no central orchestrator β each service simply reacts to events.
- Command/event split β the browser issues commands over HTTP; events stream back over a WebSocket. The browser never touches Kafka.
- Transactional outbox β reliable publishing without the dual-write problem.
- Decoupled observability β a dedicated Event Monitor is "just another consumer," so the domain services know nothing about the UI.
- Resilience patterns, each as a one-click scenario:
- Failed payment β refund (compensating transaction)
- Delayed processing (watch consumer lag rise, then drain)
- Kill / pause a consumer (events buffer as lag, then catch up on resume)
- Poison message β dead-letter queue
- Idempotent redelivery (no double-charge on a duplicate event)
- Replay (rebuild the whole timeline from the log, read-only)
A Next.js dashboard renders the live system:
- Command panel β scenario buttons with editable, regenerable order inputs (proving the values are real, not hardcoded).
- Service health β per-service status dots (up / paused / down) and consumer lag; payment and shipping consumers can be paused/resumed straight from the UI.
- Live timeline β every event with its type, correlation id, causal
triggeredBy, and a plain-English narration of what just happened. Toggle a flat stream or grouped-by-saga view, filter to DLQ only, and click any event to read its full published envelope JSON. - A "how it works" guide, a 300-word explainer footer, and event-family color coding.
Browser UI ββPOST /orders, /control/... (HTTP)βββΆ API Gateway βββΆ Order Service
β² β writes order + outbox (1 tx)
β WebSocket (events out) βΌ relay publishes
β βββββ Kafka event log (KRaft) βββββ
Event Monitor βββ consumes all topics ββββββββββββ€ order.created β
β payment.succeeded / .failed β
β shipment.created β
β refund.initiated β
β notification.sent β
β <topic>.DLQ β
ββββ¬βββββββββ¬ββββββββββ¬ββββββββββ¬βββ
Payment Shipping Notification Refund
The browser sends commands; services react to events. Kafka runs in KRaft mode
(no Zookeeper). Every decision is recorded in ARD.md.
| Service | Role |
|---|---|
| API Gateway | Only public HTTP entry; accepts commands (POST /orders, /orders/:id/redeliver, /control/:service/:action) |
| Order | Creates orders, publishes order.created via the outbox |
| Payment | Consumes order.created; publishes payment.succeeded / payment.failed (deterministic, seeded) |
| Shipping | Consumes payment.succeeded; publishes shipment.created |
| Notification | Consumes customer-facing events; emits notification.sent (email/SMS sim) |
| Refund | Consumes payment.failed; runs the compensation saga (refund.initiated) |
| Event Monitor | Consumes all topics + the control plane; streams everything to the UI over WebSocket; serves POST /replay |
Backend: TypeScript Β· NestJS (monorepo, Kafka microservice transport) Β· Apache Kafka (KRaft) Β· PostgreSQL (schema per service) Β· TypeORM. Frontend: Next.js (App Router) Β· React Β· TypeScript Β· Tailwind CSS Β· socket.io-client Β· lucide-react. Infra: Docker Compose Β· Caddy (reverse proxy + automatic TLS) Β· Hetzner Cloud VPS.
The full rationale lives in ARD.md. In brief:
| # | Decision | Why |
|---|---|---|
| 001 | Commands over HTTP, never direct Kafka from the browser | Trust boundary + protocol reality |
| 002 | Events reach the UI only via the Event Monitor β WebSocket | Decouple observability from the domain |
| 003 | Choreographed saga (no orchestrator) | Showcase event-driven flow; refund = compensation |
| 004 | Transactional outbox for all publishing | Solve the dual-write problem |
| 005 | Kafka in KRaft mode | Current standard, fewer moving parts |
| 006 | At-least-once delivery + idempotent consumers | Correct under redelivery |
| 007 | Dead-letter queue for poison messages | Survive un-processable messages |
| 008 | PostgreSQL schema per service | Data ownership in one container |
| 009 | NestJS monorepo, one app per service | Shared contracts, independent services |
| 010 | Docker Compose for local orchestration | One-command startup |
| 011 | Notification publishes notification.sent |
Make the notify step visible in the UI |
| 012 | Read-only replay from the log | Rebuild the timeline without re-triggering the saga |
| 013 | Redeliver the identical event from its owner | Prove idempotency: no second charge |
| 014 | Kill-a-consumer = reversible pause via a control topic | One-click pause/resume; lag builds then drains |
apps/
api-gateway/ # only public HTTP entry
order-service/ payment-service/ shipping-service/
notification-service/ refund-service/
event-monitor/ # consumes all topics β WebSocket
libs/
events/ # shared event envelopes + topic/command names
kafka/ # DLQ helper + consumer pause/resume control
outbox/ # outbox entity + relay
persistence/ # shared TypeORM naming strategy
frontend/ # Next.js + Tailwind dashboard (own package.json)
initdb/ # Postgres per-service schema bootstrap
Prerequisites: Docker (with Compose) and Node.js 22+.
make up-all # builds + starts infra, all 7 services, and the frontend
# then open:
make ui # http://localhost:3001Tear down with make down (keep data) or make down-v (also reset Kafka log + DB).
Run the infrastructure in Docker and the apps on your machine:
make up # kafka + postgres only
# each in its own terminal:
make api-gateway-dev # :5000
make event-monitor-service-dev# :4000
make order-service-dev
make payment-service-dev
make shipping-service-dev
make notification-service-dev
make refund-service-dev
# the UI:
make frontend-install # first time only
make frontend-dev # http://localhost:3001The frontend reads
NEXT_PUBLIC_GATEWAY_URL(defaulthttp://localhost:5000) andNEXT_PUBLIC_MONITOR_URL(defaulthttp://localhost:4000); the defaults work out of the box.
Open http://localhost:3001 and try, in order:
- Place order β watch
order.created β payment.succeeded β shipment.created β notification.sent. - Failed payment β see the refund compensation branch instead.
- Kill a consumer (top bar) β pause shipping, place a succeeding order, watch its lag climb with no shipment, then resume and watch it drain.
- Poison β DLQ, then toggle DLQ view to isolate the dead-letter.
- Replay β the board rebuilds from the Kafka log (read-only, dimmed rows).
Click any event to inspect its raw envelope; switch stream / grouped to see sagas as cards.
A live instance runs end to end on a single Hetzner Cloud VPS (Ubuntu, 4 GB) at eventtracer.cloudblick.com.
The entire stack β all seven NestJS apps, the Next.js UI, Kafka (KRaft), and PostgreSQL β runs
from the same docker compose --profile apps up on the box. A host-level
Caddy reverse proxy terminates TLS (automatic Let's Encrypt
certificates) and routes everything under one domain:
| Path | Upstream |
|---|---|
/api/* |
API Gateway (:5000) β the /api prefix is stripped |
/socket.io/*, /replay |
Event Monitor (:4000) β including the WebSocket upgrade β wss:// |
/* |
Frontend (:3001) |
Because the browser sees a single origin, there's no CORS to manage and the event stream runs
over secure wss://. The frontend's NEXT_PUBLIC_* URLs are baked at image-build time (compose
build args), so the deployed bundle points at the public origin. For defense in depth, the app and
database ports are bound to 127.0.0.1 (only Caddy and the host can reach them) behind a cloud
firewall that allows just 22/80/443 inbound. Additional projects follow the same shape β one
subdomain, one Caddy block, one shared proxy.
Deployment is git-based β push locally, then pull and rebuild on the server:
# locally
git push
# on the server
ssh <host> && cd eventtracer
make deploy # git pull + rebuild changed images + recreate containersmake deploy is safe to run repeatedly: Docker's layer cache skips unchanged steps, the Kafka and
Postgres data volumes persist, and only containers whose image or config actually changed are
recreated. The frontend's public URLs come from a server-only .env (git-ignored), so rebuilt
bundles keep pointing at the live origin. A routing change is the exception β edit
/etc/caddy/Caddyfile and sudo systemctl reload caddy, since Caddy runs outside Compose.
| Port | Service |
|---|---|
| 3001 | Frontend (Next.js) |
| 5000 | API Gateway (REST) |
| 4000 | Event Monitor (WebSocket + /replay) |
| 9092 | Kafka (host listener) |
| 5432 | PostgreSQL |
make help lists everything. Highlights: up / up-all / down / down-v, the per-service
*-dev targets, frontend-dev / frontend-build, build / lint / test, and the
kafka-topics / kafka-groups / db-schemas helpers.
specs.mdβ what we're building: services, topics, UI, scenarios, milestones.ARD.mdβ architecture decision records: the why behind each choice.CLAUDE.mdβ working agreement and the inviolable architectural rules.
UNLICENSED β portfolio / educational project by Martin Oputa.
