This is a preview of unreleased docs from main. View the published docs
Skip to main content

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​

MethodPathAuthPurpose
POST/v1/beatingest token in bodyAccept 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=Nsame bearerRemoves N (required, 1–10,000) from the front. Returns 200 {"data":{"removed":N}}.
GET/healthznone200 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"}}.

EndpointStatuscodeCause
POST /v1/beat401INVALID_HEARTBEAT_TOKENtoken is missing, malformed, or fails signature verification.
POST /v1/beat422VALIDATION_ERRORevent_id is not a UUID, or active_seconds is not a non-negative integer.
GET/DELETE /v1/heartbeats401UNAUTHENTICATEDThe bearer token is missing or does not match HEARTBEAT_CONSUMER_SECRET.
GET/DELETE /v1/heartbeats400TENANT_REQUIRED?tenant= is missing or blank.
GET/DELETE /v1/heartbeats422VALIDATION_ERRORlimit 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​

VariableDefaultDescription
PORT, HOST8787, ::Listen address. :: accepts both IPv6 and IPv4.
REDIS_URLredis://127.0.0.1:6379/0The heartbeat Redis. Laravel must point HEARTBEAT_REDIS_CONNECTION=heartbeats at the same instance.
REDIS_KEY_PREFIXallegro-database-Must equal Laravel's REDIS_PREFIX (derived from APP_NAME when unset).
HEARTBEAT_SIGNING_KEYSrequiredComma-separated. Must match Laravel's value.
HEARTBEAT_CONSUMER_SECRETunsetStatic bearer for the consumer routes. When unset they are disabled.
BUFFER_FLUSH_MS, BUFFER_MAX_BATCH, BUFFER_MAX_PENDING25, 1000, 100000Batching: flush interval, early-flush size, and the cap beyond which beats are dropped and counted.
WEB_CONCURRENCYCPU countWorker processes.
TRUST_PROXY, LOG_LEVELtrue, infoProxy 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.

SettingValue
Node.js24
Build commandnpm ci && npm run build
Start commandnpm start
CacheA 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.