|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from collections.abc import Mapping |
| 4 | +from dataclasses import dataclass, field |
| 5 | +from importlib import import_module |
| 6 | +from typing import Any, Protocol, TypeAlias |
| 7 | + |
| 8 | +MetricTags: TypeAlias = Mapping[str, str] |
| 9 | +MetricKey: TypeAlias = tuple[str, tuple[tuple[str, str], ...]] |
| 10 | + |
| 11 | +CLIENT_REQUESTS = "durable_workflow_client_requests" |
| 12 | +CLIENT_REQUEST_DURATION_SECONDS = "durable_workflow_client_request_duration_seconds" |
| 13 | +WORKER_POLLS = "durable_workflow_worker_polls" |
| 14 | +WORKER_POLL_DURATION_SECONDS = "durable_workflow_worker_poll_duration_seconds" |
| 15 | +WORKER_TASKS = "durable_workflow_worker_tasks" |
| 16 | +WORKER_TASK_DURATION_SECONDS = "durable_workflow_worker_task_duration_seconds" |
| 17 | + |
| 18 | + |
| 19 | +class MetricsRecorder(Protocol): |
| 20 | + """Pluggable counter and histogram recorder used by the client and worker.""" |
| 21 | + |
| 22 | + def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None: |
| 23 | + """Increment a counter metric.""" |
| 24 | + |
| 25 | + def record(self, name: str, value: float, tags: MetricTags | None = None) -> None: |
| 26 | + """Record a histogram/sample metric.""" |
| 27 | + |
| 28 | + |
| 29 | +class NoopMetrics: |
| 30 | + """Default metrics recorder that intentionally drops all observations.""" |
| 31 | + |
| 32 | + def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None: |
| 33 | + pass |
| 34 | + |
| 35 | + def record(self, name: str, value: float, tags: MetricTags | None = None) -> None: |
| 36 | + pass |
| 37 | + |
| 38 | + |
| 39 | +NOOP_METRICS = NoopMetrics() |
| 40 | + |
| 41 | + |
| 42 | +def _metric_key(name: str, tags: MetricTags | None) -> MetricKey: |
| 43 | + return name, tuple(sorted((str(k), str(v)) for k, v in (tags or {}).items())) |
| 44 | + |
| 45 | + |
| 46 | +@dataclass |
| 47 | +class InMemoryMetrics: |
| 48 | + """Simple recorder useful for tests and custom exporter loops.""" |
| 49 | + |
| 50 | + counters: dict[MetricKey, float] = field(default_factory=dict) |
| 51 | + histograms: dict[MetricKey, list[float]] = field(default_factory=dict) |
| 52 | + |
| 53 | + def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None: |
| 54 | + key = _metric_key(name, tags) |
| 55 | + self.counters[key] = self.counters.get(key, 0.0) + value |
| 56 | + |
| 57 | + def record(self, name: str, value: float, tags: MetricTags | None = None) -> None: |
| 58 | + self.histograms.setdefault(_metric_key(name, tags), []).append(value) |
| 59 | + |
| 60 | + def counter_value(self, name: str, tags: MetricTags | None = None) -> float: |
| 61 | + return self.counters.get(_metric_key(name, tags), 0.0) |
| 62 | + |
| 63 | + def observations(self, name: str, tags: MetricTags | None = None) -> list[float]: |
| 64 | + return list(self.histograms.get(_metric_key(name, tags), [])) |
| 65 | + |
| 66 | + |
| 67 | +class PrometheusMetrics: |
| 68 | + """Metrics recorder backed by the optional prometheus-client package.""" |
| 69 | + |
| 70 | + def __init__(self, *, registry: Any | None = None) -> None: |
| 71 | + try: |
| 72 | + prometheus_client = import_module("prometheus_client") |
| 73 | + except ImportError as exc: |
| 74 | + raise RuntimeError( |
| 75 | + "PrometheusMetrics requires prometheus-client. " |
| 76 | + "Install it with `pip install durable-workflow[prometheus]`." |
| 77 | + ) from exc |
| 78 | + |
| 79 | + self._counter_cls: Any = prometheus_client.Counter |
| 80 | + self._histogram_cls: Any = prometheus_client.Histogram |
| 81 | + self._registry = registry |
| 82 | + self._counters: dict[str, Any] = {} |
| 83 | + self._histograms: dict[str, Any] = {} |
| 84 | + self._label_names: dict[tuple[str, str], tuple[str, ...]] = {} |
| 85 | + |
| 86 | + def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None: |
| 87 | + tag_values = dict(_metric_key(name, tags)[1]) |
| 88 | + counter = self._metric("counter", name, tuple(tag_values)) |
| 89 | + if tag_values: |
| 90 | + counter.labels(**tag_values).inc(value) |
| 91 | + else: |
| 92 | + counter.inc(value) |
| 93 | + |
| 94 | + def record(self, name: str, value: float, tags: MetricTags | None = None) -> None: |
| 95 | + tag_values = dict(_metric_key(name, tags)[1]) |
| 96 | + histogram = self._metric("histogram", name, tuple(tag_values)) |
| 97 | + if tag_values: |
| 98 | + histogram.labels(**tag_values).observe(value) |
| 99 | + else: |
| 100 | + histogram.observe(value) |
| 101 | + |
| 102 | + def _metric(self, kind: str, name: str, label_names: tuple[str, ...]) -> Any: |
| 103 | + key = (kind, name) |
| 104 | + existing = self._label_names.get(key) |
| 105 | + if existing is not None and existing != label_names: |
| 106 | + raise ValueError( |
| 107 | + f"metric {name!r} was already registered as a {kind} " |
| 108 | + f"with labels {existing!r}; got {label_names!r}" |
| 109 | + ) |
| 110 | + self._label_names[key] = label_names |
| 111 | + |
| 112 | + store = self._counters if kind == "counter" else self._histograms |
| 113 | + if name in store: |
| 114 | + return store[name] |
| 115 | + |
| 116 | + kwargs: dict[str, Any] = {} |
| 117 | + if self._registry is not None: |
| 118 | + kwargs["registry"] = self._registry |
| 119 | + metric_cls = self._counter_cls if kind == "counter" else self._histogram_cls |
| 120 | + metric = metric_cls(name, f"{name} {kind}", label_names, **kwargs) |
| 121 | + store[name] = metric |
| 122 | + return metric |
0 commit comments