From 622d0455055e51e486ed5f94c4776a29a66f91a3 Mon Sep 17 00:00:00 2001 From: hazel-shen Date: Wed, 12 Nov 2025 23:25:51 +0800 Subject: [PATCH 1/2] fix(multiprocess): avoid double-building child metric names (#1035) Signed-off-by: hazel-shen --- prometheus_client/metrics.py | 12 +++++- tests/test_multiprocess.py | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 39daac2d..6efade71 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -176,13 +176,21 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: labelvalues = tuple(str(l) for l in labelvalues) with self._lock: if labelvalues not in self._metrics: + + child_kwargs = dict(self._kwargs) if self._kwargs else {} + + for k in ('namespace', 'subsystem', 'unit'): + child_kwargs.pop(k, None) + self._metrics[labelvalues] = self.__class__( self._name, documentation=self._documentation, labelnames=self._labelnames, - unit=self._unit, + namespace="", + subsystem="", + unit="", _labelvalues=labelvalues, - **self._kwargs + **child_kwargs ) return self._metrics[labelvalues] diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index 77fd3d81..a2ab5342 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -396,6 +396,77 @@ def test_remove_clear_warning(self): assert "Removal of labels has not been implemented" in str(w[0].message) assert issubclass(w[-1].category, UserWarning) assert "Clearing labels has not been implemented" in str(w[-1].message) + + def test_child_name_is_built_once_with_namespace_subsystem_unit(self): + """ + Repro for #1035: + In multiprocess mode, child metrics must NOT rebuild the full name + (namespace/subsystem/unit) a second time. The exported family name should + be built once, and Counter samples should use "_total". + """ + from prometheus_client import Counter + + class CustomCounter(Counter): + def __init__( + self, + name, + documentation, + labelnames=(), + namespace="mydefaultnamespace", + subsystem="mydefaultsubsystem", + unit="", + registry=None, + _labelvalues=None + ): + # Intentionally provide non-empty defaults to trigger the bug path. + super().__init__( + name=name, + documentation=documentation, + labelnames=labelnames, + namespace=namespace, + subsystem=subsystem, + unit=unit, + registry=registry, + _labelvalues=_labelvalues) + + # Create a Counter with explicit namespace/subsystem/unit + c = CustomCounter( + name='m', + documentation='help', + labelnames=('status', 'method'), + namespace='ns', + subsystem='ss', + unit='seconds', # avoid '_total_total' confusion + registry=None, # not registered in local registry in multiprocess mode + ) + + # Create two labeled children + c.labels(status='200', method='GET').inc() + c.labels(status='404', method='POST').inc() + + # Collect from the multiprocess collector initialized in setUp() + metrics = {m.name: m for m in self.collector.collect()} + + # Family name should be built once (no '_total' in family name) + expected_family = 'ns_ss_m_seconds' + self.assertIn(expected_family, metrics, f"missing family {expected_family}") + + # Counter samples must use '_total' + mf = metrics[expected_family] + sample_names = {s.name for s in mf.samples} + self.assertTrue( + all(name == expected_family + '_total' for name in sample_names), + f"unexpected sample names: {sample_names}" + ) + + # Ensure no double-built prefix sneaks in (the original bug) + bad_prefix = 'mydefaultnamespace_mydefaultsubsystem_' + all_names = {mf.name, *sample_names} + self.assertTrue( + all(not n.startswith(bad_prefix) for n in all_names), + f"found double-built name(s): {[n for n in all_names if n.startswith(bad_prefix)]}" + ) + class TestMmapedDict(unittest.TestCase): From d0cf1fc4db21254aaf34ec1a92a6f285402be0af Mon Sep 17 00:00:00 2001 From: hazel-shen Date: Fri, 14 Nov 2025 18:02:25 +0800 Subject: [PATCH 2/2] test: ensure child metrics retain parent namespace/subsystem/unit Signed-off-by: hazel-shen --- prometheus_client/metrics.py | 18 ++++++++++++----- tests/test_multiprocess.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index 6efade71..4c53b26b 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -109,6 +109,10 @@ def __init__(self: T, registry: Optional[CollectorRegistry] = REGISTRY, _labelvalues: Optional[Sequence[str]] = None, ) -> None: + + self._original_name = name + self._namespace = namespace + self._subsystem = subsystem self._name = _build_full_name(self._type, name, namespace, subsystem, unit) self._labelnames = _validate_labelnames(self, labelnames) self._labelvalues = tuple(_labelvalues or ()) @@ -177,18 +181,22 @@ def labels(self: T, *labelvalues: Any, **labelkwargs: Any) -> T: with self._lock: if labelvalues not in self._metrics: - child_kwargs = dict(self._kwargs) if self._kwargs else {} + original_name = getattr(self, '_original_name', self._name) + namespace = getattr(self, '_namespace', '') + subsystem = getattr(self, '_subsystem', '') + unit = getattr(self, '_unit', '') + child_kwargs = dict(self._kwargs) if self._kwargs else {} for k in ('namespace', 'subsystem', 'unit'): child_kwargs.pop(k, None) self._metrics[labelvalues] = self.__class__( - self._name, + original_name, documentation=self._documentation, labelnames=self._labelnames, - namespace="", - subsystem="", - unit="", + namespace=namespace, + subsystem=subsystem, + unit=unit, _labelvalues=labelvalues, **child_kwargs ) diff --git a/tests/test_multiprocess.py b/tests/test_multiprocess.py index a2ab5342..e7ca154e 100644 --- a/tests/test_multiprocess.py +++ b/tests/test_multiprocess.py @@ -467,6 +467,45 @@ def __init__( f"found double-built name(s): {[n for n in all_names if n.startswith(bad_prefix)]}" ) + def test_child_preserves_parent_context_for_subclasses(self): + """ + Ensure child metrics preserve parent's namespace/subsystem/unit information + so that subclasses can correctly use these parameters in their logic. + """ + class ContextAwareCounter(Counter): + def __init__(self, + name, + documentation, + labelnames=(), + namespace="", + subsystem="", + unit="", + **kwargs): + self.context = { + 'namespace': namespace, + 'subsystem': subsystem, + 'unit': unit + } + super().__init__(name, documentation, + labelnames=labelnames, + namespace=namespace, + subsystem=subsystem, + unit=unit, + **kwargs) + + parent = ContextAwareCounter('m', 'help', + labelnames=['status'], + namespace='prod', + subsystem='api', + unit='seconds', + registry=None) + + child = parent.labels(status='200') + + # Verify that child retains parent's context + self.assertEqual(child.context['namespace'], 'prod') + self.assertEqual(child.context['subsystem'], 'api') + self.assertEqual(child.context['unit'], 'seconds') class TestMmapedDict(unittest.TestCase):