Skip to content

Analytics

The /api/v1/analytics/* time-series routes: what each returns, which are tenant-scoped, and which are cell-wide.

Updated View as Markdown

Seven routes live under /api/v1/analytics/. All are read-only, all dispatch one stored procedure and serve its JSON verbatim. The handlers add no envelope. None of them compute anything live: they read tables the metrics collector fills every METRICS_FLUSH_MS (default 60000 ms) on every replica, which is why a freshly started broker answers with empty series for about a minute.

Four are scoped to the caller’s tenant. Three are cell-wide: they aggregate across every tenant on the broker and must not be exposed to a tenant.

Route Scope Backing table
GET /api/v1/analytics/queue-lag tenant queen.queue_lag_metrics
GET /api/v1/analytics/queue-ops tenant queen.queue_lag_metrics
GET /api/v1/analytics/queue-parked-replicas tenant queen.queue_parked_replica
GET /api/v1/analytics/retention tenant queen.retention_history
GET /api/v1/analytics/system-metrics cell-wide queen.system_metrics
GET /api/v1/analytics/worker-metrics cell-wide queen.worker_metrics
GET /api/v1/analytics/postgres-stats cell-wide pg_stat_* views

The bucket rule

Every time-series route here derives its bucket width from the length of the window you ask for, so a wide range does not return one point per minute per queue:

Window Bucket
up to 60 minutes 1 minute
up to 6 hours 5 minutes
up to 24 hours 15 minutes
up to 7 days 60 minutes
longer 360 minutes

Unless stated otherwise, from defaults to now() - 1 hour and to to now(). The envelope carries bucketMinutes; never assume 1-minute points.

Gauges and counters are rolled up differently, on purpose. Counters are summed. avg_lag_ms is merged as a pop-weighted average (SUM(avg × count) / SUM(count)). The parked-consumer gauge is summed across replicas within a bucket and averaged across buckets.

GET /api/v1/analytics/queue-lag

Tenant-scoped. Filters: from, to, queue. The response is a bare JSON array. There is no envelope, so the bucket width travels with every point.

[
  {
    "queueName": "orders",
    "popCount": 41200,
    "avgLagMs": 18,
    "maxLagMs": 412,
    "bucketMinutes": 1,
    "bucketTime": "2026-07-30T10:41:00Z"
  }
]

Ordered newest bucket first, then by queue name. avgLagMs and maxLagMs are null when the bucket held no pop sample.

GET /api/v1/analytics/queue-ops

Tenant-scoped. Filters: from, to, queue. The per-queue operations series the dashboard’s queue-operations view renders.

{
  "timeRange": { "from": "2026-07-30T09:44:00.000Z", "to": "2026-07-30T10:44:00.000Z" },
  "bucketMinutes": 1,
  "series": [
    {
      "bucket": "2026-07-30T10:41:00Z",
      "queueName": "orders",
      "pushRequests": 412,
      "pushMessages": 41200,
      "popMessages": 40980,
      "popEmpty": 31,
      "ackRequests": 402,
      "ackSuccess": 40970,
      "ackFailed": 10,
      "transactions": 0,
      "partitionsCreated": 0,
      "partitionsDeleted": 0,
      "partitionCount": 8,
      "avgLagMs": 18,
      "maxLagMs": 412,
      "pushPerSecond": 686.67,
      "popPerSecond": 683.0,
      "ackPerSecond": 682.97,
      "emptyPerSecond": 0.52,
      "parkedCount": 12.5
    }
  ],
  "queues": ["orders"]
}

The *PerSecond fields are the counters divided by the bucket width, computed in SQL so a client cannot get the division wrong; parkedCount is a gauge and is deliberately not rate-normalised. avgLagMs and maxLagMs are null for a bucket with no pop sample. queues lists the queue names present in the window, so a chart can build its legend before the first datapoint arrives. Points are ordered oldest first.

GET /api/v1/analytics/queue-parked-replicas

Tenant-scoped. Filters: from, to, queue. The same parked-consumer gauge as queue-ops, broken out by replica instead of aggregated.

{
  "timeRange": { "from": "2026-07-30T09:44:00.000Z", "to": "2026-07-30T10:44:00.000Z" },
  "bucketMinutes": 1,
  "series": [
    {
      "bucket": "2026-07-30T10:41:00Z",
      "queueName": "orders",
      "hostname": "queen-0",
      "workerId": 0,
      "parkedCount": 12.5
    }
  ],
  "replicas": [ { "hostname": "queen-0", "workerId": 0 } ]
}

Buckets line up exactly with queue-ops, so the two can share a time axis. parkedCount is a within-bucket average, rounded to two decimals: a broker that parks and wakes long-polls constantly will show fractional values. workerId is always 0: one async process per replica. Rows are written only for a queue that actually had parked pops in the interval.

GET /api/v1/analytics/retention

Tenant-scoped. Filters: from, to, queue. What retention and eviction actually deleted, split by rule.

{
  "timeRange": { "from": "2026-07-30T09:44:00.000Z", "to": "2026-07-30T10:44:00.000Z" },
  "bucketMinutes": 1,
  "series": [
    {
      "bucket": "2026-07-30T10:41:00Z",
      "retentionMsgs": 40960,
      "completedRetentionMsgs": 0,
      "evictionMsgs": 0,
      "totalMsgs": 40960,
      "eventCount": 5
    }
  ],
  "totals": { "retentionMsgs": 40960, "completedRetentionMsgs": 0, "evictionMsgs": 0, "totalMsgs": 40960, "eventCount": 5 }
}

The three columns map to the three rules: retentionMsgs is age-based cleanup (retentionSeconds), completedRetentionMsgs is cleanup of segments every group has consumed past (completedRetentionSeconds), and evictionMsgs is maxWaitTimeSeconds eviction, which deletes messages regardless of whether anyone consumed them, see queues. Partition create/delete events are written to the same table with reserved type names and are filtered out here.

A history row whose partition no longer resolves to a queue (the queue was deleted after the sweep) cannot be attributed and stays in the result rather than being dropped, so totals can exceed the sum of what the queue filter would show.

queen.retention_history ages out with the other metrics tables at METRICS_RETENTION_DAYS (default 90).

GET /api/v1/analytics/system-metrics

Cell-wide. Filters: from, to, hostname, workerId. Host and process gauges per replica, re-aggregated into the window’s buckets. Every numeric leaf is a {avg, min, max, last} object.

Populated blocks: cpu.user_us and cpu.system_us (percent × 100, so divide by 100 to plot a percentage), memory.rss_bytes, database.pool_size / pool_idle / pool_active, and uptime_seconds.

GET /api/v1/analytics/worker-metrics

Cell-wide. Filters: from, to, queue, hostname, workerId. Per-replica throughput and health as a time series.

{
  "timeRange": { "from": "2026-07-30T09:44:00.000Z", "to": "2026-07-30T10:44:00.000Z" },
  "bucketMinutes": 1,
  "pointCount": 60,
  "timeSeries": [],
  "workers": [],
  "queues": [],
  "summary": {}
}

Each timeSeries point carries timestamp, the interval deltas (pushMessages, popMessages, ackMessages, pushRequests, popRequests, ackRequests, ackSuccess, ackFailed, dlqCount, dbErrors), their pushPerSecond / popPerSecond / ackPerSecond normalisations, the scheduler (“event loop”) lag pair avgEventLoopLagMs / maxEventLoopLagMs, and the pop-lag triple avgLagMs / maxLagMs / lagCount.

Four fields carry a different meaning than their names suggest, because the column set predates this broker: dbConnections is the number of active pooled connections at flush time, avgFreeSlots and minFreeSlots are both the idle pool count at that same instant, and jobsDone is written from the push-request counter, so it equals pushRequests. avgJobQueueSize, maxJobQueueSize and backoffSize have no producer at all and stay at their column default of 0. Do not chart them.

workers enumerates the replicas seen in the window, so a rolling deployment shows the old and new pods side by side. Rows are written by every replica independently, without leader election, so this is where per-replica skew that the cluster totals hide becomes visible.

GET /api/v1/analytics/postgres-stats

Cell-wide, no parameters. A direct read of PostgreSQL’s own statistics views for the current database, intended for debugging a slow broker.

Keys: timestamp, database, databaseCache (block reads, cache hits, hit ratio), tableCache and indexCache (per relation in the queen schema), cacheSummary, deadTuples, hotUpdates, activeQueries, autovacuumStatus, bufferConfig, bufferUsage and tableSizes.

Access levels

Route Level Tenant-scoped
GET /api/v1/analytics/queue-lag read-only yes
GET /api/v1/analytics/queue-ops read-only yes
GET /api/v1/analytics/queue-parked-replicas read-only yes
GET /api/v1/analytics/retention read-only yes
GET /api/v1/analytics/system-metrics read-only no
GET /api/v1/analytics/worker-metrics read-only no
GET /api/v1/analytics/postgres-stats read-only no

read-only here is the route level, not a tenancy boundary: the three cell-wide routes will happily serve one tenant’s read-only token the whole cell’s numbers. Restrict them at the proxy or the network. The aggregate status routes are on Status, health and metrics.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close