conductor-python 1.3.11__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  from conductor.client.configuration.configuration import Configuration
4
4
  from conductor.client.automator.task_handler import TaskHandler
5
5
  from conductor.client.automator.task_runner import TaskRunner
6
- from conductor.client.orkes_clients import OrkesClients
6
+ from conductor.client.orkes_clients import OrkesClients, ConductorClients
7
7
  from conductor.client.workflow.conductor_workflow import ConductorWorkflow
8
8
  from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
9
9
  from conductor.client.worker.worker_task import worker_task
@@ -18,6 +18,7 @@ __all__ = [
18
18
  "TaskHandler",
19
19
  "TaskRunner",
20
20
  "OrkesClients",
21
+ "ConductorClients",
21
22
  "ConductorWorkflow",
22
23
  "WorkflowExecutor",
23
24
  "worker_task",
@@ -27,7 +27,7 @@ from conductor.client.http.models.schema_def import SchemaDef, SchemaType
27
27
  from conductor.client.http.rest import AuthorizationException, ApiException
28
28
  from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
29
29
  from conductor.client.orkes.orkes_schema_client import OrkesSchemaClient
30
- from conductor.client.telemetry.metrics_collector import MetricsCollector
30
+ from conductor.client.telemetry.metrics_factory import create_metrics_collector
31
31
  from conductor.client.worker.worker_interface import WorkerInterface
32
32
  from conductor.client.worker.worker_config import resolve_worker_config, get_worker_config_oneline
33
33
  from conductor.client.worker.exception import NonRetryableException
@@ -88,7 +88,7 @@ class AsyncTaskRunner:
88
88
 
89
89
  self.metrics_collector = None
90
90
  if metrics_settings is not None:
91
- self.metrics_collector = MetricsCollector(
91
+ self.metrics_collector = create_metrics_collector(
92
92
  metrics_settings
93
93
  )
94
94
  # Register metrics collector as event listener
@@ -863,6 +863,7 @@ class AsyncTaskRunner:
863
863
  if attempt > 0:
864
864
  # Exponential backoff: [10s, 20s, 30s] before retry
865
865
  await asyncio.sleep(attempt * 10)
866
+ update_start = time.time()
866
867
  try:
867
868
  if self._use_update_v2:
868
869
  next_task = await self.async_task_client.update_task_v2(body=task_result)
@@ -873,6 +874,10 @@ class AsyncTaskRunner:
873
874
  task_definition_name,
874
875
  next_task.task_id if next_task else None
875
876
  )
877
+ if self.metrics_collector is not None:
878
+ self.metrics_collector.record_task_update_time(
879
+ task_definition_name, time.time() - update_start, status="SUCCESS"
880
+ )
876
881
  return next_task
877
882
  else:
878
883
  await self.async_task_client.update_task(body=task_result)
@@ -882,6 +887,10 @@ class AsyncTaskRunner:
882
887
  task_result.workflow_instance_id,
883
888
  task_definition_name,
884
889
  )
890
+ if self.metrics_collector is not None:
891
+ self.metrics_collector.record_task_update_time(
892
+ task_definition_name, time.time() - update_start, status="SUCCESS"
893
+ )
885
894
  return None
886
895
  except ApiException as e:
887
896
  if e.status in (404, 405) and self._use_update_v2:
@@ -895,12 +904,19 @@ class AsyncTaskRunner:
895
904
  # Retry immediately with v1
896
905
  try:
897
906
  await self.async_task_client.update_task(body=task_result)
907
+ if self.metrics_collector is not None:
908
+ self.metrics_collector.record_task_update_time(
909
+ task_definition_name, time.time() - update_start, status="SUCCESS"
910
+ )
898
911
  return None
899
912
  except Exception as fallback_e:
900
913
  last_exception = fallback_e
901
914
  continue
902
915
  last_exception = e
903
916
  if self.metrics_collector is not None:
