v0.163.0
  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    ]