diff --git a/prometheus_client/metrics.py b/prometheus_client/metrics.py index b9f25ffc..39daac2d 100644 --- a/prometheus_client/metrics.py +++ b/prometheus_client/metrics.py @@ -203,6 +203,39 @@ def remove(self, *labelvalues: Any) -> None: if labelvalues in self._metrics: del self._metrics[labelvalues] + def remove_by_labels(self, labels: dict[str, str]) -> None: + """Remove all series whose labelset partially matches the given labels.""" + if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: + warnings.warn( + "Removal of labels has not been implemented in multi-process mode yet.", + UserWarning + ) + + if not self._labelnames: + raise ValueError('No label names were set when constructing %s' % self) + + if not isinstance(labels, dict): + raise TypeError("labels must be a dict of {label_name: label_value}") + + if not labels: + return # no operation + + invalid = [k for k in labels.keys() if k not in self._labelnames] + if invalid: + raise ValueError( + 'Unknown label names: %s; expected %s' % (invalid, self._labelnames) + ) + + pos_filter = {self._labelnames.index(k): str(v) for k, v in labels.items()} + + with self._lock: + # list(...) to avoid "dictionary changed size during iteration" + for lv in list(self._metrics.keys()): + if all(lv[pos] == want for pos, want in pos_filter.items()): + # pop with default avoids KeyError if concurrently removed + self._metrics.pop(lv, None) + + def clear(self) -> None: """Remove all labelsets from the metric""" if 'prometheus_multiproc_dir' in os.environ or 'PROMETHEUS_MULTIPROC_DIR' in os.environ: diff --git a/tests/test_core.py b/tests/test_core.py index 284bce09..c7c9c14f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -630,6 +630,40 @@ def test_labels_coerced_to_string(self): self.counter.remove(None) self.assertEqual(None, self.registry.get_sample_value('c_total', {'l': 'None'})) + def test_remove_by_labels(self): + from prometheus_client import Counter + + c = Counter('c2', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + c.labels('acme', '/checkout').inc() + c.labels('globex', '/').inc() + + ret = c.remove_by_labels({'tenant': 'acme'}) + self.assertIsNone(ret) + + self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/'})) + self.assertIsNone(self.registry.get_sample_value('c2_total', {'tenant': 'acme', 'endpoint': '/checkout'})) + self.assertEqual(1, self.registry.get_sample_value('c2_total', {'tenant': 'globex', 'endpoint': '/'})) + + + def test_remove_by_labels_invalid_label_name(self): + from prometheus_client import Counter + c = Counter('c3', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + with self.assertRaises(ValueError): + c.remove_by_labels({'badkey': 'x'}) + + + def test_remove_by_labels_empty_is_noop(self): + from prometheus_client import Counter + c = Counter('c4', 'help', ['tenant', 'endpoint'], registry=self.registry) + c.labels('acme', '/').inc() + + ret = c.remove_by_labels({}) + self.assertIsNone(ret) + # Ensure the series is still present + self.assertEqual(1, self.registry.get_sample_value('c4_total', {'tenant': 'acme', 'endpoint': '/'})) + def test_non_string_labels_raises(self): class Test: __str__ = None