917
+ self.metrics_collector.record_task_update_time(
918
+ task_definition_name, time.time() - update_start, status="FAILURE"
919
+ )
904
920
  self.metrics_collector.increment_task_update_error(
905
921
  task_definition_name, type(e)
906
922
  )
@@ -917,6 +933,9 @@ class AsyncTaskRunner:
917
933
  except Exception as e:
918
934
  last_exception = e
919
935
  if self.metrics_collector is not None:
936
+ self.metrics_collector.record_task_update_time(
937
+ task_definition_name, time.time() - update_start, status="FAILURE"
938
+ )
920
939
  self.metrics_collector.increment_task_update_error(
921
940
  task_definition_name, type(e)
922
941
  )
@@ -18,7 +18,7 @@ from conductor.client.configuration.settings.metrics_settings import MetricsSett
18
18
  from conductor.client.event.task_runner_events import TaskRunnerEvent
19
19
  from conductor.client.event.sync_event_dispatcher import SyncEventDispatcher
20
20
  from conductor.client.event.sync_listener_register import register_task_runner_listener
21
- from conductor.client.telemetry.metrics_collector import MetricsCollector
21
+ from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
22
22
  from conductor.client.telemetry.model.metric_documentation import MetricDocumentation
23
23
  from conductor.client.telemetry.model.metric_label import MetricLabel
24
24
  from conductor.client.telemetry.model.metric_name import MetricName
@@ -33,13 +33,24 @@ logger = logging.getLogger(
33
33
  )
34
34
 
35
35
  _decorated_functions = {}
36
+ _VALID_MP_START_METHODS = {"spawn", "fork", "forkserver"}
36
37
  _mp_fork_set = False
37
38
  if not _mp_fork_set:
38
39
  try:
39
- if platform == "win32":
40
- set_start_method("spawn")
41
- else:
42
- set_start_method("fork")
40
+ # The prometheus_client library holds a module-level threading lock;
41
+ # forking while that lock is held causes a deadlock in child processes.
42
+ # Set CONDUCTOR_MP_START_METHOD=spawn to avoid this if you hit the
43
+ # deadlock. Default is fork for backward compatibility (spawn requires
44
+ # all Process arguments to be picklable).
45
+ _default_method = "spawn" if platform == "win32" else "fork"
46
+ _method = os.environ.get("CONDUCTOR_MP_START_METHOD", "").strip().lower() or _default_method
47
+ if _method not in _VALID_MP_START_METHODS:
48
+ logger.warning(
49
+ "Ignoring invalid CONDUCTOR_MP_START_METHOD=%r; falling back to %r",
50
+ _method, _default_method,
51
+ )
52
+ _method = _default_method
53
+ set_start_method(_method)
43
54
  _mp_fork_set = True
44
55
  except Exception as e:
45
56
  logger.info("error when setting multiprocessing.set_start_method - maybe the context is set %s", e.args)
@@ -221,11 +232,11 @@ class TaskHandler:
221
232
  self._configuration = configuration
222
233
  self._metrics_settings = metrics_settings
223
234
 
224
- # Set prometheus multiprocess directory BEFORE any worker processes start
225
- # This must be done before prometheus_client is imported in worker processes
235
+ # Set PROMETHEUS_MULTIPROC_DIR BEFORE any worker processes start.
236
+ # MetricsSettings resolves the subdirectory eagerly at construction.
226
237
  if metrics_settings is not None:
227
- os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.directory
228
- logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.directory}")
238
+ os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.metrics_directory
239
+ logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.metrics_directory}")
229
240
 
230
241
  # Store event listeners to pass to each worker process
231
242
  self.event_listeners = event_listeners or []
@@ -345,7 +356,7 @@ class TaskHandler:
345
356
  self.metrics_provider_process = None
346
357
  return
347
358
  self.metrics_provider_process = Process(
348
- target=MetricsCollector.provide_metrics,
359
+ target=MetricsCollectorBase.provide_metrics,
349
360
  args=(metrics_settings,)
350
361
  )
