Skip to main content

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 to fetch with keepalive) 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_view and 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_seconds per event_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.
Engaged time now requires focus

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.

Best-effort ingestion

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.

FieldTypeDescription
event_idstringUUID of the page_view event this heartbeat belongs to. Repeated across beats; the latest wins.
active_secondsintegerCumulative active time on the page in whole seconds. Excludes time the page was backgrounded.
received_atstringISO 8601 timestamp stamped server-side when the beat was received.
property_slugstring | nullSlug of the property the heartbeat was recorded on. null when the beat came from a plain organization host with no property context.
Largest total wins

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:

ParameterTypeRequiredDescription
countinteger ≥ 1YesNumber of heartbeats to remove. Use the count value from the GET response.

Response 204 No Content — heartbeats removed.

Response 422 Unprocessable Contentcount is missing or invalid.


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 keyDefaultDescription
eventqueue.redis_connectiondefaultLaravel Redis connection to use for the heartbeat queue.