Collecting Metrics from Celery Workers

A Celery deployment has two places where things go wrong, and they need different metrics. Inside the workers, tasks succeed, fail, retry and take longer than expected. Outside them, in the broker, tasks wait — and when workers are stuck or too few, the waiting is the whole story, invisible to the workers themselves. This article covers both: recording task outcomes with Celery's signals, aggregating across the prefork pool, and measuring the backlog from the broker. It is part of metrics in multi-process Python servers in the Python metrics and instrumentation section.

Two vantage points on one task system Producers enqueue tasks to a broker holding two queues, default and reports, with 40 and 2 300 waiting tasks respectively. A Celery worker's main process manages four prefork pool processes. Signals in the pool processes record task starts, durations, successes, failures and retries, written to multiprocess files and served by the main process on a metrics port. A separate broker exporter reads queue lengths and the enqueue time of the oldest waiting task directly from the broker. The worker metrics answer how tasks behave once taken; the broker metrics answer how much is waiting and for how long. When every worker is stuck on a slow task, the worker metrics go quiet, and only the broker metrics show the reports queue growing and its oldest task aging past twenty minutes. producersweb, cron broker default · 40 waiting reports · 2 300 · oldest 24 min celery worker main process · serves :9808/metrics 4 pool processes · signals record tasks multiprocess files aggregate them broker exporter depth + oldest-task age task metrics count · duration · failures · retries when every worker is stuck on one slow task task metrics: quiet — nothing is finishing, so nothing is recorded broker metrics: the reports queue grows and its oldest task ages — the real incident
Workers can describe the tasks they run. Only the broker can describe the tasks nobody is running yet.

Prerequisites

pip install "celery>=5.3.0,<6.0.0" "prometheus-client>=0.20.0,<1.0.0" "redis>=5.0.0,<6.0.0"

A Celery application with the prefork pool, and a Prometheus server that can scrape both the workers and a small broker exporter.

Implementation steps

Step 1 — Define task metrics. Counters for outcomes, a histogram for runtime, and the task name as the principal label. Task names are a bounded set defined in code, so they are safe as label values; task arguments are not, and never belong in labels.

# myservice/celery_metrics.py
from prometheus_client import Counter, Histogram, Gauge

TASKS = Counter("celery_tasks_total", "Tasks finished", ["task", "outcome"])
RUNTIME = Histogram("celery_task_runtime_seconds", "Task runtime", ["task"],
                    buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300))
RETRIES = Counter("celery_task_retries_total", "Task retries", ["task"])
ACTIVE = Gauge("celery_tasks_active", "Tasks running", ["task"], multiprocess_mode="livesum")

The runtime buckets reach much further than an HTTP latency histogram's, because task runtimes span seconds to minutes. Choosing them follows the same reasoning as choosing histogram buckets for latency SLOs: put boundaries where decisions are made.

Step 2 — Record with signals. The signals fire in the pool process running the task, before and after it, and on failure and retry.

import time
from celery import signals
from myservice.celery_metrics import TASKS, RUNTIME, RETRIES, ACTIVE

_started: dict[str, float] = {}

@signals.task_prerun.connect
def _prerun(task_id, task, **_):
    _started[task_id] = time.perf_counter()
    ACTIVE.labels(task=task.name).inc()

@signals.task_postrun.connect
def _postrun(task_id, task, state, **_):
    ACTIVE.labels(task=task.name).dec()
    t0 = _started.pop(task_id, None)
    if t0 is not None:
        RUNTIME.labels(task=task.name).observe(time.perf_counter() - t0)
    TASKS.labels(task=task.name, outcome=(state or "UNKNOWN").lower()).inc()

@signals.task_retry.connect
def _retry(request, **_):
    RETRIES.labels(task=request.task).inc()

task_postrun fires for successes and failures alike, with the final state, so one handler counts every outcome. Retries are counted separately because a retried task's postrun reports RETRY, and a retry rate is a distinct signal — often the first sign a downstream dependency is struggling.

Step 3 — Aggregate across the pool. Set PROMETHEUS_MULTIPROC_DIR in the worker's environment and clean it at startup, exactly as for Gunicorn in Prometheus multiprocess mode with Gunicorn. Celery's worker_process_shutdown signal is the equivalent of Gunicorn's child_exit for gauge cleanup.

import os
from prometheus_client import multiprocess

@signals.worker_process_shutdown.connect
def _pool_exit(pid=None, **_):
    multiprocess.mark_process_dead(pid or os.getpid())

Step 4 — Serve from the main process. The main worker process runs no tasks, and it is the one process that lives for the worker's whole life, so it is where the HTTP server belongs. The worker_init signal fires there once.