351
362
  logger.info("Created MetricsProvider process")
@@ -30,7 +30,7 @@ from conductor.client.http.models.schema_def import SchemaDef, SchemaType
30
30
  from conductor.client.http.rest import AuthorizationException, ApiException
31
31
  from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
32
32
  from conductor.client.orkes.orkes_schema_client import OrkesSchemaClient
33
- from conductor.client.telemetry.metrics_collector import MetricsCollector
33
+ from conductor.client.telemetry.metrics_factory import create_metrics_collector
34
34
  from conductor.client.worker.worker import ASYNC_TASK_RUNNING
35
35
  from conductor.client.worker.worker_interface import WorkerInterface
36
36
  from conductor.client.worker.worker_config import resolve_worker_config, get_worker_config_oneline
@@ -69,7 +69,7 @@ class TaskRunner:
69
69
 
70
70
  self.metrics_collector = None
71
71
  if metrics_settings is not None:
72
- self.metrics_collector = MetricsCollector(
72
+ self.metrics_collector = create_metrics_collector(
73
73
  metrics_settings
74
74
  )
75
75
  # Register metrics collector as event listener
@@ -972,6 +972,7 @@ class TaskRunner:
972
972
  if attempt > 0:
973
973
  # Exponential backoff: [10s, 20s, 30s] before retry
974
974
  time.sleep(attempt * 10)
975
+ update_start = time.time()
975
976
  try:
976
977
  if self._use_update_v2:
977
978
  next_task = self.task_client.update_task_v2(body=task_result)
@@ -982,6 +983,10 @@ class TaskRunner:
982
983
  task_definition_name,
983
984
  next_task.task_id if next_task else None
984
985
  )
986
+ if self.metrics_collector is not None:
987
+ self.metrics_collector.record_task_update_time(
988
+ task_definition_name, time.time() - update_start, status="SUCCESS"
989
+ )
985
990
  return next_task
986
991
  else:
987
992
  self.task_client.update_task(body=task_result)
@@ -991,6 +996,10 @@ class TaskRunner:
991
996
  task_result.workflow_instance_id,
992
997
  task_definition_name,
993
998
  )
999
+ if self.metrics_collector is not None:
1000
+ self.metrics_collector.record_task_update_time(
1001
+ task_definition_name, time.time() - update_start, status="SUCCESS"
1002
+ )
994
1003
  return None
995
1004
  except ApiException as e:
996
1005
  if e.status in (404, 405) and self._use_update_v2:
@@ -1004,12 +1013,19 @@ class TaskRunner:
1004
1013
  # Retry immediately with v1
1005
1014
  try:
1006
1015
  self.task_client.update_task(body=task_result)
1016
+ if self.metrics_collector is not None:
1017
+ self.metrics_collector.record_task_update_time(
1018
+ task_definition_name, time.time() - update_start, status="SUCCESS"
1019
+ )
1007
1020
  return None
1008
1021
  except Exception as fallback_e:
1009
1022
  last_exception = fallback_e
1010
1023
  continue
1011
1024
  last_exception = e
1012
1025
  if self.metrics_collector is not None:
1026
+ self.metrics_collector.record_task_update_time(
1027
+ task_definition_name, time.time() - update_start, status="FAILURE"
1028
+ )
1013
1029
  self.metrics_collector.increment_task_update_error(
1014
1030
  task_definition_name, type(e)
1015
1031
  )
@@ -1044,6 +1060,9 @@ class TaskRunner:
1044
1060
  except Exception as e:
1045
1061
  last_exception = e
1046
1062
  if self.metrics_collector is not None:
1063
+ self.metrics_collector.record_task_update_time(
1064
+ task_definition_name, time.time() - update_start, status="FAILURE"
1065
+ )
1047
1066
  self.metrics_collector.increment_task_update_error(
1048
1067
  task_definition_name, type(e)
1049
1068
  )
