Skip to content

Commit 998471f

Browse files
committed
add failed runtime, online worker count, hostname label
1 parent 7bdf2e9 commit 998471f

4 files changed

Lines changed: 120 additions & 50 deletions

File tree

README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,17 @@ on the finished tasks. celerymon uses all these three to get the data.
6363

6464
## Metrics
6565

66-
| Name | Type | Labels |
67-
|-----------------------------------------------------------|-----------|---------------------------|
68-
| `celerymon_redis_last_updated_timestamp_seconds` | Gauge | |
69-
| `celerymon_redis_queue_item_count` | Gauge | `queue_name`, `priority` |
70-
| `celerymon_inspect_last_updated_timestamp_seconds` | Gauge | |
71-
| `celerymon_inspect_oldest_started_task_timestamp_seconds` | Gauge | `task_name` |
72-
| `celerymon_inspect_worker_held_task_count` | Gauge | `task_name`, `state` |
73-
| `celerymon_events_last_received_timestamp_seconds` | Gauge | `task_name`, `event_name` |
74-
| `celerymon_events_count` | Counter | `task_name`, `event_name` |
75-
| `celerymon_events_success_task_runtime_seconds` | Histogram | `task_name` |
66+
| Name | Type | Labels |
67+
|-----------------------------------------------------------|-----------|----------------------------------|
68+
| `celerymon_redis_last_updated_timestamp_seconds` | Gauge | |
69+
| `celerymon_redis_queue_item_count` | Gauge | `queue_name`, `priority` |
70+
| `celerymon_inspect_last_updated_timestamp_seconds` | Gauge | |
71+
| `celerymon_inspect_oldest_started_task_timestamp_seconds` | Gauge | `task_name` |
72+
| `celerymon_inspect_worker_held_task_count` | Gauge | `task_name`, `state`, `hostname` |
73+
| `celerymon_events_last_received_timestamp_seconds` | Gauge | `task_name`, `event_name` |
74+
| `celerymon_events_count` | Counter | `task_name`, `event_name` |
75+
| `celerymon_events_task_runtime_seconds` | Histogram | `task_name`, `result` |
76+
| `celerymon_events_online_worker_count` | Gauge | |
7677

7778
There are timestamp metrics. These are meant to be used for checking the
7879
monitoring health. If this stops updating, it means that the monitoring cannot

celerymon/event_watcher.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ class EventWatcher:
2323
num_events_per_task_count: dict[tuple[str, str], int]
2424
upper_bounds: list[float]
2525
task_names: set[str]
26-
succeeded_task_runtime_sec: list[dict[str, int]]
27-
succeeded_task_runtime_sec_sum: dict[str, float]
26+
task_runtime_sec: list[dict[tuple[str, str], int]]
27+
task_runtime_sec_sum: dict[tuple[str, str], float]
2828

