Skip to content

Commit 08e1725

Browse files
determine metrics dir early
1 parent 8eaaed2 commit 08e1725

8 files changed

Lines changed: 114 additions & 64 deletions

File tree

src/conductor/client/automator/task_handler.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,9 @@ def __init__(
232232
self._configuration = configuration
233233
self._metrics_settings = metrics_settings
234234

235-
# Resolve the metrics subdirectory and set PROMETHEUS_MULTIPROC_DIR
236-
# BEFORE any worker processes start. resolve_metrics_type is
237-
# idempotent so it's safe if the caller already called it.
235+
# Set PROMETHEUS_MULTIPROC_DIR BEFORE any worker processes start.
236+
# MetricsSettings resolves the subdirectory eagerly at construction.
238237
if metrics_settings is not None:
239-
from conductor.client.telemetry.metrics_factory import resolve_metrics_type
240-
resolve_metrics_type(metrics_settings)
241238
os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.metrics_directory
242239
logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.metrics_directory}")
243240

src/conductor/client/configuration/settings/metrics_settings.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
)
1515

1616

17+
CANONICAL_SUBDIR = "canonical"
18+
19+
20+
def _env_bool(name: str, default: bool) -> bool:
21+
value = os.environ.get(name, "")
22+
if not value:
23+
return default
24+
return value.strip().lower() in ("true", "1", "yes")
25+
26+
1727
def get_default_temporary_folder() -> str:
1828
return f"{Path.home()!s}/tmp/"
1929