@@ -14,6 +14,16 @@ logger = logging.getLogger(
14
14
  )
15
15
 
16
16
 
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
+
17
27
  def get_default_temporary_folder() -> str:
18
28
  return f"{Path.home()!s}/tmp/"
19
29
 
@@ -24,12 +34,23 @@ class MetricsSettings:
24
34
  directory: Optional[str] = None,
25
35
  file_name: str = "metrics.log",
26
36
  update_interval: float = 0.1,
27
- http_port: Optional[int] = None):
37
+ http_port: Optional[int] = None,
38
+ clean_directory: bool = False,
39
+ clean_dead_pids: bool = False):
28
40
  """
29
41
  Configure metrics collection settings.
30
42
 
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
+
31
48
  Args:
32
- directory: Directory for storing multiprocess metrics .db files
49
+ directory: Base directory for storing multiprocess metrics .db files.
50
+ Legacy metrics use this directory directly (unchanged from
51
+ prior releases). Canonical metrics use a ``canonical/``
52
+ subdirectory so that switching implementations never
53
+ produces stale metric names.
33
54
  file_name: Name of the metrics output file (only used when http_port is None)
34
55
  update_interval: How often to update metrics (in seconds)
35
56
  http_port: Optional HTTP port to expose metrics endpoint for Prometheus scraping.
@@ -40,6 +61,13 @@ class MetricsSettings:
40
61
  If None:
41
62
  - Metrics will be written to file at {directory}/{file_name}
42
63
  - No HTTP server will be started
64
+ clean_directory: If True, remove all prometheus_client .db files from
65
+ the metrics directory when the collector is created. Only
66
+ safe when no other live process shares the same directory.
67
+ Defaults to False.
68
+ clean_dead_pids: If True, remove .db files whose owning PID no
69
+ longer exists. Safer than ``clean_directory`` in shared
70
+ environments. Defaults to False.
43
71
  """
44
72
  if directory is None:
45
73
  directory = get_default_temporary_folder()
@@ -47,6 +75,30 @@ class MetricsSettings:
47
75
  self.file_name = file_name
48
76
  self.update_interval = update_interval
49
77
  self.http_port = http_port
78
+ self.clean_directory = clean_directory
79
+ self.clean_dead_pids = clean_dead_pids
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
89
+
90
+ @property
91
+ def metrics_directory(self) -> str:
92
+ """Full path where .db files live (base directory + optional subdir).
93
+
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.
98
+ """
99
+ if self._subdir:
100
+ return os.path.join(self.directory, self._subdir)
101
+ return self.directory
50
102
 
51
103
  def __set_dir(self, dir: str) -> None:
52
104
  if not os.path.isdir(dir):
@@ -57,3 +109,43 @@ class MetricsSettings:
57
109
  "Failed to create metrics temporary folder, reason: %s", e)
58
110
 
59
111
  self.directory = dir
112
+
113
+ def _clean_stale_db_files(self) -> None:
114
+ """Remove all prometheus_client multiprocess .db files."""
115
+ import glob
116
+ pattern = os.path.join(self.metrics_directory, "*.db")
117
+ for path in glob.glob(pattern):
118
+ try:
119
+ os.remove(path)
120
+ except Exception as e:
121
+ logger.debug("Could not remove stale metrics db file %s: %s", path, e)
122
+
123
+ def _clean_dead_pid_files(self) -> None:
124
+ """Remove .db files whose owning PID no longer exists."""
125
+ import glob
126
+ import re
127
+ pattern = os.path.join(self.metrics_directory, "*.db")
128
+ for path in glob.glob(pattern):
129
+ match = re.search(r'_(\d+)\.db$', os.path.basename(path))
130
+ if not match:
131
+ continue
132
+ pid = int(match.group(1))
133
+ try:
134
+ os.kill(pid, 0)
135
+ except ProcessLookupError:
136
+ # ESRCH: no process owns this PID -> safe to remove its db file.
137
+ try:
138
+ os.remove(path)
139
+ logger.debug("Removed dead-pid metrics file %s (pid %d)", path, pid)
140
+ except OSError as e:
141
+ logger.debug("Could not remove dead-pid metrics db file %s: %s", path, e)
142
+ except OSError as e:
143
+ # Any non-ESRCH probe failure (commonly EPERM: the process is
144
+ # alive but owned by another user) -> keep the file; deleting it
145
+ # could corrupt a live worker's metrics in a shared directory.
146
+ logger.debug(
147
+ "Keeping metrics db file %s; pid %d probe returned a "
148
+ "non-ProcessLookupError OSError (process likely alive): %s",
149
+ path, pid, e,
150
+ )
151
+ continue
@@ -2329,6 +2329,7 @@ class WorkflowResourceApi(object):
2329
2329
  all_params.append('_return_http_data_only')
