Skip to content

Commit 4496fb0

Browse files
RiviaAzusaRiviaAzusa
and
RiviaAzusa
authored
feat: Add new Prometheus target and metadata API endpoints (#295)
* feat: Add new Prometheus target and metadata API endpoints Add four new methods to PrometheusConnect class: - get_scrape_pools(): Retrieve list of unique scrape pool names - get_targets(): Get active/dropped targets with optional state and pool filters - get_target_metadata(): Fetch metadata about metrics from specific targets - get_metric_metadata(): Get metadata about metrics with optional filtering These additions provide better visibility into Prometheus targets and metric metadata, enabling more detailed monitoring and configuration analysis. * Add test cases for prometheus connection * modified: prometheus_api_client/prometheus_connect.py modified: tests/test_prometheus_connect.py --------- Co-authored-by: RiviaAzusa <[email protected]>
1 parent 89d2885 commit 4496fb0

File tree

2 files changed

+210
-1
lines changed

2 files changed

+210
-1
lines changed

prometheus_api_client/prometheus_connect.py

+138
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,141 @@ def get_metric_aggregation(
561561
else:
562562
raise TypeError("Invalid operation: " + operation)
563563
return aggregated_values
564+
565+
566+
def get_scrape_pools(self) -> list[str]:
567+
"""
568+
Get a list of all scrape pools in activeTargets.
569+
"""
570+
scrape_pools = []
571+
for target in self.get_targets()['activeTargets']:
572+
scrape_pools.append(target['scrapePool'])
573+
return list(set(scrape_pools))
574+
575+
def get_targets(self, state: str = None, scrape_pool: str = None):
576+
"""
577+
Get a list of all targets from Prometheus.
578+
579+
:param state: (str) Optional filter for target state ('active', 'dropped', 'any').
580+
If None, returns both active and dropped targets.
581+
:param scrape_pool: (str) Optional filter by scrape pool name
582+
:returns: (dict) A dictionary containing active and dropped targets
583+
:raises:
584+
(RequestException) Raises an exception in case of a connection error
585+
(PrometheusApiClientException) Raises in case of non 200 response status code
586+
"""
587+
params = {}
588+
if state:
589+
params['state'] = state
590+
if scrape_pool:
591+
params['scrapePool'] = scrape_pool
592+
593+
response = self._session.get(
594+
"{0}/api/v1/targets".format(self.url),
595+
verify=self._session.verify,
596+
headers=self.headers,
597+
params=params,
598+
auth=self.auth,
599+
cert=self._session.cert,
600+
timeout=self._timeout,
601+
)
602+
603+
if response.status_code == 200:
604+
return response.json()["data"]
605+
else:
606+
raise PrometheusApiClientException(
607+
"HTTP Status Code {} ({!r})".format(
608+
response.status_code, response.content)
609+
)
610+
611+
def get_target_metadata(self, target: dict[str, str], metric: str = None):
612+
"""
613+
Get metadata about metrics from a specific target.
614+
615+
:param target: (dict) A dictionary containing target labels to match against (e.g. {'job': 'prometheus'})
616+
:param metric: (str) Optional metric name to filter metadata
617+
:returns: (list) A list of metadata entries for matching targets
618+
:raises:
619+
(RequestException) Raises an exception in case of a connection error
620+
(PrometheusApiClientException) Raises in case of non 200 response status code
621+
"""
622+
params = {}
623+
624+
# Convert target dict to label selector string
625+
if metric:
626+
params['metric'] = metric
627+
628+
if target:
629+
match_target = "{" + \
630+
",".join(f'{k}="{v}"' for k, v in target.items()) + "}"
631+
params['match_target'] = match_target
632+
633+
response = self._session.get(
634+
"{0}/api/v1/targets/metadata".format(self.url),
635+
verify=self._session.verify,
636+
headers=self.headers,
637+
params=params,
638+
auth=self.auth,
639+
cert=self._session.cert,
640+
timeout=self._timeout,
641+
)
642+
643+
if response.status_code == 200:
644+
return response.json()["data"]
645+
else:
646+
raise PrometheusApiClientException(
647+
"HTTP Status Code {} ({!r})".format(
648+
response.status_code, response.content)
649+
)
650+
651+
def get_metric_metadata(self, metric: str, limit: int = None, limit_per_metric: int = None):
652+
"""
653+
Get metadata about metrics.
654+
655+
:param metric: (str) Optional metric name to filter metadata
656+
:param limit: (int) Optional maximum number of metrics to return
657+
:param limit_per_metric: (int) Optional maximum number of metadata entries per metric
658+
:returns: (dict) A dictionary mapping metric names to lists of metadata entries in format:
659+
{'metric_name': [{'type': str, 'help': str, 'unit': str}, ...]}
660+
:raises:
661+
(RequestException) Raises an exception in case of a connection error
662+
(PrometheusApiClientException) Raises in case of non 200 response status code
663+
"""
664+
params = {}
665+
666+
if metric:
667+
params['metric'] = metric
668+
669+
if limit:
670+
params['limit'] = limit
671+
672+
if limit_per_metric:
673+
params['limit_per_metric'] = limit_per_metric
674+
675+
response = self._session.get(
676+
"{0}/api/v1/metadata".format(self.url),
677+
verify=self._session.verify,
678+
headers=self.headers,
679+
params=params,
680+
auth=self.auth,
681+
cert=self._session.cert,
682+
timeout=self._timeout,
683+
)
684+
685+
if response.status_code == 200:
686+
data = response.json()["data"]
687+
formatted_data = []
688+
for k, v in data.items():
689+
for v_ in v:
690+
formatted_data.append({
691+
"metric_name": k,
692+
"type": v_.get('type', 'unknown'),
693+
"help": v_.get('help', ''),
694+
"unit": v_.get('unit', '')
695+
})
696+
return formatted_data
697+
else:
698+
raise PrometheusApiClientException(
699+
"HTTP Status Code {} ({!r})".format(
700+
response.status_code, response.content)
701+
)

tests/test_prometheus_connect.py

+72-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ def test_get_metric_aggregation(self): # noqa D102
127127
def test_get_metric_aggregation_with_incorrect_input_types(self): # noqa D102
128128
with self.assertRaises(TypeError, msg="operations accepted invalid value type"):
129129
_ = self.pc.get_metric_aggregation(query="up", operations="sum")
130-
131130
def test_retry_on_error(self): # noqa D102
132131
retry = Retry(total=3, backoff_factor=0.1, status_forcelist=[400])
133132
pc = PrometheusConnect(url=self.prometheus_host, disable_ssl=False, retry=retry)
@@ -140,6 +139,75 @@ def test_get_label_names_method(self): # noqa D102
140139
self.assertEqual(len(labels), 3)
141140
self.assertEqual(labels, ["__name__", "instance", "job"])
142141

142+
def test_get_scrape_pools(self): # noqa D102
143+
scrape_pools = self.pc.get_scrape_pools()
144+
self.assertIsInstance(scrape_pools, list)
145+
self.assertTrue(len(scrape_pools) > 0, "no scrape pools found")
146+
self.assertIsInstance(scrape_pools[0], str)
147+
148+
def test_get_targets(self): # PR #295
149+
targets = self.pc.get_targets()
150+
self.assertIsInstance(targets, dict)
151+
self.assertIn('activeTargets', targets)
152+
self.assertIsInstance(targets['activeTargets'], list)
153+
154+
# Test with state filter
155+
active_targets = self.pc.get_targets(state='active')
156+
self.assertIsInstance(active_targets, dict)
157+
self.assertIn('activeTargets', active_targets)
158+
159+
# Test with scrape_pool filter
160+
if len(scrape_pools := self.pc.get_scrape_pools()) > 0:
161+
pool_targets = self.pc.get_targets(scrape_pool=scrape_pools[0])
162+
self.assertIsInstance(pool_targets, dict)
163+
164+
def test_get_target_metadata(self): # PR #295
165+
# Get a target to test with
166+
targets = self.pc.get_targets()
167+
if len(targets['activeTargets']) > 0:
168+
target = {
169+
'job': targets['activeTargets'][0]['labels']['job']
170+
}
171+
metadata = self.pc.get_target_metadata(target)
172+
self.assertIsInstance(metadata, list)
173+
174+
# Test with metric filter
175+
if len(metadata) > 0:
176+
metric_name = metadata[0]['metric']
177+
filtered_metadata = self.pc.get_target_metadata(
178+
target, metric=metric_name)
179+
self.assertIsInstance(filtered_metadata, list)
180+
self.assertTrue(
181+
all(item['target']['job'] == target['job'] for item in filtered_metadata))
182+
183+
184+
def test_get_metric_metadata(self): # PR #295
185+
metadata = self.pc.get_metric_metadata(metric=None)
186+
self.assertIsInstance(metadata, list)
187+
self.assertTrue(len(metadata) > 0, "no metric metadata found")
188+
189+
# Check structure of metadata
190+
self.assertIn('metric_name', metadata[0])
191+
self.assertIn('type', metadata[0])
192+
self.assertIn('help', metadata[0])
193+
self.assertIn('unit', metadata[0])
194+
195+
# Test with specific metric
196+
if len(metadata) > 0:
197+
metric_name = metadata[0]['metric_name']
198+
filtered_metadata = self.pc.get_metric_metadata(metric=metric_name)
199+
self.assertIsInstance(filtered_metadata, list)
200+
self.assertTrue(
201+
all(item['metric_name'] == metric_name for item in filtered_metadata))
202+
203+
# Test with limit
204+
limited_metadata = self.pc.get_metric_metadata(metric_name, limit=1)
205+
self.assertLessEqual(len(limited_metadata), 1)
206+
207+
# Test with limit_per_metric
208+
limited_per_metric = self.pc.get_metric_metadata(metric_name, limit_per_metric=1)
209+
self.assertIsInstance(limited_per_metric, list)
210+
143211

144212
class TestPrometheusConnectWithMockedNetwork(BaseMockedNetworkTestcase):
145213
"""Network is blocked in this testcase, see base class."""
@@ -233,3 +301,6 @@ def test_get_label_values_method(self): # noqa D102
233301
self.assertEqual(handler.call_count, 1)
234302
request = handler.requests[0]
235303
self.assertEqual(request.path_url, "/api/v1/label/label_name/values")
304+
305+
if __name__ == "__main__":
306+
unittest.main()

0 commit comments

Comments
 (0)