from prometheus_client import CollectorRegistry, start_http_server

@signals.worker_init.connect
def _serve_metrics(**_):
    registry = CollectorRegistry()
    multiprocess.MultiProcessCollector(registry)
    start_http_server(9808, registry=registry)
Signals and the processes they fire in The main worker process receives worker_init once at startup, where it cleans nothing but starts the metrics HTTP server with a multiprocess registry. Each pool process receives task_prerun before a task, which records the start time and increments the active gauge; task_postrun after it, which records runtime and counts the outcome; task_retry when a task schedules a retry, which counts the retry; and worker_process_shutdown when the pool process exits, which calls mark_process_dead. A note records that the per-task start times live in the pool process's memory, keyed by task id, so a pool process killed mid-task loses that entry — which is why the runtime handler tolerates a missing start time. signal → process → what it records worker_initmain processstart the metrics server with a multiprocess registry task_prerunpool processstart time · active +1 task_postrunpool processruntime · outcome count · active −1 task_retrypool processretry count worker_process_shutdownpool processmark_process_dead a pool process killed mid-task loses its start time — the handler must tolerate a missing entry
Task metrics are recorded where tasks run and served from the one process that runs none.

Measuring the backlog from the broker

Queue depth is the metric most needed during a Celery incident, and workers cannot provide it. A worker knows about the tasks it has prefetched, not the thousands still in the broker. When every worker is busy with a slow task, the workers report nothing new at all.

A small exporter, running separately from the workers, reads the broker directly. For Redis, each queue is a list and its length is the depth; for RabbitMQ, the management API reports messages ready and unacknowledged per queue.

# broker_exporter.py — runs as its own small deployment
import json, time, redis
from prometheus_client import Gauge, start_http_server

DEPTH = Gauge("celery_queue_depth", "Tasks waiting", ["queue"])
OLDEST = Gauge("celery_queue_oldest_task_age_seconds", "Age of the oldest waiting task", ["queue"])
QUEUES = ["default", "reports"]

def main():
    r = redis.Redis.from_url("redis://redis:6379/0")
    start_http_server(9809)
    while True:
        for q in QUEUES:
            DEPTH.labels(queue=q).set(r.llen(q))
            tail = r.lindex(q, -1)                 # Celery pops from the tail
            age = 0.0
            if tail:
                sent = json.loads(tail)["headers"].get("sent_at")
                age = max(0.0, time.time() - sent) if sent else 0.0
            OLDEST.labels(queue=q).set(age)
        time.sleep(15)

The age of the oldest task needs an enqueue timestamp, which Celery does not add by default. A before_task_publish signal in the producers can add one to the headers:

@signals.before_task_publish.connect
def _stamp(headers=None, **_):
    headers["sent_at"] = time.time()

The exporter is deliberately independent of the workers: its own deployment, its own scrape target, its own failure modes. If it ran inside a worker it would share the worker's fate, and the one incident it exists for — every worker stuck — would silence it too. It is also cheap, a couple of broker commands per queue every fifteen seconds, so running two replicas for resilience costs nothing measurable.

Age is a better alerting signal than depth. A depth of two thousand is fine for a queue that drains ten thousand a minute and alarming for one that drains ten; an oldest-task age of twenty minutes means the same thing everywhere — work is twenty minutes late.

Reading the worker and broker metrics together

Each set of metrics is useful alone, and the combination diagnoses most Celery incidents in a few minutes. The useful comparison is between the rate tasks arrive, the rate they finish, and how long each one takes.

Four backlogs, four signatures A table of four incident patterns. Too few workers: the oldest-task age rises, task runtime is normal, the completion rate is flat at its maximum and the active gauge equals the concurrency limit — the fix is more workers. Slow tasks: age rises, the runtime histogram's upper buckets fill, and completions drop while active stays at the limit — look at the dependency the task calls, usually visible in its trace. Stuck workers: age rises, completions drop to zero, runtime records nothing because nothing finishes, and active stays at the limit indefinitely — a hung call with no timeout. Failure loop: age may stay flat, the retry counter climbs, and the failure outcome dominates — a downstream error being retried rather than surfaced. The note says the broker metric is the alarm and the worker metrics are the diagnosis. patternoldest ageruntimecompletionstells you too few workersrisingnormalflat at maxscale out slow tasksrisingupper buckets fillfallingcheck the dependency stuck workersrising fastsilentzeroa call with no timeout failure loopmay be flatshortretries climbsurface the error the broker metric is the alarm; the worker metrics are the diagnosis
Age says something is wrong. Runtime, completions and retries say which of four things it is.