2330
2330
  all_params.append('_preload_content')
2331
2331
  all_params.append('_request_timeout')
2332
+ all_params.append('metric_context')
2332
2333
 
2333
2334
  params = locals()
2334
2335
  for key, val in six.iteritems(params['kwargs']):
@@ -2383,7 +2384,8 @@ class WorkflowResourceApi(object):
2383
2384
  _return_http_data_only=params.get('_return_http_data_only'),
2384
2385
  _preload_content=params.get('_preload_content', True),
2385
2386
  _request_timeout=params.get('_request_timeout'),
2386
- collection_formats=collection_formats)
2387
+ collection_formats=collection_formats,
2388
+ metric_context=params.get('metric_context'))
2387
2389
 
2388
2390
  def start_workflow1(self, body, name, **kwargs): # noqa: E501
2389
2391
  """Start a new workflow. Returns the ID of the workflow instance that can be later used for tracking # noqa: E501
@@ -2434,6 +2436,7 @@ class WorkflowResourceApi(object):
2434
2436
  all_params.append('_return_http_data_only')
2435
2437
  all_params.append('_preload_content')
2436
2438
  all_params.append('_request_timeout')
2439
+ all_params.append('metric_context')
2437
2440
 
2438
2441
  params = locals()
2439
2442
  for key, val in six.iteritems(params['kwargs']):
@@ -2500,7 +2503,8 @@ class WorkflowResourceApi(object):
2500
2503
  _return_http_data_only=params.get('_return_http_data_only'),
2501
2504
  _preload_content=params.get('_preload_content', True),
2502
2505
  _request_timeout=params.get('_request_timeout'),
2503
- collection_formats=collection_formats)
2506
+ collection_formats=collection_formats,
2507
+ metric_context=params.get('metric_context'))
2504
2508
 
2505
2509
  def terminate1(self, workflow_id, **kwargs): # noqa: E501
2506
2510
  """
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import base64
2
4
  import datetime
3
5
  import logging
@@ -6,7 +8,7 @@ import os
6
8
  import re
7
9
  import tempfile
8
10
  import time
9
- from typing import Dict
11
+ from typing import Dict, Optional, TYPE_CHECKING
10
12
  import uuid
11
13
 
12
14
  import six
@@ -20,13 +22,15 @@ from conductor.client.http import rest
20
22
  from conductor.client.http.rest import AuthorizationException
21
23
  from conductor.client.http.thread import AwaitableThread
22
24
 
25
+ if TYPE_CHECKING:
26
+ from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
27
+
23
28
  logger = logging.getLogger(
24
29
  Configuration.get_logging_formatted_name(
25
30
  __name__
26
31
  )
27
32
  )
28
33
 
29
-
30
34
  class ApiClient(object):
31
35
  PRIMITIVE_TYPES = (float, bool, bytes, six.text_type) + six.integer_types
