Repository navigation
UN-4251 [FEAT] Prometheus /metrics endpoint on the backend for queue, execution and batch metrics - #2378
UN-4251 [FEAT] Prometheus /metrics endpoint on the backend for queue, execution and batch metrics#2378muhammad-ali-e wants to merge 3 commits into
Conversation
Adds a platform_metrics app serving GET /metrics in Prometheus text format: queue depth by state and oldest-message age (pg_queue_message), active and recently-terminal execution counts (workflow_execution), in-progress and stuck batches (pg_barrier_state), plus last-refresh and refresh-failure metrics per group. Known queues and statuses are zero-filled. Each group's snapshot is cached in Redis for 15s behind a non-blocking lock, so one backend pod refreshes a group per interval however many are scraped. A failing group serves its last values and counts the failure without affecting the others; with Redis down each pod refreshes at most once per TTL. Mounted at the URL root next to /health, outside every public ingress prefix, with an optional bearer token (PLATFORM_METRICS_TOKEN). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
[Medium impact] The PR appears safe to merge, with a non-blocking issue in how Redis retries count refresh failures.
|
…fallbacks - Recent terminal executions are now filtered on created_at + execution_time (stamped on the terminal transition) instead of modified_at, which later writes such as result acknowledgement bump. - A Redis failure on the re-read under the lock now serves the cached snapshot through the failure path instead of dropping the group. - A failed Redis write is counted as a refresh failure, and the newer local copy is preferred over the older Redis value, so the pod does not refresh the DB on every scrape while writes fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
|
|
…cs cache - Count refresh failures in Redis (HINCRBY + HGETALL in one transaction), so every pod reports the same monotonic total; omit it while Redis is down - Add unstract_metrics_cache_up to make a Redis outage visible - Fall back to modified_at as the finish time for swept executions - Size the refresh lock from each group's statement count and query timeout - Treat an unreadable cache entry as missing; per-group local locks - Note in HELP text that queue metrics cover the PG transport only Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
|
| raw = pipe.execute()[-1] | ||
| except Exception: | ||
| self._restore(pending) |
There was a problem hiding this comment.
Refresh failures counted twice
If Redis applies the transaction but the connection drops before its reply arrives, pipe.execute() raises and _restore(pending) queues the same increments again. The next successful scrape counts those failures twice, inflating unstract_metrics_refresh_failures_total and potentially triggering unnecessary alerts.
Make retries safe to repeat, for example by storing each process's cumulative count and adding only its unseen increase.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/platform_metrics/cache.py
Line: 309-311
Comment:
**Refresh failures counted twice**
If Redis applies the transaction but the connection drops before its reply arrives, `pipe.execute()` raises and `_restore(pending)` queues the same increments again. The next successful scrape counts those failures twice, inflating `unstract_metrics_refresh_failures_total` and potentially triggering unnecessary alerts.
Make retries safe to repeat, for example by storing each process's cumulative count and adding only its unseen increase.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Acknowledged, but I'm not changing this. Full reasoning is in #2378 (comment).
In short: the double count needs EXEC to succeed and its reply to be lost. When that happens, the counter over-reports by at most one scrape's increments for one process, and the error only ever goes up. Tracking a per-process cumulative count would leave an orphaned hash field behind every time a gunicorn worker is recycled. For a diagnostic counter that alerts on rate() > 0 / cache_up == 0, that bookkeeping costs more than the rare over-count it prevents.
Generated by Claude Code
|
Re Greptile P2 "Refresh failures counted twice" ( You're right that it can happen. Redis has to apply the MULTI/EXEC and then lose the reply on the way back, and in that case those increments are counted again on the next scrape. That window is narrow: it needs a connection drop between EXEC and its reply. The effect is also bounded and only goes up. The failure count over-reports by at most one scrape's worth of increments for this process. The suggested fix is to store a cumulative count per process and add only the unseen delta. That needs a stable key for each process, and gunicorn recycles workers, so every recycle would leave behind a hash field that is never cleaned up. Summing those fields would also need the cleanup logic we'd have to build alongside it. For a diagnostic counter, where alerts should key on Generated by Claude Code |
Unstract test resultsPer-group results
Critical paths
|



Requested by ali · project thread
What
platform_metricsbackend app servingGET /metricsin Prometheus text format (text/plain; version=0.0.4).unstract_-prefixed, no per-org labels:unstract_queue_messages{queue,state}withstate=ready|claimed|scheduled, andunstract_queue_oldest_message_age_seconds{queue}(frompg_queue_message)unstract_workflow_executions{status}forpending/executing, andunstract_workflow_executions_recent{status}forcompleted/error/stoppedthat finished within a window (default 1h)unstract_batches_in_progress,unstract_batches_stuck(frompg_barrier_state)unstract_metrics_last_successful_refresh_timestamp{group},unstract_metrics_refresh_failures_total{group}(one cluster-wide total kept in Redis),unstract_metrics_cache_up(0 while this process can't reach Redis)Why
/metrics; Unstract had nothing equivalent. The PG reaper's/metricsdoesn't split waiting from in-progress, has no execution counts, drops empty queues, and is empty on a standby reaper.How
groups.py): eachMetricGrouphasquery()(DB, returns a JSON-safe snapshot) andfamilies()(snapshot to Prometheus families). They're registered inMETRIC_GROUPS, so a new group needs no change to the endpoint or the caching.cache.py): each group's snapshot lives in Redis forPLATFORM_METRICS_CACHE_TTL_SECONDS(15s), behind a non-blockingredis.lock. One pod refreshes a stale group and the rest serve the previous snapshot, so DB cost is about one refresh per group per 15s however many replicas get scraped. The cache is re-checked after the lock is taken, and the lock's expiry is sized from each group's statement count times the query timeout, so it can't expire mid-refresh.unstract_metrics_cache_updrops to 0.HINCRBY+HGETALLin one transaction), so every pod and gunicorn worker reports the same monotonic total instead of a per-process count that would look like counter resets. While Redis is down the counter is left out of the scrape, and the buffered increments are flushed on the next successful one.SET LOCAL statement_timeout(PLATFORM_METRICS_QUERY_TIMEOUT_MS, 5s), and the view isnon_atomic_requestsso that timeout never outlives the refresh.refresh_queue_gauges(all rows,now() - min(enqueued_at)), so the per-state counts add up topg_queue_depth.batches_stuckis the reaper's stranded predicate, read from the sameWORKER_PG_BATCH_STUCK_TIMEOUT_SECONDS. Queue metrics cover the PG transport only; work dispatched through Celery sits in RabbitMQ.EXPLAIN ANALYZEon 400k executions):we_active_by_workflow_idx.we_created_at_idxrange scan bounded byPLATFORM_METRICS_RECENT_LOOKBACK_SECONDS(24h). They are then filtered on the finish time,created_at + execution_time, which both status writers stamp on the terminal transition.modified_atis only the fallback, for rows terminalised withoutexecution_time(the undispatched-execution sweep), because later writes such as result acknowledgement bump it./health. The public ingress only routes prefixed paths (/api/v1,/deployment,/public, …) to the backend, so/metricsis reachable in-cluster on the backend service only.WHITELISTED_PATHS).PLATFORM_METRICS_TOKENadditionally requiresAuthorization: Bearer <token>, compared in constant time.Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)
/api/v1/unstract/<org>/metrics/. No execution path, model or migration changes. The only shared state is new Redis keys underplatform_metrics:v1:*.Database Migrations
Env Config
All optional:
PLATFORM_METRICS_TOKEN(default empty, meaning no token check)PLATFORM_METRICS_CACHE_TTL_SECONDS(15)PLATFORM_METRICS_QUERY_TIMEOUT_MS(5000)PLATFORM_METRICS_RECENT_WINDOW_SECONDS(3600)PLATFORM_METRICS_RECENT_LOOKBACK_SECONDS(86400)PLATFORM_METRICS_QUEUES: comma-separated list of always-reported queues. It defaults to the queues the PG consumers indocker-compose.yamlconsume. Cloud should override it if it runs extra PG queues.WORKER_PG_BATCH_STUCK_TIMEOUT_SECONDSis now also read by the backend.Relevant Docs
backend/app/controller/metrics.pywas the reference pattern.Related Issues or PRs
/metrics, which this PR leaves unchanged. Endpoints for autoscaling are a separate ticket. PodMonitoring/scrape config in the cloud chart is out of scope here.Dependencies Versions
prometheus-client>=0.17.0,<1.0.0added to the backend (same range the workers already pin; locks to 0.26.0).Notes on Testing
backend/platform_metrics/tests/, 43 tests:integration) run against Postgres 16. They check each group's counts and edges:remaining=0lingerersrunserveragainst Postgres + Redis:max(), notsum(): every pod serves the same shared snapshot, and the failure counter is the same cluster-wide total on every pod.Screenshots
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL