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 6 commits into
Conversation
[Medium impact] The PR appears safe to merge; the previously reported execution-query failure is fixed.
|
|
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 |
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
…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
dd9c434 to
2764b91
Compare
|
Thanks for this. I read it with its two likely readers in mind:
I know UN-4251 asks for "serve its last cached values" on a failed refresh. Point 1 is about bounding that. Stale counts served as current mislead both readers. 1. Queue counts can be served indefinitely after refreshes start failing When a refresh fails, the group serves its last snapshot ( Neither reader can tell a frozen count from a real one:
LLMWhisperer hit exactly this in LW-476. Zipstack/unstract-llm-whisperer#813 fixed it by omitting the per-queue series once the data is older than 60 s. KEDA then errors on the missing series ( A minimal version here:
While the data is fresh, empty queues still read 0, so UN-4251's "no gaps" holds. A gap then means only that the count is unknown, which is what a dashboard should show and what KEDA's fallback is for. 60 s stays above the worst healthy age, which is the 15 s TTL plus the 30 s lock floor when a refresher dies ( 2. The default The comment at 3. Smaller: the refresh runs inside whichever scrape takes the lock The refresh runs in the request thread ( A failed refresh also isn't backed off. The next scrape that finds the snapshot stale tries again ( Neither blocks this PR. A 4. FYI, no change needed:
The rest already fails closed, which is what KEDA needs:
Nice work on those. |
…?group= From review: - Queue counts older than PLATFORM_METRICS_QUEUE_MAX_STALENESS_SECONDS (60s) are left out of the scrape, so dashboards show a gap and KEDA falls back instead of scaling on a frozen value; the refresh timestamp stays visible - A failed refresh holds the lock for one TTL (and the Redis-less path remembers its attempt), so a struggling DB is retried about once per TTL - ?group=<name> serves only the named groups, so a queue-only reader never waits on the executions refresh; unknown names return 400 - Add the notification sub-queues to the default PLATFORM_METRICS_QUEUES Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
|
@ritwik-g thanks, that's a useful review. Taking each point in turn, all addressed in 6d6c501: 1. Stale queue counts: done, as you proposed. 2. Notification sub-queues: added 3. Done here rather than deferred, since both changes were small:
4. Lapsed claims: agreed, not in this PR. Ready + claimed is the right scaling signal while the reaper runs. 51 tests pass locally. CI is re-running. Generated by Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
…e expression Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL
|
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