32
36
  NATIVE_TYPES_MAPPING = {
@@ -46,7 +50,7 @@ class ApiClient(object):
46
50
  header_name=None,
47
51
  header_value=None,
48
52
  cookie=None,
49
- metrics_collector=None
53
+ metrics_collector: Optional[MetricsCollectorBase] = None
50
54
  ):
51
55
  if configuration is None:
52
56
  configuration = Configuration()
@@ -75,14 +79,15 @@ class ApiClient(object):
75
79
  query_params=None, header_params=None, body=None, post_params=None,
76
80
  files=None, response_type=None, auth_settings=None,
77
81
  _return_http_data_only=None, collection_formats=None,
78
- _preload_content=True, _request_timeout=None):
82
+ _preload_content=True, _request_timeout=None, metric_context=None):
79
83
  try:
80
84
  return self.__call_api_no_retry(
81
85
  resource_path=resource_path, method=method, path_params=path_params,
82
86
  query_params=query_params, header_params=header_params, body=body, post_params=post_params,
83
87
  files=files, response_type=response_type, auth_settings=auth_settings,
84
88
  _return_http_data_only=_return_http_data_only, collection_formats=collection_formats,
85
- _preload_content=_preload_content, _request_timeout=_request_timeout
89
+ _preload_content=_preload_content, _request_timeout=_request_timeout,
90
+ metric_context=metric_context
86
91
  )
87
92
  except AuthorizationException as ae:
88
93
  if ae.token_expired or ae.invalid_token:
@@ -99,7 +104,8 @@ class ApiClient(object):
99
104
  query_params=query_params, header_params=header_params, body=body, post_params=post_params,
100
105
  files=files, response_type=response_type, auth_settings=auth_settings,
101
106
  _return_http_data_only=_return_http_data_only, collection_formats=collection_formats,
102
- _preload_content=_preload_content, _request_timeout=_request_timeout
107
+ _preload_content=_preload_content, _request_timeout=_request_timeout,
108
+ metric_context=metric_context
103
109
  )
104
110
  else:
105
111
  logger.error('Failed to renew authentication token. Please check your credentials.')
@@ -110,7 +116,7 @@ class ApiClient(object):
110
116
  query_params=None, header_params=None, body=None, post_params=None,
111
117
  files=None, response_type=None, auth_settings=None,
112
118
  _return_http_data_only=None, collection_formats=None,
113
- _preload_content=True, _request_timeout=None):
119
+ _preload_content=True, _request_timeout=None, metric_context=None):
114
120
 
115
121
  config = self.configuration
116
122
 
@@ -124,6 +130,9 @@ class ApiClient(object):
124
130
  header_params = dict(self.parameters_to_tuples(header_params,
125
131
  collection_formats))
126
132
 
133
+ # Save the URI template before path param substitution for metrics
134
+ metric_uri = resource_path
135
+
127
136
  # path parameters
128
137
  if path_params:
129
138
  path_params = self.sanitize_for_serialization(path_params)
@@ -171,7 +180,9 @@ class ApiClient(object):
171
180
  method, url, query_params=query_params, headers=header_params,
172
181
  post_params=post_params, body=body,
173
182
  _preload_content=_preload_content,
174
- _request_timeout=_request_timeout)
183
+ _request_timeout=_request_timeout,
184
+ metric_uri=metric_uri,
185
+ metric_context=metric_context)
175
186
 
176
187
  return_data = response_data
177
188
  if _preload_content:
@@ -326,7 +337,7 @@ class ApiClient(object):
326
337
  body=None, post_params=None, files=None,
327
338
  response_type=None, auth_settings=None, async_req=None,
328
339
  _return_http_data_only=None, collection_formats=None,