@@ -30,6 +40,11 @@ def __init__(
3040
"""
3141
Configure metrics collection settings.
3242
43+
The ``WORKER_CANONICAL_METRICS`` env var is read at construction time
44+
to decide whether ``.db`` files go in *directory* (legacy) or in a
45+
``canonical/`` subdirectory (canonical mode). Set the env var before
46+
creating this object.
47+
3348
Args:
3449
directory: Base directory for storing multiprocess metrics .db files.
3550
Legacy metrics use this directory directly (unchanged from
@@ -62,16 +77,24 @@ def __init__(
6277
self.http_port = http_port
6378
self.clean_directory = clean_directory
6479
self.clean_dead_pids = clean_dead_pids
65-
self._subdir: Optional[str] = None
80+
self._subdir: str = (
81+
CANONICAL_SUBDIR
82+
if _env_bool("WORKER_CANONICAL_METRICS", False)
83+
else ""
84+
)
85+
86+
@property
87+
def is_canonical(self) -> bool:
88+
return self._subdir == CANONICAL_SUBDIR
6689

6790
@property
6891
def metrics_directory(self) -> str:
6992
"""Full path where .db files live (base directory + optional subdir).
7093
71-
The subdirectory is set by ``metrics_factory.resolve_metrics_type``
72-
before any child processes are forked. Legacy leaves it empty (base
73-
directory unchanged from prior releases); canonical sets it to
74-
``"canonical"`` to avoid stale metric-name collisions.
94+
Legacy leaves the subdirectory empty (base directory unchanged from
95+
prior releases); canonical sets it to ``"canonical"`` to avoid stale
96+
metric-name collisions. Resolved eagerly at construction time from
97+
the ``WORKER_CANONICAL_METRICS`` env var.
7598
"""
7699
if self._subdir:
77100
return os.path.join(self.directory, self._subdir)

src/conductor/client/telemetry/canonical_metrics_collector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
WORKER_CANONICAL_METRICS=true.
1212
"""
1313

14+
from typing import Optional
15+
1416
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
1517
from conductor.client.telemetry.metrics_collector_base import (
1618
MetricsCollectorBase,
@@ -193,7 +195,7 @@ def record_task_update_time(self, task_type: str, time_spent: float, status: str
193195
)
194196

195197
def record_api_request_time(self, method: str, uri: str, status: str, time_spent: float,
196-
metric_uri: str = None) -> None:
198+
metric_uri: Optional[str] = None) -> None:
197199
resolved_uri = (metric_uri or uri).replace("{tasktype}", "{taskType}")
198200
self._observe_histogram(
199201
name=MetricName.API_REQUEST_TIME_CANONICAL,

src/conductor/client/telemetry/legacy_metrics_collector.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88

99
from collections import deque
10-
from typing import Any, Dict, List
10+
from typing import Any, Dict, List, Optional
1111

1212
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
1313
from conductor.client.telemetry import metrics_collector_base as _mcb
@@ -176,7 +176,7 @@ def record_task_update_time(self, task_type: str, time_spent: float, status: str
176176
pass # canonical-only; call sites were added in this branch and didn't exist on main
177177

178178
def record_api_request_time(self, method: str, uri: str, status: str, time_spent: float,
179-
metric_uri: str = None) -> None:
179+
metric_uri: Optional[str] = None) -> None:
180180
self._record_quantiles(
181181
name=MetricName.API_REQUEST_TIME,
182182
documentation=MetricDocumentation.API_REQUEST_TIME,

src/conductor/client/telemetry/metrics_collector_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import signal
66
import threading
77
import time
8-
from typing import Any, Dict
8+
from typing import Any, Dict, Optional
99

1010
# Lazy imports - these will be imported when first needed.
1111
# PROMETHEUS_MULTIPROC_DIR must be set before prometheus_client is imported.
@@ -419,7 +419,7 @@ def record_task_update_time(self, task_type: str, time_spent: float, status: str
419419

420420
@abc.abstractmethod
421421
def record_api_request_time(self, method: str, uri: str, status: str, time_spent: float,
422-
metric_uri: str = None) -> None: ...
422+
metric_uri: Optional[str] = None) -> None: ...
423423

424424
@abc.abstractmethod
425425
def record_task_result_payload_size(self, task_type: str, payload_size: int) -> None: ...

src/conductor/client/telemetry/metrics_factory.py

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,6 @@
2222
)
2323

2424

25-
_CANONICAL_SUBDIR = "canonical"
26-
27-
28-
def _env_bool(name: str, default: bool) -> bool:
29-
value = os.environ.get(name, "")
30-
if not value:
31-
return default
32-
return value.strip().lower() in ("true", "1", "yes")
33-
34-
3525
_cleaned_directories: set = set()
3626

3727

@@ -42,35 +32,16 @@ def _reset_cleaned_directories() -> None:
4232
_cleaned_directories.clear()
4333

4434

45-
def resolve_metrics_type(settings: MetricsSettings) -> None:
46-
"""Read ``WORKER_CANONICAL_METRICS`` once and store the result on *settings*.
47-
48-
Idempotent -- subsequent calls are no-ops. Must be called before
49-
``settings.metrics_directory`` is read so that the subdirectory is
50-
resolved in the main process before any child processes are forked.
51-
52-
Both ``TaskHandler.__init__`` and ``create_metrics_collector`` call this.
53-
"""
54-
if settings._subdir is not None:
55-
return
56-
if _env_bool("WORKER_CANONICAL_METRICS", default=False):
57-
settings._subdir = _CANONICAL_SUBDIR
58-
else:
59-
settings._subdir = ""
60-
61-
6235
def create_metrics_collector(settings: MetricsSettings) -> MetricsCollectorBase:
6336
"""
64-
Create the metrics collector selected by environment variables.
37+
Create the metrics collector indicated by ``settings.is_canonical``.
6538
66-
Calls ``resolve_metrics_type`` to ensure ``settings.metrics_directory``
67-
returns the correct path, then instantiates the appropriate collector.
39+
``MetricsSettings`` reads ``WORKER_CANONICAL_METRICS`` at construction
40+
time, so the directory and collector type are already determined.
6841
6942
Returns a fully-initialised collector (legacy or canonical) that satisfies
7043
the MetricsCollector Protocol and can be registered as an event listener.
7144
"""
72-
resolve_metrics_type(settings)
73-
7445
metrics_dir = settings.metrics_directory
7546
os.makedirs(metrics_dir, exist_ok=True)
7647

@@ -81,7 +52,7 @@ def create_metrics_collector(settings: MetricsSettings) -> MetricsCollectorBase:
8152
if settings.clean_dead_pids:
8253
settings._clean_dead_pid_files()
8354

84-
if settings._subdir == _CANONICAL_SUBDIR:
55+
if settings.is_canonical:
8556
from conductor.client.telemetry.canonical_metrics_collector import CanonicalMetricsCollector
8657
logger.info("WORKER_CANONICAL_METRICS is true — using CanonicalMetricsCollector (dir=%s)", metrics_dir)
8758
return CanonicalMetricsCollector(settings)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import asyncio
2+
import unittest
3+
from unittest.mock import Mock, AsyncMock
4+
5+
from conductor.client.http.async_api_client import AsyncApiClient
6+
from conductor.client.configuration.configuration import Configuration
7+
8+
9+
class TestAsyncApiClientMetricUri(unittest.TestCase):
10+
11+
def setUp(self):
12+
self.config = Configuration(
13+
base_url="http://localhost:8080",
14+
authentication_settings=None,
15+
)
16+
17+
def _run(self, coro):
18+
loop = asyncio.new_event_loop()
19+
try:
20+
return loop.run_until_complete(coro)
21+
finally:
22+
loop.close()
23+
24+
def test_request_passes_metric_uri_to_collector(self):
25+
"""metric_uri kwarg is forwarded to record_api_request_time on success."""
26+
metrics_collector = Mock()
27+
client = AsyncApiClient(configuration=self.config, metrics_collector=metrics_collector)
28+
client.async_rest_client.GET = AsyncMock(return_value=Mock(status=200))
29+
30+
self._run(client.request(
31+
'GET', 'http://localhost:8080/api/workflow/test-id',
32+
metric_uri='/api/workflow/{workflowId}',
33+
))
34+
35+
call_args = metrics_collector.record_api_request_time.call_args
36+
self.assertEqual(call_args[1]['metric_uri'], '/api/workflow/{workflowId}')
37+
38+
def test_request_passes_metric_uri_to_collector_on_error(self):
39+
"""metric_uri kwarg is forwarded to record_api_request_time on error."""
40+
metrics_collector = Mock()
41+
client = AsyncApiClient(configuration=self.config, metrics_collector=metrics_collector)
42+
43+
error = Exception('Test error')
44+
error.status = 500
45+
client.async_rest_client.GET = AsyncMock(side_effect=error)
46+
47+
with self.assertRaises(Exception):
48+
self._run(client.request(
49+
'GET', 'http://localhost:8080/api/workflow/test-id',
50+
metric_uri='/api/workflow/{workflowId}',
51+
))
52+
53+
call_args = metrics_collector.record_api_request_time.call_args
54+
self.assertEqual(call_args[1]['metric_uri'], '/api/workflow/{workflowId}')
55+
56+
57+
if __name__ == '__main__':
58+
unittest.main()

tests/unit/telemetry/test_metrics_factory.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,10 @@ class TestMetricsFactory(unittest.TestCase):
2121

2222
def setUp(self):
2323
_reset_cleaned_directories()
24-
self.metrics_dir = tempfile.mkdtemp()
25-
self.settings = MetricsSettings(directory=self.metrics_dir)
2624
self._saved_env = {}
2725
for key in ("WORKER_CANONICAL_METRICS", "WORKER_LEGACY_METRICS"):
2826
self._saved_env[key] = os.environ.pop(key, None)
27+
self.metrics_dir = tempfile.mkdtemp()
2928

3029
def tearDown(self):
3130
for key, val in self._saved_env.items():
@@ -37,62 +36,62 @@ def tearDown(self):
3736
shutil.rmtree(self.metrics_dir)
3837
_reset_cleaned_directories()
3938

39+
def _make_settings(self, **kwargs):
40+
kwargs.setdefault("directory", self.metrics_dir)
41+
return MetricsSettings(**kwargs)
42+
4043
def test_default_returns_legacy(self):
4144
"""With no env vars set, factory returns LegacyMetricsCollector."""
42-
collector = create_metrics_collector(self.settings)
45+
collector = create_metrics_collector(self._make_settings())
4346
self.assertIsInstance(collector, LegacyMetricsCollector)
4447

4548
def test_canonical_true_returns_canonical(self):
4649
"""WORKER_CANONICAL_METRICS=true selects CanonicalMetricsCollector."""
4750
os.environ["WORKER_CANONICAL_METRICS"] = "true"
48-
collector = create_metrics_collector(self.settings)
51+
collector = create_metrics_collector(self._make_settings())
4952
self.assertIsInstance(collector, CanonicalMetricsCollector)
5053

5154
def test_canonical_1_returns_canonical(self):
5255
"""WORKER_CANONICAL_METRICS=1 selects CanonicalMetricsCollector."""
5356
os.environ["WORKER_CANONICAL_METRICS"] = "1"
54-
collector = create_metrics_collector(self.settings)
57+
collector = create_metrics_collector(self._make_settings())
5558
self.assertIsInstance(collector, CanonicalMetricsCollector)
5659

5760
def test_canonical_yes_returns_canonical(self):
5861
"""WORKER_CANONICAL_METRICS=yes selects CanonicalMetricsCollector."""
5962
os.environ["WORKER_CANONICAL_METRICS"] = "yes"
60-
collector = create_metrics_collector(self.settings)
63+
collector = create_metrics_collector(self._make_settings())
6164
self.assertIsInstance(collector, CanonicalMetricsCollector)
6265

6366
def test_canonical_false_returns_legacy(self):
6467
"""WORKER_CANONICAL_METRICS=false selects LegacyMetricsCollector."""
6568
os.environ["WORKER_CANONICAL_METRICS"] = "false"
66-
collector = create_metrics_collector(self.settings)
69+
collector = create_metrics_collector(self._make_settings())
6770
self.assertIsInstance(collector, LegacyMetricsCollector)
6871

6972
def test_canonical_takes_priority_over_legacy(self):
7073
"""WORKER_CANONICAL_METRICS=true wins even if WORKER_LEGACY_METRICS=true."""
7174
os.environ["WORKER_CANONICAL_METRICS"] = "true"
7275
os.environ["WORKER_LEGACY_METRICS"] = "true"
73-
collector = create_metrics_collector(self.settings)
76+
collector = create_metrics_collector(self._make_settings())
7477
self.assertIsInstance(collector, CanonicalMetricsCollector)
7578

7679
def test_legacy_collector_name(self):
7780
"""LegacyMetricsCollector.collector_name() returns 'legacy'."""
78-
collector = create_metrics_collector(self.settings)
81+
collector = create_metrics_collector(self._make_settings())
7982
self.assertEqual(collector.collector_name(), "legacy")
8083

8184
def test_canonical_collector_name(self):
8285
"""CanonicalMetricsCollector.collector_name() returns 'canonical'."""
8386
os.environ["WORKER_CANONICAL_METRICS"] = "true"
84-
collector = create_metrics_collector(
85-
MetricsSettings(directory=tempfile.mkdtemp())
86-
)
87+
collector = create_metrics_collector(self._make_settings())
8788
self.assertEqual(collector.collector_name(), "canonical")
8889

8990
def test_both_implementations_satisfy_same_interface(self):
9091
"""Both implementations have the same public method surface."""
91-
legacy = LegacyMetricsCollector(self.settings)
92+
legacy = LegacyMetricsCollector(self._make_settings())
9293
os.environ["WORKER_CANONICAL_METRICS"] = "true"
93-
canonical = create_metrics_collector(
94-
MetricsSettings(directory=tempfile.mkdtemp())
95-
)
94+
canonical = create_metrics_collector(self._make_settings())
9695

9796
required_methods = [
9897
"collector_name",

0 commit comments

Comments
 (0)