Skip to content

Commit ffda2b1

Browse files
authored
Define metrics with Prometheus integration (#33)
<!-- Describe what has changed in this PR --> **What changed?** - Define metrics using Prometheus - also add cursorrules for running uv command <!-- Tell your future self why have you made these changes --> **Why?** It enables metrics collection for monitoring Cadence workflows and activities <!-- How have you verified this change? Tested locally? Added a unit test? Checked in staging env? --> **How did you test it?** unit tests <!-- Assuming the worst case, what can be broken when deploying this change to production? --> **Potential risks** <!-- Is it notable for release? e.g. schema updates, configuration or data migration required? If so, please mention it, and also update CHANGELOG.md --> **Release notes** <!-- Is there any documentation updates should be made for config, https://cadenceworkflow.io/docs/operation-guide/setup/ ? If so, please open an PR in https://github.com/cadence-workflow/cadence-docs --> **Documentation Changes** --------- Signed-off-by: Tim Li <[email protected]>
1 parent 8f1e9e9 commit ffda2b1

File tree

9 files changed

+512
-0
lines changed

9 files changed

+512
-0
lines changed

.cursorrules

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Cursor Rules for Cadence Python Client
2+
3+
## Package Management
4+
- **Always use `uv` for Python package management**
5+
- Use `uv run` for running Python commands instead of `python` directly
6+
- Use `uv sync` for installing dependencies instead of `pip install`
7+
- Use `uv tool run` for running development tools (pytest, mypy, ruff, etc.)
8+
- Only use `pip` or `python` directly when specifically required by the tool or documentation
9+
10+
## Examples
11+
```bash
12+
# ✅ Correct
13+
uv run python scripts/generate_proto.py
14+
uv run python -m pytest tests/
15+
uv tool run mypy cadence/
16+
uv tool run ruff check
17+
18+
# ❌ Avoid
19+
python scripts/generate_proto.py
20+
pip install -e ".[dev]"
21+
```
22+
23+
## Virtual Environment
24+
- The project uses `uv` for virtual environment management
25+
- Always activate the virtual environment using `uv` commands
26+
- Dependencies are managed through `pyproject.toml` and `uv.lock`
27+
28+
## Testing
29+
- Run tests with `uv run python -m pytest`
30+
- Use `uv run` for any Python script execution
31+
- Development tools should be run with `uv tool run`
32+
33+
## Code Generation
34+
- Use `uv run python scripts/generate_proto.py` for protobuf generation
35+
- Use `uv run python scripts/dev.py` for development tasks
36+
37+
## Code Quality
38+
- **ALWAYS run linter and type checker after making code changes**
39+
- Run linter with auto-fix: `uv tool run ruff check --fix`
40+
- Run type checking: `uv tool run mypy cadence/`
41+
- Use `uv tool run ruff check --fix && uv tool run mypy cadence/` to run both together
42+
- **Standard workflow**: Make changes → Run linter → Run type checker → Commit
43+
44+
## Development Workflow
45+
1. Make code changes
46+
2. Run `uv tool run ruff check --fix` (fixes formatting and linting issues)
47+
3. Run `uv tool run mypy cadence/` (checks type safety)
48+
4. Run `uv run python -m pytest` (run tests)
49+
5. Commit changes

cadence/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from grpc.aio import Channel, ClientInterceptor, secure_channel, insecure_channel
1313
from cadence.api.v1.service_workflow_pb2_grpc import WorkflowAPIStub
1414
from cadence.data_converter import DataConverter, DefaultDataConverter
15+
from cadence.metrics import MetricsEmitter, NoOpMetricsEmitter
1516

1617

1718
class ClientOptions(TypedDict, total=False):
@@ -24,6 +25,7 @@ class ClientOptions(TypedDict, total=False):
2425
channel_arguments: dict[str, Any]
2526
credentials: ChannelCredentials | None
2627
compression: Compression
28+
metrics_emitter: MetricsEmitter
2729
interceptors: list[ClientInterceptor]
2830

2931
_DEFAULT_OPTIONS: ClientOptions = {
@@ -34,6 +36,7 @@ class ClientOptions(TypedDict, total=False):
3436
"channel_arguments": {},
3537
"credentials": None,
3638
"compression": Compression.NoCompression,
39+
"metrics_emitter": NoOpMetricsEmitter(),
3740
"interceptors": [],
3841
}
3942

@@ -69,6 +72,10 @@ def worker_stub(self) -> WorkerAPIStub:
6972
def workflow_stub(self) -> WorkflowAPIStub:
7073
return self._workflow_stub
7174

75+
@property
76+
def metrics_emitter(self) -> MetricsEmitter:
77+
return self._options["metrics_emitter"]
78+
7279
async def ready(self) -> None:
7380
await self._channel.channel_ready()
7481

cadence/metrics/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Metrics collection components for Cadence client."""
2+
3+
from .metrics import MetricsEmitter, NoOpMetricsEmitter, MetricType
4+
from .prometheus import PrometheusMetrics, PrometheusConfig
5+
6+
__all__ = [
7+
"MetricsEmitter",
8+
"NoOpMetricsEmitter",
9+
"MetricType",
10+
"PrometheusMetrics",
11+
"PrometheusConfig",
12+
]

cadence/metrics/metrics.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Core metrics collection interface and registry for Cadence client."""
2+
3+
import logging
4+
from enum import Enum
5+
from typing import Dict, Optional, Protocol
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class MetricType(Enum):
11+
"""Types of metrics that can be collected."""
12+
13+
COUNTER = "counter"
14+
GAUGE = "gauge"
15+
HISTOGRAM = "histogram"
16+
17+
18+
class MetricsEmitter(Protocol):
19+
"""Protocol for metrics collection backends."""
20+
21+
def counter(
22+
self, key: str, n: int = 1, tags: Optional[Dict[str, str]] = None
23+
) -> None:
24+
"""Send a counter metric."""
25+
...
26+
27+
def gauge(
28+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
29+
) -> None:
30+
"""Send a gauge metric."""
31+
...
32+
33+
34+
def histogram(
35+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
36+
) -> None:
37+
"""Send a histogram metric."""
38+
...
39+
40+
41+
class NoOpMetricsEmitter:
42+
"""No-op metrics emitter that discards all metrics."""
43+
44+
def counter(
45+
self, key: str, n: int = 1, tags: Optional[Dict[str, str]] = None
46+
) -> None:
47+
pass
48+
49+
def gauge(
50+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
51+
) -> None:
52+
pass
53+
54+
55+
def histogram(
56+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
57+
) -> None:
58+
pass
59+
60+

cadence/metrics/prometheus.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Prometheus metrics integration for Cadence client."""
2+
3+
import logging
4+
from dataclasses import dataclass, field
5+
from typing import Dict, Optional
6+
7+
from prometheus_client import ( # type: ignore[import-not-found]
8+
REGISTRY,
9+
CollectorRegistry,
10+
Counter,
11+
Gauge,
12+
Histogram,
13+
generate_latest,
14+
)
15+
16+
from .metrics import MetricsEmitter
17+
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
@dataclass
23+
class PrometheusConfig:
24+
"""Configuration for Prometheus metrics."""
25+
26+
# Default labels to apply to all metrics
27+
default_labels: Dict[str, str] = field(default_factory=dict)
28+
29+
# Custom registry (if None, uses default global registry)
30+
registry: Optional[CollectorRegistry] = None
31+
32+
33+
class PrometheusMetrics(MetricsEmitter):
34+
"""Prometheus metrics collector implementation."""
35+
36+
def __init__(self, config: Optional[PrometheusConfig] = None):
37+
self.config = config or PrometheusConfig()
38+
self.registry = self.config.registry or REGISTRY
39+
40+
# Track created metrics to avoid duplicates
41+
self._counters: Dict[str, Counter] = {}
42+
self._gauges: Dict[str, Gauge] = {}
43+
self._histograms: Dict[str, Histogram] = {}
44+
45+
def _get_metric_name(self, name: str) -> str:
46+
"""Get the metric name."""
47+
return name
48+
49+
def _merge_labels(self, labels: Optional[Dict[str, str]]) -> Dict[str, str]:
50+
"""Merge provided labels with default labels."""
51+
merged = self.config.default_labels.copy()
52+
if labels:
53+
merged.update(labels)
54+
return merged
55+
56+
def _get_or_create_counter(
57+
self, name: str, labels: Optional[Dict[str, str]]
58+
) -> Counter:
59+
"""Get or create a Counter metric."""
60+
metric_name = self._get_metric_name(name)
61+
62+
if metric_name not in self._counters:
63+
label_names = list(self._merge_labels(labels).keys()) if labels else []
64+
self._counters[metric_name] = Counter(
65+
metric_name,
66+
f"Counter metric for {name}",
67+
labelnames=label_names,
68+
registry=self.registry,
69+
)
70+
logger.debug(f"Created counter metric: {metric_name}")
71+
72+
return self._counters[metric_name]
73+
74+
def _get_or_create_gauge(
75+
self, name: str, labels: Optional[Dict[str, str]]
76+
) -> Gauge:
77+
"""Get or create a Gauge metric."""
78+
metric_name = self._get_metric_name(name)
79+
80+
if metric_name not in self._gauges:
81+
label_names = list(self._merge_labels(labels).keys()) if labels else []
82+
self._gauges[metric_name] = Gauge(
83+
metric_name,
84+
f"Gauge metric for {name}",
85+
labelnames=label_names,
86+
registry=self.registry,
87+
)
88+
logger.debug(f"Created gauge metric: {metric_name}")
89+
90+
return self._gauges[metric_name]
91+
92+
def _get_or_create_histogram(
93+
self, name: str, labels: Optional[Dict[str, str]]
94+
) -> Histogram:
95+
"""Get or create a Histogram metric."""
96+
metric_name = self._get_metric_name(name)
97+
98+
if metric_name not in self._histograms:
99+
label_names = list(self._merge_labels(labels).keys()) if labels else []
100+
self._histograms[metric_name] = Histogram(
101+
metric_name,
102+
f"Histogram metric for {name}",
103+
labelnames=label_names,
104+
registry=self.registry,
105+
)
106+
logger.debug(f"Created histogram metric: {metric_name}")
107+
108+
return self._histograms[metric_name]
109+
110+
111+
def counter(
112+
self, key: str, n: int = 1, tags: Optional[Dict[str, str]] = None
113+
) -> None:
114+
"""Send a counter metric."""
115+
try:
116+
counter = self._get_or_create_counter(key, tags)
117+
merged_tags = self._merge_labels(tags)
118+
119+
if merged_tags:
120+
counter.labels(**merged_tags).inc(n)
121+
else:
122+
counter.inc(n)
123+
124+
except Exception as e:
125+
logger.error(f"Failed to send counter {key}: {e}")
126+
127+
def gauge(
128+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
129+
) -> None:
130+
"""Send a gauge metric."""
131+
try:
132+
gauge = self._get_or_create_gauge(key, tags)
133+
merged_tags = self._merge_labels(tags)
134+
135+
if merged_tags:
136+
gauge.labels(**merged_tags).set(value)
137+
else:
138+
gauge.set(value)
139+
140+
except Exception as e:
141+
logger.error(f"Failed to send gauge {key}: {e}")
142+
143+
144+
def histogram(
145+
self, key: str, value: float, tags: Optional[Dict[str, str]] = None
146+
) -> None:
147+
"""Send a histogram metric."""
148+
try:
149+
histogram = self._get_or_create_histogram(key, tags)
150+
merged_tags = self._merge_labels(tags)
151+
152+
if merged_tags:
153+
histogram.labels(**merged_tags).observe(value)
154+
else:
155+
histogram.observe(value)
156+
157+
except Exception as e:
158+
logger.error(f"Failed to send histogram {key}: {e}")
159+
160+
def get_metrics_text(self) -> str:
161+
"""Get metrics in Prometheus text format."""
162+
try:
163+
metrics_bytes = generate_latest(self.registry)
164+
return metrics_bytes.decode("utf-8") # type: ignore[no-any-return]
165+
except Exception as e:
166+
logger.error(f"Failed to generate metrics text: {e}")
167+
return ""

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"msgspec>=0.19.0",
3232
"protobuf==5.29.1",
3333
"typing-extensions>=4.0.0",
34+
"prometheus-client>=0.21.0",
3435
]
3536

3637
[project.optional-dependencies]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for metrics collection functionality."""
2+
3+
from unittest.mock import Mock
4+
5+
6+
from cadence.metrics import (
7+
MetricsEmitter,
8+
MetricType,
9+
NoOpMetricsEmitter,
10+
)
11+
12+
13+
class TestMetricsEmitter:
14+
"""Test cases for MetricsEmitter protocol."""
15+
16+
def test_noop_emitter(self):
17+
"""Test no-op emitter doesn't raise exceptions."""
18+
emitter = NoOpMetricsEmitter()
19+
20+
# Should not raise any exceptions
21+
emitter.counter("test_counter", 1)
22+
emitter.gauge("test_gauge", 42.0)
23+
emitter.histogram("test_histogram", 0.5)
24+
25+
def test_mock_emitter(self):
26+
"""Test mock emitter implementation."""
27+
mock_emitter = Mock(spec=MetricsEmitter)
28+
29+
# Test counter
30+
mock_emitter.counter("test_counter", 2, {"label": "value"})
31+
mock_emitter.counter.assert_called_once_with(
32+
"test_counter", 2, {"label": "value"}
33+
)
34+
35+
# Test gauge
36+
mock_emitter.gauge("test_gauge", 100.0, {"env": "test"})
37+
mock_emitter.gauge.assert_called_once_with(
38+
"test_gauge", 100.0, {"env": "test"}
39+
)
40+
41+
42+
# Test histogram
43+
mock_emitter.histogram("test_histogram", 2.5, {"env": "prod"})
44+
mock_emitter.histogram.assert_called_once_with(
45+
"test_histogram", 2.5, {"env": "prod"}
46+
)
47+
48+
49+
50+
51+
class TestMetricType:
52+
"""Test cases for MetricType enum."""
53+
54+
def test_metric_type_values(self):
55+
"""Test that MetricType enum has correct values."""
56+
assert MetricType.COUNTER.value == "counter"
57+
assert MetricType.GAUGE.value == "gauge"
58+
assert MetricType.HISTOGRAM.value == "histogram"

0 commit comments

Comments
 (0)