Full-stack observability platform for LLM-powered applications. Helix wraps multi-provider LLM calls (Anthropic, OpenAI, Google, and 300+ models via OpenRouter) in a lightweight SDK, captures inference metadata — latency, token usage, errors, session IDs — and ships it asynchronously to an ingestion pipeline backed by PostgreSQL. A Next.js chatbot UI demonstrates the SDK; Grafana dashboards expose real-time latency, throughput, and error metrics.
The design goal: production-grade observability over LLM calls without adding latency to the user-facing response path.
- Assignment Coverage
- Demo
- Setup
- Architecture Overview
- Schema Design Decisions
- Architecture Notes
- Tradeoffs Made
- What I Would Improve With More Time
| Requirement | Where it lives |
|---|---|
| Chatbot — multi-turn conversation | apps/web chat UI; apps/api SSE route /conversations/:id/messages |
| Chatbot — short conversational context | Redis ctx:{conversation_id}, trailing CONTEXT_WINDOW_SIZE (10) msgs |
| Chatbot — simple UI | Next.js 16 chat at /chat; landing at / |
| SDK / wrapper around LLM calls | packages/sdk — unified LLMClient over four providers |
| Inference metadata capture | LLMCallMetadata (provider, model, latency, tokens, status, session ids, previews, timestamps) |
| Near real-time log shipping | SDK → Redpanda topic llm.inference.logs (fire-and-forget) |
| Ingestion service | apps/ingestion Kafka consumer |
| Payload validation / parsing | Zod KafkaLogPayloadSchema at the consumer boundary |
| Metadata extraction | Envelope split + PII redaction layer in processMessage |
| Database storage — chat messages | messages table |
| Database storage — inference logs | inference_logs TimescaleDB hypertable |
| Database storage — extracted metadata | Columns on inference_logs (tokens, latency, first_token_ms, …) |
| Bonus | Status | Notes |
|---|---|---|
| Multi-provider support | Done | Anthropic, OpenAI, Gemini + OpenRouter (300+ models via one key) |
| Streaming responses | Done | SSE end-to-end; SDK stream interception measures firstTokenMs |
| Latency + throughput + error dashboards | Done | Grafana — Overview (Prometheus) + per-provider / per-conversation (Postgres) |
| Docker Compose one-command setup | Done | docker compose -f infra/docker-compose.yml up --build |
| Event-based architecture | Done | Redpanda (Kafka-compatible) topic llm.inference.logs + DLQ |
| PII redaction | Done | Two layers — SDK before emit, ingestion before DB write |
| Self-hosted k8s deploy | Charts ready | Helm charts under infra/k8s/ per service; not yet applied to a live cluster |
| Frontend — cancel a conversation | Done | "Archive" in the UI; POST /conversations/:id/cancel + client-disconnect abort |
| Frontend — list conversations | Done | Sidebar rail (components/sidebar/) — TanStack Query cached |
| Frontend — resume a conversation | Done | Click any row; full history restored from DB |
No hosted link or recorded walkthrough — provider API keys not available at submission time. Stack runs end-to-end locally via the one-command Docker setup below; supply any one of ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, or OPENROUTER_API_KEY in .env to exercise the full flow (chat → SDK → Kafka → ingestion → Postgres → Grafana).
- Docker + Docker Compose (v2.24+) — the one-command path
- For local dev outside Docker: Node >= 22, pnpm 10.33.2
(
corepack enable && corepack prepare pnpm@10.33.2 --activate)
# 1. Create the env file (provider keys are optional)
cp .env.example .env
# then edit .env and set the keys you have:
# ANTHROPIC_API_KEY=... OPENAI_API_KEY=...
# GOOGLE_API_KEY=... OPENROUTER_API_KEY=...
# GOOGLE_API_KEY is the Gemini key. OPENROUTER_API_KEY unlocks the
# "More models" dropdown (DeepSeek, Grok, Llama, Mistral, Qwen, …).
# A provider with no key simply cannot be selected for a conversation
# — the UI surfaces a friendly 503 error if you try.
# 2. Build and start the full stack
docker compose -f infra/docker-compose.yml up --build
# 3. Open the chat UI
# http://localhost:3000
# Stop everything (add -v to also wipe the data volumes)
docker compose -f infra/docker-compose.yml downIf you change provider keys in .env after the stack is up, recreate
the gateway so it reloads them: docker compose -f infra/docker-compose.yml up -d api.
This starts everything with no manual steps. Two one-shot init containers run first and exit:
migrate—drizzle-kit push(schema sync) +hypertable.sql(TimescaleDB hypertable conversion)redpanda-init— creates thellm.inference.logs(24h retention) andllm.inference.dlqKafka topics
The application services wait on infra health checks and both init containers completing before they start.
Without provider keys the stack still boots — a provider with no key configured simply cannot be selected for a conversation.
| Service | URL | Notes |
|---|---|---|
| Web (chat UI) | http://localhost:3000 | |
| API gateway | http://localhost:3001 | /health, /metrics |
| Ingestion | http://localhost:3002 | /health, /metrics |
| Grafana | http://localhost:3003 | anonymous viewer; admin/admin |
| Prometheus | http://localhost:9090 | |
| Postgres | localhost:5433 | helix / helix |
| Redis | localhost:6380 | |
| Redpanda | localhost:19093 | external Kafka listener |
Postgres, Redis, and Redpanda are mapped to non-standard host ports
(5433 / 6380 / 19093) so they do not clash with a host-native
Postgres/Redis/broker. The Helix services reach them over the Compose
network on their normal ports (postgres:5432, etc.) regardless.
Postgres, Redis, and Redpanda still need to run (use the compose file for just those, or local installs). Then:
pnpm install
pnpm --filter @helix/db db:push # sync schema
psql "$DATABASE_URL" -f packages/db/sql/hypertable.sql
pnpm dev # turbo runs all apps in watch modeAll environment variables are documented in .env.example and validated
at startup with Zod — the process exits fast on a missing/invalid key.
pnpm build # turbo build — all six packages
pnpm typecheck # tsc --noEmit across the monorepo
pnpm test # vitest (types + sdk)
pnpm dev # all apps in watch modeAll commands assume -f infra/docker-compose.yml (omitted below for
brevity — alias dc='docker compose -f infra/docker-compose.yml' if you
run these often).
# Stack lifecycle
dc up --build # build images + start everything (foreground)
dc up -d --build # same, but detached
dc down # stop, keep volumes (pg/redis/redpanda data persists)
dc down -v # stop AND wipe data volumes — full reset
# Targeted service ops
dc up -d --build web # rebuild + restart a single service
dc up -d api # restart api only (e.g. to reload .env keys)
dc restart ingestion # restart without rebuild
dc up migrate # re-run the one-shot DB migration container
# Inspection
dc ps # service status + health
dc logs -f api # tail logs for one service
dc logs -f api ingestion # multi-service tail
dc exec api sh # shell into a running container
dc exec postgres psql -U helix helix # psql into the DBHelix is a Turborepo pnpm monorepo with three apps and three shared packages.
/
├── apps/
│ ├── web/ # Next.js 16 chat UI (App Router, Turbopack)
│ ├── api/ # Fastify API gateway — conversation CRUD, SSE
│ └── ingestion/ # Kafka consumer + PostgreSQL writer
├── packages/
│ ├── sdk/ # Unified LLM client (Anthropic, OpenAI, Gemini, OpenRouter) + redaction
│ ├── db/ # Drizzle ORM schema + TimescaleDB hypertable SQL
│ └── types/ # Shared Zod schemas + inferred TypeScript types
└── infra/
├── docker-compose.yml # local one-run stack
├── k8s/ # Helm charts for EKS (one per service)
└── grafana/ # dashboards + provisioning
User → Next.js (SSE) → Fastify API Gateway → SDK Wrapper → LLM Provider
│
Kafka: llm.inference.logs
│
Ingestion Service → PostgreSQL → Grafana
The user response path and the log write path are fully decoupled. The SDK emits one Kafka event per call, fire-and-forget; ingestion consumes and persists out-of-band. A broker outage never blocks a reply.
apps/web— UI only. Talks exclusively to its own same-origin/api/*route handlers, which proxy to the gateway server-side (lib/server.ts). The browser never sees the gateway URL and never touches the DB or Kafka.apps/api— conversation CRUD, message persistence, SSE streaming, Redis context cache. Importspackages/sdkto make LLM calls. Does not writeinference_logsdirectly.apps/ingestion— the only writer ofinference_logs. Consumes Kafka, validates, redacts, persists. Serves no HTTP to the frontend.packages/sdk— pure TypeScript, no HTTP layer. One file per provider behind a singleLLMClientinterface. Four providers: Anthropic, OpenAI, Gemini (direct SDKs), and OpenRouter (reuses theopenaiSDK againstopenrouter.ai/api/v1— one key, 300+ models).hasProvider(name)lets callers refuse a request up-front when a key is missing.packages/db— Drizzle schema (source of truth) + the one piece of hand-written SQL (hypertable conversion).packages/types— Zod schemas shared everywhere; no duplicated type definitions across apps.
Next.js 16 · Fastify 5 · Drizzle ORM · PostgreSQL + TimescaleDB · Redis · Redpanda (Kafka-compatible) · Prometheus + Grafana · Docker Compose (local) · AWS EKS + Helm (production target).
Four tables in PostgreSQL. Full DDL in context/architecture.md.
| Table | Purpose |
|---|---|
conversations |
One row per chat session (provider, model, status). |
messages |
Every user/assistant/system turn. Content is redacted. |
inference_logs |
One row per LLM API call. TimescaleDB hypertable. |
providers |
Provider configuration (name, base URL, active flag). |
inference_logs is a TimescaleDB hypertable on request_at. The
Grafana dashboards are almost entirely time-bucketed queries (p50/p95/p99
latency over time, throughput per minute). A hypertable partitions by
time automatically, making those range scans orders of magnitude faster
than a plain table — without changing the query API. This is the one
schema decision that drove a technology choice.
inference_logs has no primary key. TimescaleDB hypertables cannot
carry a primary key that excludes the partitioning column. The table keeps
an id with a default but no PK constraint; idempotency is handled
upstream via the Kafka payload's eventId.
Schema synced with drizzle-kit push, no migration files. For a
one-run Docker setup, syncing Drizzle definitions directly is simpler than
a migration history. The Drizzle schema in packages/db/src/schema/ is
the single source of truth. The only hand-written SQL is
sql/hypertable.sql — create_hypertable is a TimescaleDB function that
must run after the base table exists, and is idempotent so it can run on
every boot. (Tradeoff: no rollback story — see below.)
Stored content is always PII-redacted. messages.content and the
input_preview / output_preview columns on inference_logs only ever
hold redacted text. Previews are capped (~200 chars) — enough for a
dashboard glance, not a full transcript.
Drizzle over Prisma. Lighter, better TypeScript inference, closer to raw SQL. Tradeoff: smaller ecosystem.
- An LLM call completes inside the SDK (
apps/apimade the call). - The SDK measures
firstTokenMs/latencyMs, builds oneKafkaLogPayload(a versioned envelope:schemaVersion,eventId,emittedAt+ the call metadata), redacts the previews, and emits it to thellm.inference.logstopic — fire-and-forget. apps/ingestionconsumes the topic.processMessageruns the pipeline: JSON parse →KafkaLogPayloadSchemaZod validation → PII redaction (second layer) → insert intoinference_logsvia Drizzle.- The DB write is retried up to 3×. Validation failures or persistent
write failures are routed to the
llm.inference.dlqtopic withx-dlq-*headers carrying the original raw bytes. processMessagenever throws — every path resolves to either a write or a DLQ route, so the kafkajs consumer offset always advances and the loop never stalls.
Grafana reflects new calls within seconds of the DB write.
- One Kafka message per LLM call. Emitted exactly once, after the stream closes (or errors, or is cancelled) — never mid-stream.
- Asynchronous, never blocking. The emit is fire-and-forget with a
2000ms timeout on the producer
send(). If Kafka is slow or down, the SDK logs to stderr and moves on — the user already has their response. - Two redaction layers. PII (email, phone, SSN, credit card) is
redacted once in the SDK before emit, and again in ingestion before the
DB write. Defense in depth: a raw payload never reaches the database
even if one layer is bypassed. Redaction logic is a single shared module
(
packages/sdk/src/redaction.ts). - Status is always captured. Success, error, and cancelled calls all produce a log row. Cancelled/errored calls keep whatever partial metadata and output were captured up to that point.
- Prometheus metrics are exposed separately from the Kafka log path —
helix_api_*from the gateway,helix_ingestion_*from ingestion — scraped every 10s and visualised in Grafana.
- Decoupled write path. Because logging goes through Kafka, the ingestion service scales independently of the gateway. Under load, add ingestion consumers in the same consumer group — Redpanda rebalances partitions across them.
- Redpanda partitioning. The
llm.inference.logstopic can be partitioned (keyed byconversationId) to parallelise consumption while preserving per-conversation ordering. - Stateless services.
web,api, andingestionhold no local state — all state is in Postgres / Redis / Kafka — so they scale horizontally behind a load balancer (the EKS deployment target). - TimescaleDB keeps time-range dashboard queries fast as
inference_logsgrows; retention/compression policies can cap storage. - Redis context cache removes a DB round-trip on every message. The
trailing-N-message window is read from
ctx:{id}(TTL 24h); a cold miss falls back to the DB. - Backpressure. Kafka is the buffer — an ingestion slowdown grows consumer lag rather than dropping data or stalling user responses.
- Current limits: single-node Redpanda/Postgres/Redis in the local compose; rate limiting is a per-session sliding window in Redis.
- Kafka unreachable → the SDK catches the emit error, logs to stderr, continues. The log event is dropped; the user response is unaffected. This is invariant #1: the log write path never blocks the user response.
- LLM provider error → the SDK still emits a log event with
status: 'error'and anerror_code, then propagates the error to the gateway, which writes an SSEerrorframe (carrying the SDK message) before closing the stream. - Provider has no API key configured → the gateway refuses
before hijacking the SSE response, returning
503 { error: { code: "provider_not_configured", message } }. The client surfaces a friendly "This model isn't available on the server right now" message instead of a silent stream close. - Conversation cancelled → the gateway closes the SSE stream and sets
status='cancelled'. Partial assistant output is persisted; the SDK emits acancelledlog event. Cancel never deletes — a cancelled conversation stays listable and viewable; posting to it returns409. - Ingestion DB write fails → retried 3×, then the message goes to the
DLQ topic.
processMessagenever throws, so the consumer never crashes. - Invalid Kafka payload (fails Zod validation) → routed straight to the DLQ; not retried.
- Cold context cache (conversation older than the 24h Redis TTL) → the context window is rebuilt from redacted DB history. Accepted minor quality degradation rather than an error.
- Client disconnect mid-stream → the gateway detects the dropped
connection, aborts the upstream LLM call, and the SDK emits a
cancelledlog.
drizzle-kit push, no migration files. Simple for a one-run setup; no rollback story or schema history. A production deploy would want versioned migrations.- Fire-and-forget logging. Guarantees the user response is never blocked, but a Kafka outage means log events are dropped, not queued — observability data can be lost during an incident. Acceptable because observability is non-critical relative to the user reply.
- No authentication. Single-user / internal-tool assumption. All conversations are globally accessible; there is no ownership model. Rate limiting is per-session only.
- Compiled
dist/vstsxin Docker. The shared packages expose TypeScript source directly (package.json main → src/*.ts), soapi/ingestionrun undertsxin their containers rather than a compiled build. Simpler workspace wiring; slightly heavier runtime than a bundled artifact.webships a realnext build. - Redpanda over Kafka. Single binary, no ZooKeeper — great for local dev. Kafka-compatible API means a production Kafka swap is config-only.
- SSE over WebSockets. Streaming is one-directional (server → client); SSE is simpler to proxy through Traefik and needs no persistent socket.
- Drizzle over Prisma. Lighter and better-typed; smaller ecosystem.
- Single-node infra in compose. One Postgres / Redis / Redpanda node — fine for local dev, not HA.
- Raw text in the Redis context cache.
ctx:{id}holds un-redacted message text (Redis is ephemeral, not "the database") to keep model responses high quality; the DB copy is always redacted.
- Versioned DB migrations with a rollback path, replacing
drizzle-kit pushfor any non-local environment. - Durable log delivery — a local disk spool or outbox so a Kafka outage delays rather than drops observability data.
- Verify the EKS deployment end to end — the Helm charts
(
infra/k8s/) are written but not yet applied to a live cluster;helm lint/helm templateand a realhelm installneed a pass. - HA data stores on EKS — the
postgres/redis/redpandacharts are single-node StatefulSets; move to managed RDS / ElastiCache / MSK. - Verify the Grafana dashboards against a live stack — the JSON is
committed (
infra/grafana/dashboards/) but not yet confirmed with metrics actually flowing. - KEDA lag-based ingestion autoscaling — the current HPA scales on CPU; a Kafka consumer-lag trigger would scale on the real signal.
- Authentication and a per-user ownership model, so conversations are scoped rather than globally visible.
- End-to-end and integration tests — currently only
typesandsdkhave unit tests; the API gateway, ingestion pipeline, and SSE path need coverage. - Idempotent ingestion — use the payload
eventIdto dedupe so a Kafka redelivery cannot create a duplicateinference_logsrow. - DLQ tooling — a consumer/UI to inspect and replay dead-lettered payloads instead of leaving them on the topic.
- Tracing — propagate a trace ID from the browser through the gateway, SDK, Kafka, and ingestion for true end-to-end request correlation.
- Multi-node infra in compose (or a staging profile) to exercise partition rebalancing and failover before EKS.