2929
@classmethod
3030
def create_started(
@@ -71,6 +71,7 @@ def update_enable_event() -> None:
7171

7272
def __init__(self, buckets: Sequence[float | str]):
7373
self._task_names_by_uuid: OrderedDict[str, str] = OrderedDict()
74+
self._worker_last_heartbeat: dict[str, datetime.datetime] = dict()
7475

7576
self.upper_bounds = [float(b) for b in buckets]
7677
if self.upper_bounds and self.upper_bounds[-1] != float("inf"):
@@ -81,16 +82,21 @@ def __init__(self, buckets: Sequence[float | str]):
8182
self.last_received_timestamp = None
8283
self.last_received_timestamp_per_task_event = dict()
8384
self.num_events_per_task_count = defaultdict(int)
84-
self.succeeded_task_runtime_sec = []
85-
self.succeeded_task_runtime_sec_sum = defaultdict(float)
86-
for _ in range(0, len(self.upper_bounds)):
87-
self.succeeded_task_runtime_sec.append(defaultdict(int))
85+
self.task_runtime_sec = [
86+
defaultdict(int) for _ in range(len(self.upper_bounds))
87+
]
88+
self.task_runtime_sec_sum = defaultdict(float)
8889

8990
def on_event(self, event: dict[str, Any]):
9091
now = datetime.datetime.now(tz=datetime.UTC)
9192
self.last_received_timestamp = now
9293

9394
event_name: str = event["type"]
95+
96+
if event_name.startswith("worker-"):
97+
self._on_worker_event(event_name, event, now)
98+
return
99+
94100
if not event_name.startswith("task-"):
95101
return
96102

@@ -108,10 +114,52 @@ def on_event(self, event: dict[str, Any]):
108114
self.num_events_per_task_count[(task_name, event_name)] += 1
109115

110116
if event_name == "task-succeeded":
111-
# Not documented, but looking into the Celery codebase, the runtime
112-
# looks like seconds.
113-
runtime_sec = event["runtime"]
114-
self.succeeded_task_runtime_sec_sum[task_name] += runtime_sec
115-
for i, bound in enumerate(self.upper_bounds):
116-
if runtime_sec <= bound:
117-
self.succeeded_task_runtime_sec[i][task_name] += 1
117+
self._record_task_runtime(task_name, "success", event)
118+
119+
if event_name == "task-failed":
120+
self._record_task_runtime(task_name, "failed", event)
121+
122+
def _on_worker_event(
123+
self,
124+
event_name: str,
125+
event: dict[str, Any],
126+
now: datetime.datetime,
127+
) -> None:
128+
hostname = event.get("hostname")
129+
if not hostname:
130+
return
131+
if event_name == "worker-offline":
132+
self._worker_last_heartbeat.pop(hostname, None)
133+
else:
134+
self._worker_last_heartbeat[hostname] = now
135+
136+
def online_worker_count(
137+
self,
138+
now: datetime.datetime,
139+
ttl_sec: int = 120,
140+
) -> int:
141+
cutoff = now - datetime.timedelta(seconds=ttl_sec)
142+
return sum(1 for ts in self._worker_last_heartbeat.values() if ts > cutoff)
143+
144+
def _record_task_runtime(
145+
self,
146+
task_name: str,
147+
result: str,
148+
event: dict[str, Any],
149+
) -> None:
150+
# Celery sets `runtime` (seconds) on task-succeeded consistently, and on
151+
# task-failed when the task actually ran. Defensive `.get()` handles
152+
# failure modes where the task never executed.
153+
runtime_sec = event.get("runtime")
154+
if runtime_sec is None:
155+
logger.debug(
156+
"task event missing runtime field; task_name=%s result=%s",
157+
task_name,
158+
result,
159+
)
160+
return
161+
key = (task_name, result)
162+
self.task_runtime_sec_sum[key] += runtime_sec
163+
for i, bound in enumerate(self.upper_bounds):
164+
if runtime_sec <= bound:
165+
self.task_runtime_sec[i][key] += 1

celerymon/metrics.py

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import datetime
67
from typing import Iterable
78

89
import prometheus_client
@@ -85,7 +86,7 @@ def worker_metrics(watcher: WorkerWatcher) -> list[prometheus_client.Metric]:
8586
active_task_count_metric = GaugeMetricFamily(
8687
name="celerymon_inspect_worker_held_task_count",
8788
documentation="Number of tasks held in all workers",
88-
labels=["task_name", "state"],
89+
labels=["task_name", "state", "hostname"],
8990
)
9091
if watcher.last_updated_timestamp is not None:
9192
last_updated_timestamp_seconds_metric.add_metric(
@@ -100,9 +101,9 @@ def worker_metrics(watcher: WorkerWatcher) -> list[prometheus_client.Metric]:
100101
timestamp=watcher.last_updated_timestamp.timestamp(),
101102
)
102103
for key, count in watcher.task_count.items():
103-
state, task_name = key
104+
state, task_name, hostname = key
104105
active_task_count_metric.add_metric(
105-
labels=[task_name, state],
106+
labels=[task_name, state, hostname],
106107
value=count,
107108
timestamp=watcher.last_updated_timestamp.timestamp(),
108109
)
@@ -125,12 +126,21 @@ def event_metrics(watcher: EventWatcher) -> list[prometheus_client.Metric]:
125126
documentation="The task event count per task name and event name.",
126127
labels=["task_name", "event_name"],
127128
)
128-
success_task_runtime_seconds_metric = HistogramMetricFamily(
129-
name="celerymon_events_success_task_runtime_seconds",
130-
documentation="The task runtime per task name for finished success tasks.",
131-
labels=["task_name"],
129+
task_runtime_seconds_metric = HistogramMetricFamily(
130+
name="celerymon_events_task_runtime_seconds",
131+
documentation=(
132+
"Task runtime per task name, labeled by result (success|failed)."
133+
),
134+
labels=["task_name", "result"],
132135
unit="seconds",
133136
)
137+
online_worker_count_metric = GaugeMetricFamily(
138+
name="celerymon_events_online_worker_count",
139+
documentation=(
140+
"Number of workers currently online, derived from worker-online, "
141+
"worker-heartbeat, and worker-offline events."
142+
),
143+
)
134144
if watcher.last_received_timestamp is not None:
135145
for key, timestamp in watcher.last_received_timestamp_per_task_event.items():
136146
count = watcher.num_events_per_task_count[key]
@@ -147,19 +157,28 @@ def event_metrics(watcher: EventWatcher) -> list[prometheus_client.Metric]:
147157
timestamp=timestamp.timestamp(),
148158
)
149159
for task_name in watcher.task_names:
150-
acc = 0.0
151-
buckets = []
152-
for i, bound in enumerate(watcher.upper_bounds):
153-
acc += watcher.succeeded_task_runtime_sec[i][task_name]
154-
buckets.append((floatToGoString(bound), acc))
155-
success_task_runtime_seconds_metric.add_metric(
156-
labels=[task_name],
157-
buckets=buckets,
158-
sum_value=watcher.succeeded_task_runtime_sec_sum[task_name],
159-
timestamp=watcher.last_received_timestamp.timestamp(),
160-
)
160+
for result in ("success", "failed"):
161+
key = (task_name, result)
162+
acc = 0.0
163+
buckets = []
164+
for i, bound in enumerate(watcher.upper_bounds):
165+
acc += watcher.task_runtime_sec[i].get(key, 0)
166+
buckets.append((floatToGoString(bound), acc))
167+
task_runtime_seconds_metric.add_metric(
168+
labels=[task_name, result],
169+
buckets=buckets,
170+
sum_value=watcher.task_runtime_sec_sum.get(key, 0.0),
171+
timestamp=watcher.last_received_timestamp.timestamp(),
172+
)
173+
now = datetime.datetime.now(tz=datetime.UTC)
174+
online_worker_count_metric.add_metric(
175+
labels=[],
176+
value=watcher.online_worker_count(now),
177+
timestamp=watcher.last_received_timestamp.timestamp(),
178+
)
161179
return [
162180
last_received_timestamp_seconds_metric,
163181
events_count_metric,
164-
success_task_runtime_seconds_metric,
182+
task_runtime_seconds_metric,
183+
online_worker_count_metric,
165184
]

