This document describes how Heightproof is actually built: the runtime topology, the event
abstraction everything flows through, the worker's processing pipeline, the data model, and the
trust boundaries around authentication. It supersedes the shorter note that used to live at
docs/architecture.md. For the reasoning behind specific technology choices, see the ADRs linked
at the bottom rather than duplicated here.
Heightproof is a self-hosted API security and observability platform for Node.js applications. It watches REST, GraphQL, and EVM JSON-RPC traffic through a lightweight SDK, normalizes everything into one event shape, scores each event for threat signals, groups related signals into incidents, and surfaces all of it in a dashboard with live updates.
The core design promise is that monitoring never becomes a liability for the application it watches: the SDK captures data in-process and ships it asynchronously, the ingestion API does the minimum work needed to accept and queue a batch, and every expensive operation — threat scoring, incident correlation, error grouping, alert delivery — happens in a separate worker process against its own database connection pool. A slow query in the analytics path, or a saturated worker, can degrade Heightproof's own visibility, but it cannot make a monitored request wait longer for its own response. This is also, deliberately, more than a threat-score gadget: the same event pipeline that powers threat detection also powers request-latency and error observability, so operators get one system for "is this endpoint slow" and "is this endpoint under attack" instead of two.
flowchart LR
subgraph App["Monitored Application"]
SDK["@heightproof/sdk-node\n(Express middleware)"]
Web3SDK["@heightproof/web3\n(EVM RPC client / EIP-1193 wrapper)"]
end
subgraph API["apps/api (Fastify)"]
Ingest["POST /v1/events\nAPI key resolution + batch validation"]
Analytics["Analytics + auth routes\n/v1/analytics/*, /v1/incidents/*, ..."]
Live["WebSocket /live\n(live.ts)"]
end
Redis[("Redis Stream\nheightproof:events")]
subgraph Worker["apps/worker"]
Tick["tick(): consume + score + persist"]
AlertEval["checkAlertRules(): threshold evaluation (every 30s)"]
AlertSend["deliverAlerts(): webhook delivery + backoff"]
end
PG[("PostgreSQL\napi_events, incidents,\nerror_groups, ...")]
Dest["Alert destination\nwebhooks (alert_destinations)"]
Dash["apps/dashboard (React)"]
SDK -- "batched HTTP POST\nx-heightproof-api-key" --> Ingest
Web3SDK -- "batched HTTP POST" --> Ingest
Ingest -- "XADD" --> Redis
Redis -- "XREADGROUP\n(consumer group: heightproof-workers)" --> Tick
Tick -- "INSERT api_events\nUPSERT incidents / error_groups" --> PG
Tick -. "queues alert_deliveries\nfor high/critical incidents" .-> PG
AlertEval -- "reads api_events,\nraises incidents" --> PG
AlertSend -- "claims queued alert_deliveries\n(SELECT ... FOR UPDATE SKIP LOCKED)" --> PG
AlertSend -- "POST JSON payload" --> Dest
Analytics -- "SELECT ... WHERE project_id = $1" --> PG
Dash -- "REST polling" --> Analytics
Dash <-- "event/incident notifications" --> Live
Ingest -. "publish('events.accepted')" .-> Live
Analytics -. "publish('incident.updated')" .-> Live
Two flows share the worker as their hub: the primary event pipeline (SDK → ingestion → stream →
worker → Postgres → dashboard) and the alert fan-out that branches off it once an incident is
created (worker → alert_deliveries row → webhook POST to whatever URLs are registered in
alert_destinations).
Every signal Heightproof ingests — a REST call, a GraphQL operation, an EVM JSON-RPC call — is
normalized into one shape: HeightproofEvent, defined as a Zod schema in
packages/shared/src/index.ts. This is the single contract the whole system is built around:
the SDKs produce it, the ingestion API validates it, the worker consumes it, and every downstream
table and dashboard view is ultimately a projection of it.
The schema's shape reflects what actually gets used downstream:
- Identity and correlation:
id,traceId(32-hex OTel-style trace id),parentSpanId,projectId,serviceName,environment,timestamp. kind: one ofrest,graphql,evm_rpc,websocket,webhook(TrafficKindSchema) — the discriminator that downstream code branches on.request: method, path, an optional normalizedroute,ip,userAgent,headers,query,body, and anauthobject (present/scheme/failed).response:statusCode,latencyMs, optionalbodyBytes.graphQL: optionaloperationName/operationType, populated only whenkind === "graphql".evmRpc: optionalmethod,chainId,provider,walletAddress,contractAddress, populated only whenkind === "evm_rpc".error: optionaltype/message/stack, populated when the SDK captured an exception viaheightproofErrorHandler().
Batches are validated as EventBatchSchema, an array of 1–100 HeightproofEvents
(z.array(HeightproofEventSchema).min(1).max(100)).
The payoff of this abstraction is extensibility (expanded in §12): classifyTraffic() in
packages/shared/src/index.ts is the only place that decides what "kind" of traffic something is
(GraphQL is detected by path, EVM RPC by a jsonrpc + method body shape), and everything after
that point — scoring, persistence, incident grouping, the dashboard — operates on the same
HeightproofEvent regardless of transport. A new SDK for a new runtime only has to produce a
conformant HeightproofEvent; it doesn't need to know anything about Redis, Postgres, or how
incidents get raised.
POST /v1/events in apps/api/src/server.ts does exactly four things, in order, and nothing more:
- Resolve the API key.
resolveProjectForApiKey()(apps/api/src/db.ts) checks thex-heightproof-api-keyheader against two possible identities: the shared fallback key (HEIGHTPROOF_API_KEY, defaulting todev-heightproof-key) for local development, or a real, project-scoped key looked up by its SHA-256 hash in theapi_keystable (key_hash = sha256(apiKey),revoked_at is null). A hit updateslast_used_at. A miss on both returns401 invalid_api_key. - Validate the batch. The body is parsed against
EventBatchSchema. A schema failure returns400 invalid_event_batchwith the Zod error detail and incrementsfailedIngestionBatches. - Enforce project scope. Every event's
projectIdmust match the project the resolved key is scoped to; any mismatch rejects the whole batch with403 project_scope_mismatch. This is what stops a key issued to one project from writing into another project's data. - Enqueue and return
202. Valid events are pushed onto the Redis Stream viaenqueueEvents()(apps/api/src/queue.ts, oneXADDper event inside a pipeline), aliveHub.publish("events.accepted", ...)notification goes out over the WebSocket, and the route returns202 Acceptedwith a count. No database write happens in this request path.
This split — synchronous accept, asynchronous process — is the whole point of
ADR-003: ingestion authenticates, validates,
scopes, and queues, and returns before anything touches Postgres or runs threat-scoring logic. A
slow worker or a slow database can build up queue depth (visible at /ready and /metrics), but
it cannot add latency to the monitored application's own request/response cycle.
apps/worker/src/worker.ts runs one loop until it receives SIGTERM/SIGINT (see the bottom of the
file): heartbeat(), reclaim(), tick(), deliverAlerts(), checkAlertRules() (rate-limited
to once per 30 seconds via ALERT_RULE_EVAL_INTERVAL_MS). Each is a full pass, not a
fire-and-forget task — the worker is a simple single-process consumer with no internal concurrency
beyond what Postgres and Redis give it for free. If any step throws (Postgres unreachable, a query
failing) the iteration is logged and retried after an exponential backoff of 1s doubling to 30s;
the process does not exit. A signal stops the loop after the current batch is acked, so a deploy or
docker stop does not strand in-flight messages.
tick() reads up to 25 messages via XREADGROUP against the heightproof-workers consumer group
(BLOCK 5000 so it doesn't spin when idle), and for each message:
- Parses the payload with
parseStreamMessage()(apps/worker/src/stream.ts), which never throws. A message with noeventfield, invalid JSON, or a schema mismatch can never succeed, so it is copied to the dead-letter streamheightproof:events:dlq(with areasonand the original id) and acked in oneMULTI. - On success, wraps the rest of the work in an OpenTelemetry span
(
heightproof.worker.process_event) and:- Fetches context needed for scoring in parallel:
countRecentIpRequests()(requests from this IP in the last minute, for the generic rate-anomaly signal) andgetWeb3ThreatContext()(only populated whenkind === "evm_rpc"; see below). - Calls
assessThreat()(packages/shared/src/index.ts) to score the event. - Calls
recordEvent(), which in one transaction inserts the event row (INSERT ... ON CONFLICT (id) DO NOTHINGintoapi_events), upserts its incident (see below) and its error group. If the event row already existed, the event is a duplicate and nothing else is written.
- Fetches context needed for scoring in parallel:
- Only after that whole span completes without throwing does the worker
XACKthe message. If it throws, the message stays in the pending entries list andreclaim()retries it (§11).
Threat scoring (assessThreat, packages/shared/src/index.ts). This is a pure, additive
heuristic — not a model. Each applicable rule adds a weighted signal ({name, weight, reason});
the final score is the sum of weights, capped at 100. Generic signals: server_error (+18, 5xx
response), auth_failure (+24, 401/403 or request.auth.failed), high_latency (+12, over
1500ms), rate_anomaly (+30, more than 120 requests/minute from the same IP),
large_graphql_payload (+14, GraphQL body over 10,000 characters). EVM-specific signals only fire
when kind === "evm_rpc": sensitive_rpc (+26, method name matches
/send|sign|private|unlock/i), rpc_flooding (+28, over 200 calls/minute to the same method from
one IP), tx_burst (+30, a wallet's transaction-submission volume in the last hour is both ≥10 and
more than 10x its trailing 24-hour hourly baseline), provider_degradation (+20, an RPC provider's
5-minute p95 latency is over 500ms and more than 3x its 24-hour baseline), provider_failures
(+22, over 20% failure rate for a provider, with a minimum sample size of 5 requests to avoid
false positives on low volume). Severity buckets: critical ≥75, high ≥50, medium ≥25,
otherwise low.
Incident fingerprinting (apps/worker/src/incidents.ts). An event only becomes (or updates) an
incident if its score is ≥25. fingerprintIncident() checks signal names in a fixed priority order
— auth_failure → tx_burst → rpc_flooding → provider_degradation → provider_failures →
rate_anomaly → sensitive_rpc → a generic heuristic_risk fallback — and produces a stable
incident_key such as demo:credential_stuffing:POST /login or demo:tx_burst:0xabc...123. The
web3-specific checks are ordered ahead of the generic rate_anomaly check deliberately: a flood of
calls to one RPC method from one IP always also trips the generic per-IP rate signal, and the more
specific category produces a more useful incident title. createIncidentIfNeeded()
(apps/worker/src/db.ts) then runs a transaction that does a SELECT ... FOR UPDATE on any open
or acknowledged incident sharing that key: if one exists, it bumps request_count, merges
attacker_ips, and widens the started_at/last_seen_at window; if not, it inserts a new
incidents row. A brand-new high or critical incident additionally inserts one queued
alert_deliveries row per enabled alert_destinations row for the project, inside the same
transaction — the alert queue and the incident it belongs to are always created atomically.
Error grouping (apps/worker/src/errors.ts). fingerprintError() builds a key from
projectId, error.type, the endpoint (METHOD route-or-path), and a normalized error message
(normalizeErrorMessage() replaces UUIDs and bare integers with placeholders, so "user 42 not found" and "user 99 not found" collapse into the same group). recordErrorGroup() upserts
error_groups, incrementing occurrences and merging affected_ips.
Alert-rule evaluation (apps/worker/src/alertRules.ts), every 30 seconds.
evaluateAlertRules() loads all enabled alert_rules and, per rule, runs a metric query scoped
to that rule's window_minutes: error_rate_percent, p95_latency_ms, max_threat_score,
request_count, or auth_failure_count. If the computed value exceeds threshold,
raiseAlertRuleIncident() upserts a synthetic incident keyed by
{project_id}:alert_rule:{metric} (severity always high) and queues alert deliveries the same
way event-triggered incidents do. This is a separate code path from per-event incident creation —
it catches sustained conditions (like a slowly rising error rate) that no single event would
trigger on its own.
Alert delivery (apps/worker/src/alerts.ts). deliverPendingAlerts() claims up to 10
due deliveries with claimAlertDeliveries() — an UPDATE ... WHERE id IN (SELECT ... WHERE status = 'queued' AND next_attempt_at <= now() ... FOR UPDATE SKIP LOCKED) — which is what makes it safe
to run more than one worker process against the same delivery queue without double-sending a
webhook. Each claimed delivery gets a POST with a 5-second timeout
(sendAlertWebhook()); success marks it delivered, failure calls markAlertDeliveryFailed(),
which either schedules a retry with exponential backoff (min(30 * 2^attempts, 3600) seconds) or,
after 5 attempts, marks it failed permanently.
flowchart TD
A["Event popped off Redis stream"] --> B{"Schema valid?"}
B -- "no" --> Z["XADD to heightproof:events:dlq\n+ XACK (one MULTI)"]
B -- "yes" --> C["Gather scoring context:\nrecent IP request count,\nweb3 baseline stats (if evm_rpc)"]
C --> D["assessThreat()\nadditive weighted signals"]
D --> E["recordEvent(): one transaction\nINSERT api_events (skip rest if duplicate)"]
E --> F{"score >= 25?"}
F -- "no" --> G["recordErrorGroup()\nif event.error present"]
F -- "yes" --> H["fingerprintIncident()\npriority-ordered signal match"]
H --> I["upsert incidents\n(txn, FOR UPDATE on existing key)"]
I --> J{"new incident AND\nseverity high/critical?"}
J -- "yes" --> K["queue alert_deliveries\nfor each enabled destination"]
J -- "no" --> G
K --> G
G --> L["XACK message"]
All persistent state lives in PostgreSQL, defined in infra/migrations/001_initial.sql.
erDiagram
ORGANIZATIONS ||--o{ PROJECTS : "owns"
ORGANIZATIONS ||--o{ USERS : "employs"
PROJECTS ||--o{ PROJECT_MEMBERSHIPS : "has members"
USERS ||--o{ PROJECT_MEMBERSHIPS : "belongs to"
USERS ||--o{ SESSIONS : "authenticates via"
PROJECTS ||--o{ API_KEYS : "issues"
PROJECTS ||--o{ API_EVENTS : "receives"
PROJECTS ||--o{ INCIDENTS : "raises"
PROJECTS ||--o{ INCIDENT_TIMELINE : "logs"
PROJECTS ||--o{ ALERT_DESTINATIONS : "configures"
PROJECTS ||--o{ ALERT_RULES : "configures"
PROJECTS ||--o{ ALERT_DELIVERIES : "tracks"
PROJECTS ||--o{ ERROR_GROUPS : "groups"
API_EVENTS ||--o| INCIDENTS : "may trigger (event_id, nullable)"
INCIDENTS ||--o{ INCIDENT_TIMELINE : "records status changes"
INCIDENTS ||--o{ ALERT_DELIVERIES : "fans out to"
ALERT_DESTINATIONS ||--o{ ALERT_DELIVERIES : "receives"
ORGANIZATIONS {
uuid id PK
text name
timestamptz created_at
}
USERS {
uuid id PK
uuid organization_id FK
text email UK
text password_hash
timestamptz created_at
}
PROJECTS {
text id PK
uuid organization_id FK
text name
timestamptz created_at
}
PROJECT_MEMBERSHIPS {
uuid id PK
text project_id FK
uuid user_id FK
text role "owner|admin|developer|viewer"
}
SESSIONS {
uuid id PK
uuid user_id FK
text token_hash UK
timestamptz expires_at
}
API_KEYS {
uuid id PK
text project_id FK
text name
text key_hash UK
text prefix
timestamptz last_used_at
timestamptz revoked_at
}
API_EVENTS {
text id PK
text trace_id
text project_id
text kind
text method
text path
text route
text ip
int status_code
int latency_ms
int threat_score
text threat_severity
jsonb threat_signals
text evm_rpc_method
text wallet_address
text error_type
}
INCIDENTS {
uuid id PK
text event_id FK "nullable, unique"
text incident_key UK
text project_id
text severity
text status "open|acknowledged|resolved|ignored"
int request_count
jsonb attacker_ips
timestamptz started_at
timestamptz last_seen_at
}
INCIDENT_TIMELINE {
uuid id PK
uuid incident_id FK
text project_id FK
text action
text actor
timestamptz created_at
}
ALERT_DESTINATIONS {
uuid id PK
text project_id FK
text name
text url
boolean enabled
}
ALERT_RULES {
uuid id PK
text project_id FK
text metric
numeric threshold
int window_minutes
boolean enabled
}
ALERT_DELIVERIES {
uuid id PK
uuid incident_id FK
uuid destination_id FK
text project_id FK
text status "queued|sending|delivered|failed"
int attempts
timestamptz next_attempt_at
}
ERROR_GROUPS {
uuid id PK
text project_id FK
text fingerprint
text error_type
text message
int occurrences
jsonb affected_ips
}
A few details worth calling out beyond the ER shape: incidents.event_id is nullable and unique —
event-triggered incidents point at the api_events row that created them, but alert-rule-triggered
incidents (§5) have no single originating event, so event_id is null for those; the real
dedupe/merge key for all incidents is incident_key, which has its own unique index. api_events
itself has no foreign key to projects (it's a text project_id column, matched by convention,
not enforced by a constraint) — a pragmatic choice for a high-volume, insert-heavy table where
adding an FK would mean an index lookup against projects on every ingested event.
Heightproof has two authentication mechanisms that coexist, and understanding where the line between them falls is important for reasoning about who can do what.
Project-scoped API keys (apps/api/src/db.ts, resolveProjectForApiKey /
hashApiKey/createApiKey) authenticate SDK ingestion traffic and, when passed on read routes,
also scope analytics reads to a project. A real key looks like heightproof_<24 random bytes, base64url>; only its SHA-256 hash is stored (key_hash), alongside a non-secret prefix used for
display in the dashboard's key list. There's also a special case: the value of HEIGHTPROOF_API_KEY
(the "shared fallback key," dev-heightproof-key by default) is accepted directly without a database
lookup and defaults callers to the demo project (or ?projectId= if supplied) — this is what
lets docker compose up work out of the box without provisioning a real key first.
Dashboard sessions (apps/api/src/auth.ts, apps/api/src/db.ts) authenticate human operators.
Passwords are hashed with scrypt (hashPassword/verifyPassword, 16-byte random salt, 64-byte
derived key, constant-time comparison). On login, a session token
(heightproof_session_<32 random bytes, base64url>) is generated, SHA-256-hashed, and stored in
sessions.token_hash with a 7-day expiry (SESSION_TTL_MS); the raw token is only ever handed to
the client once, in the login response, as a bearer token. getBearerToken() pulls it from the
Authorization: Bearer ... header on subsequent requests. Roles — owner > admin > developer
viewer, ranked inROLE_RANK— are per-project, stored inproject_memberships, and checked withroleMeetsMinimum().
The nuance: role enforcement is implemented in enforceProjectRole() in
apps/api/src/server.ts, and it only runs if (getBearerToken(request)) — that is, only when a
session token is actually present on the request. If it's absent, enforceProjectRole returns
true immediately and the mutation proceeds. This is a deliberate design decision (the comment
directly above the function in server.ts spells it out): API-key-only callers — SDKs posting
events, automation scripts, or the shared dev fallback key hitting a mutating analytics route like
POST /v1/api-keys — are a coarser-grained trust tier than a human with a dashboard role, and are
simply not subject to the owner/admin/developer/viewer hierarchy at all. In other words, "does this
caller have a valid project-scoped API key" and "does this caller's session have the admin role
on this project" are two independent gates; a request needs to clear whichever one applies to it
(API key always, for scope; session role only if a session token was actually presented), not both
in every case. Every route documented as "requires admin role when called with a session token"
in the README is stating exactly this: the check is real, but it is conditional on the auth
mechanism in play.
The tenancy hierarchy is organizations → projects → project memberships. organizations is the
top-level tenant boundary; projects.organization_id ties every project to one organization; and
project_memberships (unique on (project_id, user_id)) is the join table that grants a specific
user a specific role on a specific project — a user can belong to multiple projects with different
roles in each.
Every ingestion, analytics, and mutation path is scoped by project_id: ingestion rejects any
event whose projectId doesn't match the resolved key's project (§4); every analytics query in
apps/api/src/db.ts (getOverview, getIncidents, getRequests, listApiKeys,
listAlertDestinations, listErrorGroups, etc.) takes a projectId parameter and filters with
where project_id = $1; and every worker write (persistEvent, createIncidentIfNeeded,
recordErrorGroup) carries the event's own projectId through to the insert. There is a system
default: getAnalyticsProjectScope() falls back to the demo project when no API key header is
present at all, which is what backs the dashboard's offline/demo-data experience.
apps/api/src/live.ts attaches a WebSocketServer at /live directly onto the Fastify HTTP
server. It has no channels or subscriptions in the protocol sense — every connected client receives
every published message ({channel, payload} as JSON), and the dashboard filters by channel
client-side. Two events are published today: events.accepted from the ingestion route (a live
counter of accepted batches) and incident.updated from PATCH /v1/incidents/:id/status (so an
operator changing an incident's status in one browser tab is reflected live in another). Publishing
is itself wrapped in an OpenTelemetry span (heightproof.websocket.publish) with the channel name and
current client count as attributes. Note that the worker does not publish to this hub directly —
newly created incidents and freshly scored events reach the dashboard by polling the analytics
routes, not over the WebSocket; live-tail today is a thin layer on top of the ingestion and
incident-status paths specifically, not a general event fan-out from the worker (WebSocket event fanout from worker results is explicitly still on the roadmap — see docs/roadmap.md).
docker-compose.yml defines five things:
postgres(postgres:16-alpine) — the migrations ininfra/migrations/are mounted into/docker-entrypoint-initdb.d, so they run automatically on first boot of the volume.redis(redis:7-alpine) — the event stream and consumer-group state.api— built frominfra/node.DockerfilewithWORKSPACE=apps/api, listening on8080.worker— built from the sameinfra/node.DockerfilewithWORKSPACE=apps/worker; no exposed port, since it only talks to Postgres and Redis.dashboard— built frominfra/dashboard.Dockerfile, a Vite dev server on5173.
api and worker both wait on Postgres and Redis health checks (service_healthy) before
starting. Both use the same multi-stage node.Dockerfile: a build stage installs dependencies
and compiles packages/shared plus whichever workspace is being built, and the final stage copies
only the built output and node_modules, sets NODE_ENV=production, and — notably — runs as the
non-root node user (USER node, with --chown=node:node on every COPY --from=build) rather
than root. infra/dashboard.Dockerfile does the same (chown -R node:node /app then USER node).
Nothing in the compose file runs as root inside its container.
Redis stream consumer group. The worker joins the heightproof-workers consumer group on stream
heightproof:events (XGROUP CREATE ... $ MKSTREAM, tolerating the BUSYGROUP error if the group
already exists) under a consumer name of worker-${process.pid} by default
(apps/worker/src/config.ts). Redis Streams' consumer-group semantics mean that if you run more
than one worker process against the same group, each stream message is delivered to exactly one
consumer, so horizontal scaling of workers is a matter of starting more processes — no additional
partitioning logic needed. Messages left pending by a consumer that died mid-processing, or by a
failed processing attempt, are recovered by claimAbandoned() (apps/worker/src/stream.ts) at the
start of every loop: it lists pending entries idle for at least HEIGHTPROOF_RECLAIM_MIN_IDLE_MS
(default 60s) with XPENDING ... IDLE, takes them over with XCLAIM (which re-checks idle time,
so two workers cannot both win a message), and processes them. An entry already delivered
HEIGHTPROOF_MAX_DELIVERIES times (default 20, roughly 20 minutes of continuous failure) is
dead-lettered with reason max_deliveries_exceeded instead of being retried forever.
At-least-once processing, not exactly-once. Within tick(), XACK is only called after the
per-message withSpan("heightproof.worker.process_event", ...) block completes without throwing —
it's the last line of the loop body, after recordEvent(). If that throws, the exception
propagates out of withSpan (which records it and re-raises), the XACK call is skipped, and the
message is reclaimed and retried as above.
Delivery is at-least-once; the effects are exactly-once per event id. recordEvent() writes the
event row, incident upsert and error-group upsert in one transaction, and treats an existing
api_events.id as "already applied". So a message redelivered after a crash between commit and
XACK, or an event the SDK re-sent after a timed-out batch, changes nothing (aggregates such as
request_count and occurrences are not double-counted, and a resolved incident is not reopened
by an old event); and a failure part way through rolls back all three writes, so the retry never
finds an event stored without its incident. Alert deliveries are queued inside the same
transaction, so a duplicate cannot send a second webhook either. Note the idempotency key is the
SDK-generated event id: two genuinely separate events with the same id would be merged.
Alert delivery retries. claimAlertDeliveries()'s FOR UPDATE SKIP LOCKED claim (§5) is a
separate reliability mechanism from the Redis consumer group — it's how multiple worker processes
could safely share the alert-delivery backlog without both sending the same webhook. Failed
deliveries retry with exponential backoff (30 * 2^attempts seconds, capped at 3600) up to 5
attempts, then are marked failed permanently with the last error message recorded.
Postgres connection loss. Both services attach a pool.on("error") listener in createPool().
Without it, an idle client killed by a Postgres restart or failover is an unhandled 'error' event
and exits the process. Compose also sets restart: unless-stopped on api, worker and
dashboard as a second line of defence.
Operational visibility. Two health endpoints answer different questions, and both return
503 when their check fails (apps/api/src/observability.ts):
GET /ready— can this API process serve? Fails when Postgres (needed to resolve API keys) or Redis (needed to enqueue) is unreachable. This is what a load balancer or Kubernetes readiness probe should use. It deliberately ignores the worker: a backlog that pulled API pods out of rotation would turn a slow worker into an ingestion outage.GET /health/pipeline— are events being processed? Fails when the oldest unacked or unread message is older thanHEIGHTPROOF_MAX_QUEUE_AGE_MS(default 60s; age rather than depth, since a burst that drains is healthy and a stuck message is not), or when no worker has refreshed theheightproof:events:heartbeatkey within its TTL (default 30s). Point alerting here.
Both return the same body: status (API readiness), pipeline (healthy/stalled) and every
check. GET /metrics exports heightproof_queue_depth and heightproof_queue_oldest_age_seconds.
Note that API keys other than the dev fallback key are resolved against Postgres, so project-key
ingestion returns 500 while the database is down.
Because everything downstream of ingestion — scoring, persistence, incident correlation, error
grouping, the dashboard's queries — operates purely on HeightproofEvent, adding support for a new
source of traffic is a matter of producing conformant events, not modifying the pipeline. A
hypothetical Python SDK, an Nginx log-tailing agent, or a Lambda extension (all named in
docs/roadmap.md's "Later" section) would need to: shape its captured request/response data into
HeightproofEvent, POST it in batches of up to 100 to /v1/events with a valid project API key, and
nothing else — it doesn't need to know about Redis Streams, the worker's threat-scoring rules, or
how the dashboard renders incidents. The kind field and its optional graphQL/evmRpc sub-shapes
are the only places a genuinely new traffic type would need a schema addition (as EVM RPC support
already demonstrates: packages/web3 is a ~180-line package that reuses HeightproofClient from
packages/sdk-node wholesale and only adds its own event-shaping logic).