Heartbeat Queue
The heartbeat queue is a Redis List-based pipeline for delivering page engagement signals from Allegro to external consumers. When enabled, the SDK tracks how long each page is actively open and periodically POSTs that running total to the platform, which pushes it onto a per-tenant Redis List. External consumers poll the queue to read heartbeats, then issue a separate delete call to remove them.
It mirrors the Event Queue — same per-tenant FIFO List, same read-then-delete consumer contract — but carries engagement heartbeats rather than tracked events.
How It Works
Feature Flag
The queue is opt-in per tenant via the SdkHeartbeat Pennant feature flag. The
same flag also surfaces the heartbeat configuration to the SDK, so enabling it
both starts the client loop and opens the queue.
Active Time Tracking
When the SDK loads, it fires a page_view event and starts measuring
cumulative engaged time: the time the page is visible and the window has
focus. Time spent backgrounded, or in a visible window that does not have focus,
is not counted.
The SDK sends the running total (whole seconds) to /api/heartbeat on a
checkpoint schedule rather than a fixed timer:
- The first checkpoint is due 15 seconds after engagement starts.
- After a checkpoint with no interaction (pointer, key, scroll, or touch) the delay doubles: 30 s, 60 s, 120 s, up to a 5 minute cap. Any interaction returns the delay to 15 s and, if a beat is more than 15 s away, pulls it forward.
- A checkpoint is only sent when the total grew since the last beat, so an idle page produces no traffic.
- After 30 minutes with no interaction the SDK sends one final beat and stops. The next interaction restarts the clock and the schedule.
Each beat carries the page_view event_id and the running active_seconds
total, so the latest beat for an event id supersedes earlier ones.
- Beats are sent with
navigator.sendBeacon(falling back tofetchwithkeepalive) so they survive the page being backgrounded or unloaded. - When the page is hidden or unloaded, or the window loses focus, the SDK flushes one beat with the accumulated time and pauses. When the page becomes visible and focused again, accrual resumes from where it left off.
- On SPA navigation the SDK flushes the outgoing page, then fires a new
page_viewand resets the counter for the new page. - Every beat is also stored in the browser. If a final beat never reaches the
platform (roughly one exit in ten across browsers), the next page load on the
same site re-sends the stored total. Consumers keep the largest
active_secondsperevent_id, so a replayed total that already arrived, or one smaller than a total already received, is harmless. - Beats that round to 0 active seconds are not sent, so every queued
heartbeat has
active_seconds≥ 1.
As of September 2026, engaged time excludes visible-but-unfocused windows. Totals recorded before that date counted any visible page and run higher for the same behavior.
Heartbeat Ingestion
Each accepted heartbeat is pushed onto the tenant's heartbeat queue with a server-stamped receipt timestamp. Each tenant has its own isolated queue.
If Redis is unreachable when a heartbeat arrives, Allegro logs a warning and drops that beat rather than failing the request. A transient Redis outage never surfaces as an error on the client-facing beacon endpoint. Heartbeats are engagement telemetry, so an occasional dropped beat during an outage is an acceptable trade-off for keeping ingestion responsive.
Reading Heartbeats (get)
Consumers call GET /api/v1/platform/heartbeats to read up to 10,000 heartbeats
from the front of the queue. This is a non-destructive read — heartbeats
remain in the queue until you explicitly delete them.
Deleting Heartbeats (delete)
After processing the heartbeats, call
DELETE /api/v1/platform/heartbeats?count=N where N is the count value
returned by the preceding GET. Passing the exact count prevents a race
condition: any heartbeats that arrived between the GET and the DELETE are
left untouched and will be returned on the next GET.
API Endpoints
All endpoints are under the /api/v1/platform prefix and require Sanctum
authentication (auth:sanctum).
GET /api/v1/platform/heartbeats
Read up to 10,000 heartbeats from the front of the queue. Non-destructive —
heartbeats are not removed until DELETE is called.
If the SdkHeartbeat feature flag is not active for the current tenant, returns
an empty result.
Response 200 OK:
{
"data": {
"count": 2,
"heartbeats": [
{
"event_id": "9b5c1f2e-9c3a-4f1b-8a2d-1e2f3a4b5c6d",
"active_seconds": 5,
"received_at": "2026-02-25T18:00:05+00:00",
"property_slug": "magazine"
},
{
"event_id": "9b5c1f2e-9c3a-4f1b-8a2d-1e2f3a4b5c6d",
"active_seconds": 10,
"received_at": "2026-02-25T18:00:10+00:00",
"property_slug": "magazine"
}
]
}
}
Empty queue response:
{
"data": {
"count": 0,
"heartbeats": []
}
}
Heartbeat Payload
Each entry in the queue is the exact object stored at ingestion time — there is no enrichment or lookup.
| Field | Type | Description |
|---|---|---|
event_id | string | UUID of the page_view event this heartbeat belongs to. Repeated across beats; the latest wins. |
active_seconds | integer | Cumulative active time on the page in whole seconds. Excludes time the page was backgrounded. |
received_at | string | ISO 8601 timestamp stamped server-side when the beat was received. |
property_slug | string | null | Slug of the property the heartbeat was recorded on. null when the beat came from a plain organization host with no property context. |
Multiple beats share the same event_id as active time grows. To compute final
engagement per page view, keep the largest active_seconds for each event_id. Do not use the
latest received_at as a proxy: a replayed beat can arrive minutes or days
after the page view, and it may carry a total smaller than one already
received, so the maximum is the only correct reducer.
DELETE /api/v1/platform/heartbeats
Remove exactly count heartbeats from the front of the queue (oldest first).
The count parameter is required and must match the count returned by the
preceding GET call — this prevents accidentally deleting heartbeats that
arrived after the read.
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
count | integer ≥ 1 | Yes | Number of heartbeats to remove. Use the count value from the GET response. |
Response 204 No Content — heartbeats removed.
Response 422 Unprocessable Content — count is missing or invalid.
Recommended Consumer Pattern
loop:
GET /api/v1/platform/heartbeats
if count == 0 → sleep, continue
process each heartbeat (keep the largest active_seconds per event_id)
DELETE /api/v1/platform/heartbeats?count={count}
Read all heartbeats first, process them, then delete — passing the exact count
from the GET response. Because GET is non-destructive, heartbeats are safe
to re-read if your consumer needs to retry before issuing the DELETE. Using
the explicit count ensures heartbeats that arrived between your GET and
DELETE are never accidentally removed.
Configuration
| Config key | Default | Description |
|---|---|---|
eventqueue.redis_connection | default | Laravel Redis connection to use for the heartbeat queue. |