celerymon/worker_watcher.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
class WorkerWatcher:
1515
last_updated_timestamp: datetime.datetime | None
1616
oldest_started_task_timestamp: dict[str, datetime.datetime]
17-
task_count: dict[tuple[str, str], int]
17+
task_count: dict[tuple[str, str, str], int]
1818

1919
@classmethod
2020
def create_started(
@@ -37,28 +37,30 @@ def __init__(self, app: celery.Celery):
3737

3838
def _update(self) -> None:
3939
oldest_timestamp: dict[str, datetime.datetime] = dict()
40-
task_count: dict[tuple[str, str], int] = defaultdict(int)
40+
task_count: dict[tuple[str, str, str], int] = defaultdict(int)
4141

42-
for tasks in (self._inspect.active() or {}).values():
42+
for hostname, tasks in (self._inspect.active() or {}).items():
4343
for task in tasks:
4444
if isinstance(task["time_start"], str):
4545
start_time = datetime.datetime.fromisoformat(task["time_start"])
4646
else:
4747
start_time = datetime.datetime.fromtimestamp(task["time_start"])
4848
task_name = task["type"]
49-
task_count[("active", task_name)] += 1
49+
task_count[("active", task_name, hostname)] += 1
5050
if task_name not in oldest_timestamp:
5151
oldest_timestamp[task_name] = start_time
5252
else:
5353
oldest_timestamp[task_name] = min(
5454
oldest_timestamp[task_name], start_time
5555
)
56-
for tasks in (self._inspect.reserved() or {}).values():
56+
for hostname, tasks in (self._inspect.reserved() or {}).items():
5757
for task in tasks:
58-
task_count[("reserved", task["type"])] += 1
59-
for scheduled_tasks in (self._inspect.scheduled() or {}).values():
58+
task_count[("reserved", task["type"], hostname)] += 1
59+
for hostname, scheduled_tasks in (self._inspect.scheduled() or {}).items():
6060
for scheduled_task in scheduled_tasks:
61-
task_count[("scheduled", scheduled_task["request"]["type"])] += 1
61+
task_count[
62+
("scheduled", scheduled_task["request"]["type"], hostname)
63+
] += 1
6264

6365
self.last_updated_timestamp = datetime.datetime.now(tz=datetime.UTC)
6466
self.oldest_started_task_timestamp = oldest_timestamp

0 commit comments

Comments
 (0)