1from __future__ import annotations
2
3import importlib.metadata
4from collections.abc import Callable, Iterable, Iterator
5from contextlib import contextmanager
6from functools import wraps
7from typing import TYPE_CHECKING, Any, ClassVar
8
9from opentelemetry import metrics, trace
10from opentelemetry.metrics import CallbackOptions, Observation
11from opentelemetry.semconv._incubating.attributes.code_attributes import (
12 CODE_FUNCTION_NAME,
13)
14from opentelemetry.semconv._incubating.attributes.messaging_attributes import (
15 MESSAGING_DESTINATION_NAME,
16 MESSAGING_OPERATION_TYPE,
17 MESSAGING_SYSTEM,
18 MessagingOperationTypeValues,
19)
20from opentelemetry.semconv._incubating.metrics.messaging_metrics import (
21 create_messaging_client_consumed_messages,
22 create_messaging_client_operation_duration,
23 create_messaging_client_sent_messages,
24)
25from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
26from plain.postgres import Q
27from plain.postgres.aggregates import Count, Min
28from plain.postgres.db import return_database_connection
29from plain.postgres.otel import suppress_db_tracing
30from plain.utils import timezone
31from plain.utils.otel import format_exception_type
32
33if TYPE_CHECKING:
34 from .models import JobResult
35 from .workers import Worker
36
37# Attribute key for the terminal-status dimension on the consumed counter.
38PLAIN_JOBS_OUTCOME = "plain.jobs.outcome"
39
40# Attribute key for the worker-liveness dimension on plain.jobs.workers.
41PLAIN_JOBS_WORKER_STATE = "plain.jobs.worker.state"
42
43try:
44 _package_version = importlib.metadata.version("plain.jobs")
45except importlib.metadata.PackageNotFoundError:
46 _package_version = "dev"
47
48tracer = trace.get_tracer("plain.jobs", _package_version)
49meter = metrics.get_meter("plain.jobs", version=_package_version)
50
51# Per-event instruments (semconv messaging metrics + plain.jobs queue.wait.duration).
52sent_messages_counter = create_messaging_client_sent_messages(meter)
53consumed_messages_counter = create_messaging_client_consumed_messages(meter)
54operation_duration_histogram = create_messaging_client_operation_duration(meter)
55queue_wait_duration_histogram = meter.create_histogram(
56 name="plain.jobs.queue.wait.duration",
57 unit="s",
58 description="Time a job spent waiting in the queue before a worker picked it up.",
59)
60
61
62def record_span_error(span: trace.Span, exc: BaseException) -> str:
63 """Mark the span as failed, stamp error.type on it, and return the
64 error.type string so the caller can forward it to other instruments
65 (e.g. per-call metric attributes)."""
66 error_type = format_exception_type(exc)
67 span.record_exception(exc)
68 span.set_status(trace.StatusCode.ERROR)
69 span.set_attribute(ERROR_TYPE, error_type)
70 return error_type
71
72
73@contextmanager
74def error_consumer_span(*, name: str, exc: BaseException) -> Iterator[None]:
75 """Open a one-off CONSUMER span solely to carry a failure that has no
76 other entry span — a DB error before the span that would normally own
77 the work is ever reached. Stamps the canonical failure signal so the
78 error lands in entry-span error attribution.
79
80 Run the paired ``logger.exception`` call inside the ``with`` block so the
81 log record is created while this span is current — the record then carries
82 the span's trace/span ids instead of exporting as a span-less error log
83 that reports the same failure a second time."""
84 with tracer.start_as_current_span(name, kind=trace.SpanKind.CONSUMER) as span:
85 record_span_error(span, exc)
86 yield
87
88
89def process_metric_attributes(queue: str, job_class: str) -> dict[str, Any]:
90 """Base attribute dict for messaging.client.* process-side metrics.
91
92 Shared by JobProcess.run() (which adds error.type for failed jobs) and
93 record_consumed (which adds the outcome dimension). One builder so
94 keys/values stay in lockstep across the two call sites.
95 """
96 return {
97 MESSAGING_SYSTEM: "plain.jobs",
98 MESSAGING_OPERATION_TYPE: MessagingOperationTypeValues.PROCESS.value,
99 MESSAGING_DESTINATION_NAME: queue,
100 CODE_FUNCTION_NAME: f"{job_class}.run",
101 }
102
103
104def record_consumed(result: JobResult, *, error_type: str | None = None) -> None:
105 """Record one consumed-message metric point per terminal JobResult.
106
107 `plain.jobs.outcome` carries the terminal status (successful/errored/
108 lost/cancelled/deferred). `error.type` is included when known — i.e.,
109 when the live path caught an exception and forwarded it through. The
110 rescue path (LOST) and direct cancellations don't carry an error type
111 because there is no exception object to derive it from."""
112 attrs = process_metric_attributes(result.queue, result.job_class)
113 attrs[PLAIN_JOBS_OUTCOME] = result.status.lower()
114 if error_type is not None:
115 attrs[ERROR_TYPE] = error_type
116 consumed_messages_counter.add(1, attrs)
117
118
119def _gauge_db_queries(
120 callback: Callable[..., Iterable[Observation]],
121) -> Callable[..., Iterable[Observation]]:
122 """Wrap a gauge callback that queries the database.
123
124 OTel runs observable-gauge callbacks on the PeriodicExportingMetricReader
125 thread, which has no request or job lifecycle — that shapes both concerns
126 handled here:
127
128 - Suppress DB span tracing for the callback's queries. No entry span is
129 active on this thread, so each query would otherwise export as its own
130 single-span root trace — per gauge, per export interval, forever.
131 - Return the connection to the pool once the observation has been
132 collected. Left unreturned, this thread's connection wrapper holds a
133 single pooled connection idle between export intervals — long enough
134 for the server (or a pooler) to close it. The next interval then reuses
135 the dead connection and raises `OperationalError: the connection is
136 closed`. Returning it each interval means every callback starts from a
137 freshly checked-out connection.
138 """
139
140 @wraps(callback)
141 def wrapper(
142 cls: type[WorkerMetrics], options: CallbackOptions
143 ) -> Iterable[Observation]:
144 with suppress_db_tracing():
145 try:
146 # list() is load-bearing: the SDK iterates the result after
147 # this wrapper exits, so a lazy iterable would run its
148 # queries un-suppressed on an already-returned connection.
149 return list(callback(cls, options))
150 finally:
151 return_database_connection()
152
153 return wrapper
154
155
156class WorkerMetrics:
157 """Per-Worker observable gauges (queue depth/age/scheduled, running count,
158 worker process count).
159
160 The OTel SDK keeps the *first* callback registered for a given instrument
161 name, so instruments are registered once per process. The Worker they
162 observe may change across reload paths, so each Worker owns a
163 WorkerMetrics; constructing one swaps it in as the active target for the
164 (process-singleton) callbacks. The new instance simply replaces the old
165 one in the class-level `_current` slot — no explicit teardown is needed
166 because either a successor swaps in (reload) or the process exits
167 (signal shutdown).
168
169 Each callback emits one observation per queue this Worker handles, every
170 export interval, including zero for empty queues so `last_value`
171 dashboards don't show stale readings after a drain. When two Workers
172 handle the same queue they emit identical values; aggregate with
173 `last_value`/`max`, never `sum`.
174 """
175
176 _current: ClassVar[WorkerMetrics | None] = None
177 _registered: ClassVar[bool] = False
178
179 def __init__(self, worker: Worker) -> None:
180 self.worker = worker
181 type(self)._register_instruments()
182 type(self)._current = self
183
184 @classmethod
185 def _register_instruments(cls) -> None:
186 if cls._registered:
187 return
188 cls._registered = True
189 meter.create_observable_gauge(
190 name="plain.jobs.worker.processes",
191 callbacks=[cls._gauge_worker_processes],
192 unit="{process}",
193 description="OS processes spawned by this worker.",
194 )
195 meter.create_observable_gauge(
196 name="plain.jobs.queue.depth",
197 callbacks=[cls._gauge_queue_depth],
198 unit="{job}",
199 description="Pending JobRequests ready to run, per queue.",
200 )
201 meter.create_observable_gauge(
202 name="plain.jobs.queue.oldest.age",
203 callbacks=[cls._gauge_queue_oldest_age],
204 unit="s",
205 description="Age of the oldest ready-to-run JobRequest, per queue.",
206 )
207 meter.create_observable_gauge(
208 name="plain.jobs.queue.scheduled",
209 callbacks=[cls._gauge_queue_scheduled],
210 unit="{job}",
211 description="JobRequests with start_at in the future, per queue.",
212 )
213 meter.create_observable_gauge(
214 name="plain.jobs.running",
215 callbacks=[cls._gauge_running],
216 unit="{job}",
217 description="JobProcess rows currently running, per queue.",
218 )
219 meter.create_observable_gauge(
220 name="plain.jobs.workers",
221 callbacks=[cls._gauge_workers],
222 unit="{worker}",
223 description=(
224 "WorkerHeartbeat row count, split by liveness state "
225 "(active=within JOBS_HEARTBEAT_TIMEOUT, stale=past it)."
226 ),
227 )
228
229 # --- Callbacks ----------------------------------------------------------
230
231 # Each callback snapshots `cls._current` to a local — `deactivate()` can
232 # null the class var on another thread mid-callback (PeriodicExporting
233 # MetricReader runs callbacks off the main thread).
234
235 @classmethod
236 def _gauge_worker_processes(cls, options: CallbackOptions) -> Iterable[Observation]:
237 active = cls._current
238 if active is None:
239 return []
240 try:
241 n = len(active.worker.executor._processes)
242 except (AttributeError, TypeError):
243 # Pool may be mid-shutdown; report 0 rather than crashing the export.
244 n = 0
245 return [Observation(n)]
246
247 @classmethod
248 @_gauge_db_queries
249 def _gauge_queue_depth(cls, options: CallbackOptions) -> Iterable[Observation]:
250 active = cls._current
251 if active is None:
252 return []
253 # Lazy import - see Worker._worker_process_initializer() comment for why.
254 from .models import JobRequest
255
256 return _count_per_queue(JobRequest.query.ready_to_run(), active.worker.queues)
257
258 @classmethod
259 @_gauge_db_queries
260 def _gauge_queue_oldest_age(cls, options: CallbackOptions) -> Iterable[Observation]:
261 active = cls._current
262 if active is None:
263 return []
264 from .models import JobRequest
265
266 queues = active.worker.queues
267 rows = (
268 JobRequest.query.ready_to_run()
269 .filter(queue__in=queues)
270 .values("queue")
271 .annotate(oldest=Min("created_at"))
272 )
273 now = timezone.now()
274 # `max(0, ...)` defends against Python/Postgres clock skew producing
275 # a negative age. Empty queues fall through to 0.0 below.
276 ages = {
277 row["queue"]: max(0.0, (now - row["oldest"]).total_seconds())
278 for row in rows
279 if row["oldest"] is not None
280 }
281 return [
282 Observation(ages.get(q, 0.0), {MESSAGING_DESTINATION_NAME: q})
283 for q in queues
284 ]
285
286 @classmethod
287 @_gauge_db_queries
288 def _gauge_queue_scheduled(cls, options: CallbackOptions) -> Iterable[Observation]:
289 active = cls._current
290 if active is None:
291 return []
292 from .models import JobRequest
293
294 return _count_per_queue(JobRequest.query.scheduled(), active.worker.queues)
295
296 @classmethod
297 @_gauge_db_queries
298 def _gauge_running(cls, options: CallbackOptions) -> Iterable[Observation]:
299 active = cls._current
300 if active is None:
301 return []
302 from .models import JobProcess
303
304 return _count_per_queue(JobProcess.query.running(), active.worker.queues)
305
306 # The worker-liveness gauge observes the global WorkerHeartbeat table and
307 # doesn't need a calling Worker — emit unconditionally so dashboards keep
308 # reporting even during a full worker drain. One snapshot of the cutoff
309 # is shared across both observations so a row landing exactly at the
310 # boundary can't be counted in both states (or neither).
311 @classmethod
312 @_gauge_db_queries
313 def _gauge_workers(cls, options: CallbackOptions) -> Iterable[Observation]:
314 from .models import WorkerHeartbeat, heartbeat_cutoff
315
316 cutoff = heartbeat_cutoff()
317 counts = WorkerHeartbeat.query.aggregate(
318 active=Count("id", filter=Q(last_heartbeat_at__gte=cutoff)),
319 stale=Count("id", filter=Q(last_heartbeat_at__lt=cutoff)),
320 )
321 return [
322 Observation(counts["active"], {PLAIN_JOBS_WORKER_STATE: "active"}),
323 Observation(counts["stale"], {PLAIN_JOBS_WORKER_STATE: "stale"}),
324 ]
325
326
327def _count_per_queue(queryset: Any, queues: list[str]) -> list[Observation]:
328 rows = queryset.filter(queue__in=queues).values("queue").annotate(c=Count("*"))
329 counts = {row["queue"]: row["c"] for row in rows}
330 return [
331 Observation(counts.get(q, 0), {MESSAGING_DESTINATION_NAME: q}) for q in queues
332 ]