How this is built

An event-driven security analysis platform, built to demonstrate real distributed systems — not just claim it.

A Kafka-based log pipeline, a stateless-and-stateful detection engine, Postgres-backed incident correlation, LLM incident summarization with a deterministic fallback, and a real-time console with a live pipeline trace view.

How it's built

01

Event-driven ingestion

A scenario-driven generator emits realistic security logs — auth, network, process — onto Kafka (KRaft mode). A /simulate API fires attacks on demand.

02

Schema-validated at the boundary

Every raw log is parsed and validated against a Zod contract before anything downstream sees it — one canonical NormalizedEvent shape, an eventId threaded end-to-end as a trace id.

03

Stateless + stateful detection

A rule engine runs both signature checks (SQLi, directory enumeration) and windowed/statistical rules (brute-force bursts, per-entity rolling-baseline anomaly scoring) against the same Rule interface.

04

Correlation into incidents

Related alerts from the same entity within a time window collapse into one incident, transactionally persisted in Postgres via Prisma — one attacker, one story, not a wall of alerts.

05

AI summarization, degrading gracefully

An LLMProvider interface (Gemini/Groq via plain fetch) drafts a plain-language incident summary behind a debounced job queue — falling back to a deterministic template with zero API keys.

06

Real-time, traceable console

Every alert, incident, and summary streams live over WebSockets. The pipeline trace view follows one event by its eventId across Kafka, Elasticsearch, and Postgres — proof, not a diagram.

Engineering decisions

CQRS-lite storage split

Elasticsearch owns the searchable event stream; Postgres owns the relational incident/alert/summary graph. Each store does the one thing it is actually good at.

eventId end-to-end

Every event carries a UUID set at the generator that doubles as its correlation id all the way through parsing, detection, correlation, and the AI summary — the trace view is a real query against it, not a mock.

Mechanically enforced module boundaries

eslint-plugin-boundaries fails the build on a deep cross-module import. Each module in apps/api/src talks to the others only through its public index.ts or the internal event bus — a real constraint, not a convention.

Three-tier LLM degradation

Real LLM → deterministic template → still fully-typed output. The whole system runs with zero API keys and the dashboard never has to know which tier produced a summary.

Redis is optional, not required

The detection engine’s sliding-window state and the internal event bus both have a Redis-backed implementation behind the same interface, gated on one REDIS_URL env var — unset, everything stays in-process.

Stack

TypeScript (strict)Node 24FastifyKafka (KRaft)ElasticsearchPostgreSQL + PrismaNext.jsWebSocketsZodDocker Composepnpm + Turborepo