329
- _preload_content=True, _request_timeout=None):
340
+ _preload_content=True, _request_timeout=None, metric_context=None):
330
341
  """Makes the HTTP request (synchronous) and returns deserialized data.
331
342
 
332
343
  To make an async request, set the async_req parameter.
@@ -369,7 +380,8 @@ class ApiClient(object):
369
380
  body, post_params, files,
370
381
  response_type, auth_settings,
371
382
  _return_http_data_only, collection_formats,
372
- _preload_content, _request_timeout)
383
+ _preload_content, _request_timeout,
384
+ metric_context)
373
385
  thread = AwaitableThread(
374
386
  target=self.__call_api,
375
387
  args=(
@@ -378,15 +390,36 @@ class ApiClient(object):
378
390
  body, post_params, files,
379
391
  response_type, auth_settings,
380
392
  _return_http_data_only, collection_formats,
381
- _preload_content, _request_timeout
393
+ _preload_content, _request_timeout,
394
+ metric_context
382
395
  )
383
396
  )
384
397
  thread.start()
385
398
  return thread
386
399
 
400
+ def _safe_record_api_request_time(self, method, uri, status, time_spent,
401
+ metric_uri):
402
+ """Record the api-request metric without ever raising.
403
+
404
+ Metrics are best-effort instrumentation; a failure here must not
405
+ propagate and take down the actual API call.
406
+ """
407
+ if self.metrics_collector is None:
408
+ return
409
+ try:
410
+ self.metrics_collector.record_api_request_time(
411
+ method=method,
412
+ uri=uri,
413
+ status=status,
414
+ time_spent=time_spent,
415
+ metric_uri=metric_uri,
416
+ )
417
+ except Exception as e:
418
+ logger.debug(f'Failed to record api request metric (ignored): {e}')
419
+
387
420
  def request(self, method, url, query_params=None, headers=None,
388
421
  post_params=None, body=None, _preload_content=True,
389
- _request_timeout=None):
422
+ _request_timeout=None, metric_uri=None, metric_context=None):
390
423
  """Makes the HTTP request using RESTClient."""
391
424
  # Extract URI path from URL (remove query params and domain)
392
425
  try:
@@ -461,16 +494,28 @@ class ApiClient(object):
461
494
  # Extract status code from response
462
495
  status_code = str(response.status) if hasattr(response, 'status') else "200"
463
496
 
464
- # Record metrics
497
+ # Record metrics. A metrics failure must never turn a successful
498
+ # request into a failed one, so the emit is isolated.
465
499
  if self.metrics_collector is not None:
466
500
  elapsed_time = time.time() - start_time
467
- self.metrics_collector.record_api_request_time(
468
- method=method,
469
- uri=uri,
470
- status=status_code,
471
- time_spent=elapsed_time
501
+ self._safe_record_api_request_time(
502
+ method, uri, status_code, elapsed_time, metric_uri,
472
503
  )
473
504
 
505
+ # Record workflow input payload size when the caller supplies
506
+ # workflow metadata (only the start-workflow paths do). The size
507
+ # was computed once in rest.py and lives on the response.
508
+ if metric_context is not None:
509
+ try:
510
+ size = getattr(response, 'request_body_size', 0)
511
+ self.metrics_collector.record_workflow_input_payload_size(
512
+ metric_context["workflow_name"],
513
+ metric_context["version"],
514
+ size,
515
+ )
516
+ except Exception:
517
+ pass
518
+
474
519
  return response
475
520
 
476
521
  except Exception as e:
@@ -482,14 +527,12 @@ class ApiClient(object):
482
527
  else:
483
528
  status_code = "error"
484
529
 
485
- # Record metrics for failed requests
530
+ # Record metrics for failed requests. Guard the emit so a metrics
531
+ # error can't mask the original request exception below.
486
532
  if self.metrics_collector is not None:
487
533
  elapsed_time = time.time() - start_time
488
- self.metrics_collector.record_api_request_time(
489
- method=method,
490
- uri=uri,
491
- status=status_code,
492
- time_spent=elapsed_time
534
+ self._safe_record_api_request_time(
535
+ method, uri, status_code, elapsed_time, metric_uri,
493
536
  )
494
537
 
495
538
  # Re-raise the exception