Too few workers shows as rising age with normal runtimes and a completion rate that is flat at its ceiling: every worker is busy and every task is behaving, there just is not enough capacity. Autoscaling on oldest-task age, rather than on CPU, responds to exactly this case.

Slow tasks show as rising age with the runtime histogram shifting into its upper buckets. The cause is almost always a dependency — a database or an HTTP API the task calls — and a trace of one slow task names it, which is where tracing slow SQL queries in Python picks up.

Stuck workers are the case the broker exporter exists for. Completions fall to zero, the runtime histogram records nothing because nothing finishes, and the active gauge sits at the concurrency limit. The cause is a call with no timeout; Celery's task_time_limit bounds the damage by killing the pool process, and the resulting failures appear in the outcome counter.

Failure loops can leave age flat while the retry counter climbs, because tasks are being taken and put back. A retry rate alert catches them before the retries exhaust and the failures become permanent.

Configuration options

Item Setting Notes
Task label task.name bounded, defined in code
Runtime buckets 50 ms to 5 min tasks run longer than requests
PROMETHEUS_MULTIPROC_DIR set for the worker pool processes aggregate
Metrics server worker_init, main process one port per worker
Gauge cleanup worker_process_shutdown pool recycling
Broker exporter separate deployment sees what workers cannot
sent_at header before_task_publish enables age
Alert oldest age per queue maps onto user impact

Verification

Enqueue a known number of tasks and compare counters:

from myservice.tasks import noop
for _ in range(500):
    noop.delay()
curl -s localhost:9808/metrics | grep 'celery_tasks_total{outcome="success",task="myservice.tasks.noop"}'

Expected Output: five hundred, whichever pool processes ran them.

celery_tasks_total{outcome="success",task="myservice.tasks.noop"} 500.0

Then stop the workers, enqueue a hundred more, and watch the broker exporter: depth reads one hundred and the oldest-task age rises by fifteen seconds each scrape — the incident view, with no worker running.

Common mistakes

Metrics server started in every pool process. Error signature: Address already in use in the pool, or metrics from one random child. Root cause: the server started at import. Remediation: worker_init, which runs once in the main process.

No multiprocess mode. Error signature: task counters stuck at zero. Root cause: the main process serves its own registry, and it runs no tasks. Remediation: the multiprocess directory and collector.

Task arguments as labels. Error signature: a series count that grows with every order or user. Root cause: unbounded values in labels. Remediation: the task name only; arguments belong on spans or log records.

Queue depth from inspect(). Error signature: depths that look fine during an outage. Root cause: inspect asks workers, which see only prefetched tasks and do not answer when stuck. Remediation: read the broker.

Alerting on depth alone. Error signature: pages for harmless bursts, silence for a slow trickle that nobody drains. Root cause: depth without drain rate. Remediation: alert on the oldest task's age.

Prefetch hiding the backlog. Error signature: broker depth near zero while tasks are late. Root cause: workers prefetching many tasks each, moving the backlog from the broker into worker memory, where the exporter cannot see it. Remediation: worker_prefetch_multiplier = 1 for long tasks, and acks_late so unfinished tasks return to the queue.

Timestamps from the wrong clock. Error signature: negative or wildly large ages. Root cause: producer and exporter clocks disagree. Remediation: synchronised clocks, and clamping age at zero as the exporter does.

No time limit on tasks. Error signature: active gauge pinned at the concurrency limit for hours. Root cause: a blocking call without a timeout. Remediation: task_time_limit and task_soft_time_limit, with the soft limit logged and counted.

Frequently Asked Questions

Why use signals instead of decorating each task?

Signals fire for every task without changing task code, including tasks from libraries. They also give access to the task's name, state and runtime in one place, so every task is measured the same way.

Do Celery prefork workers need multiprocess mode?

Yes. Tasks run in pool child processes, each with its own memory, while the HTTP endpoint runs in one process. Without multiprocess mode the endpoint reports only its own process's values, which are usually zero because the main process runs no tasks.

How should queue depth be measured?

From the broker — Redis list length or RabbitMQ queue message counts — by an exporter independent of the workers. Workers cannot see tasks they have not received, and when every worker is stuck, the metric most needed is the one they cannot report.

What is the most useful single Celery metric?

The age of the oldest waiting task in each queue. It rises when workers fall behind for any reason — slow tasks, too few workers, a stuck worker — and it maps directly onto how late the work users are waiting for will be.

Does the eventlet or gevent pool change anything?

It removes the multiprocess requirement, because every task runs in the one worker process. Recording with signals is the same; long-running synchronous code in a task blocks the whole worker, which a task-duration histogram will show.