Heartbeat Service
Heartbeats are the platform's highest-volume request. They are handled by a
standalone Node.js service, @allegrocdp/heartbeat, rather than the Laravel
application. The service accepts beats from the SDK, batches them onto the same
per-tenant Redis list described in Heartbeat Queue, and
serves the queue's read and flush API. Laravel keeps reading the list for health
reporting and its own queue API.
How a beat is attributed
The SDK loader embeds a heartbeat block in the tenant config when the
SdkHeartbeat feature is active:
{
"enabled": true,
"intervalMs": 15000,
"maxIntervalMs": 300000,
"endpoint": "https://hb.example.org/v1/beat",
"token": "1.eyJ0…"
}
token is an HMAC-SHA256 claim of the tenant slug and property slug, signed by
Laravel with HEARTBEAT_SIGNING_KEYS. The service verifies it with the same
keys, so no Host header, database, or feature flag check happens on ingest.
Turning the flag off removes the block and the SDK stops beating.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /v1/beat | ingest token in body | Accept one beat. Form or JSON body with token, event_id, active_seconds. Returns 204. |
GET | /v1/heartbeats?tenant=<tenant_slug> | Authorization: Bearer <HEARTBEAT_CONSUMER_SECRET> | Same response as GET /api/v1/platform/heartbeats. Optional ?limit= from 1 to 10,000; omitted or empty defaults to 10,000. |
DELETE | /v1/heartbeats?tenant=<tenant_slug>&count=N | same bearer | Removes N (required, 1–10,000) from the front. Returns 200 {"data":{"removed":N}}. |
GET | /healthz | none | 200 with buffer counters and per-tenant queue lengths, 503 when Redis is unreachable. |
/healthz reports two different things. buffer holds this worker's in-memory
counters: buffered is beats waiting on the next Redis write, flushedTotal
and droppedTotal count up from when the process started and reset on restart.
queue is read from Redis: tenants maps each tenant slug to the length of its
list, and total sums them. It is the backlog the consumer has not drained yet,
so it rises and falls. A tenant appears once the service has written a beat for
it; the slugs are tracked in the heartbeat:tenants set under the same key
prefix.
The consumer routes use one static secret shared with the CDP rather than
Sanctum keys; ?tenant= is required because the secret names no organization.
When HEARTBEAT_CONSUMER_SECRET is unset the routes are not registered.
Organization-scoped API keys continue to work against Laravel's
/api/v1/platform/heartbeats.
Errors
Every error body is {"data":{"message","code"}}.
| Endpoint | Status | code | Cause |
|---|---|---|---|
POST /v1/beat | 401 | INVALID_HEARTBEAT_TOKEN | token is missing, malformed, or fails signature verification. |
POST /v1/beat | 422 | VALIDATION_ERROR | event_id is not a UUID, or active_seconds is not a non-negative integer. |
GET/DELETE /v1/heartbeats | 401 | UNAUTHENTICATED | The bearer token is missing or does not match HEARTBEAT_CONSUMER_SECRET. |
GET/DELETE /v1/heartbeats | 400 | TENANT_REQUIRED | ?tenant= is missing or blank. |
GET/DELETE /v1/heartbeats | 422 | VALIDATION_ERROR | limit or count is not an integer from 1 to 10,000. |
POST /v1/beat and its OPTIONS preflight always carry
Access-Control-Allow-Origin: * (and the matching
Access-Control-Allow-Methods/-Headers), including on error responses,
because the SDK calls it directly from the browser. The /v1/heartbeats
consumer routes are server-to-server and set no CORS headers.
Configuration
| Variable | Default | Description |
|---|---|---|
PORT, HOST | 8787, :: | Listen address. :: accepts both IPv6 and IPv4. |
REDIS_URL | redis://127.0.0.1:6379/0 | The heartbeat Redis. Laravel must point HEARTBEAT_REDIS_CONNECTION=heartbeats at the same instance. |
REDIS_KEY_PREFIX | allegro-database- | Must equal Laravel's REDIS_PREFIX (derived from APP_NAME when unset). |
HEARTBEAT_SIGNING_KEYS | required | Comma-separated. Must match Laravel's value. |
HEARTBEAT_CONSUMER_SECRET | unset | Static bearer for the consumer routes. When unset they are disabled. |
BUFFER_FLUSH_MS, BUFFER_MAX_BATCH, BUFFER_MAX_PENDING | 25, 1000, 100000 | Batching: flush interval, early-flush size, and the cap beyond which beats are dropped and counted. |
WEB_CONCURRENCY | CPU count | Worker processes. |
TRUST_PROXY, LOG_LEVEL | true, info | Proxy headers and Pino level. |
On the Laravel side set HEARTBEAT_INGEST_URL to the service's /v1/beat URL,
HEARTBEAT_SIGNING_KEYS to the same keys, and
HEARTBEAT_REDIS_CONNECTION=heartbeats with HEARTBEAT_REDIS_URL pointing at
the service's Redis. Leaving HEARTBEAT_INGEST_URL empty keeps heartbeats on
POST /api/heartbeat. If HEARTBEAT_SIGNING_KEYS is empty, the SDK also stays
on that legacy route even when HEARTBEAT_INGEST_URL is set, since a beat sent
to the service without a token would only get a 401.
Running locally
HEARTBEAT_SIGNING_KEYS=local-dev-heartbeat-key HEARTBEAT_CONSUMER_SECRET=local-dev-consumer-secret npm run heartbeat:dev
Then set HEARTBEAT_INGEST_URL=http://127.0.0.1:8787/v1/beat in .env and
reload a page with the SDK embedded.
redis-cli LRANGE allegro-database-tenant:<tenant_slug>:heartbeats 0 -1 shows
the beats.
Deployment
The service runs as its own Laravel Cloud application, using the Node.js
runtime, with packages/allegro-platform/packages/allegro-heartbeat as the
application's root directory. The package is not an npm workspace of the
repository root, so it has its own package-lock.json and installs on its own.
| Setting | Value |
|---|---|
| Node.js | 24 |
| Build command | npm ci && npm run build |
| Start command | npm start |
| Cache | A Valkey cache attached to the environment |
Cloud sets PORT and routes traffic to it. Leave HOST unset: Cloud's startup
probes connect over IPv6, so binding 0.0.0.0 fails them. Cloud injects
REDIS_URL for the attached cache; set HEARTBEAT_SIGNING_KEYS,
HEARTBEAT_CONSUMER_SECRET, and REDIS_KEY_PREFIX on the environment yourself.
Keep Scale-to-Zero off, since the SDK expects the ingest endpoint to answer
immediately.
WEB_CONCURRENCY caps how many workers the service forks. Keep it low. Each
worker keeps its own Redis connection and its own buffer, so raising it splits
the same beats across more buffers and costs Redis batching efficiency — one
worker already sustains tens of thousands of beats per second (see the package
README.md's Benchmark section). Scale out with replicas instead; the service
holds nothing across requests beyond the in-flight buffer, which drains on
SIGTERM.
The package also includes a Dockerfile, built with the package directory as
its context, for hosts that run containers.