Skip to content

Commit 71af843

Browse files
committed
Support query parameters for Exporters
We have supported query parameters for the Exporter's endpoint. Users can scrape the endpoint with specific query parameters by adding their query parameters to the "path" field when creating a new Exporter in Promgen. Currently, the list of values for parameters are not supported. For example: ``` path = /metrics?match[]={job="prometheus"}&param1=foo&param2=bar ```
1 parent dd9074f commit 71af843

6 files changed

Lines changed: 144 additions & 4 deletions

File tree

promgen/forms.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,32 @@ class Meta:
109109
"scheme": forms.Select(attrs={"class": "form-control"}),
110110
}
111111

112+
def clean_path(self):
113+
from urllib.parse import parse_qs, urlparse
114+
115+
path = self.cleaned_data["path"]
116+
if not path:
117+
return path
118+
119+
# Validate path and query parameters
120+
parsed = urlparse(path)
121+
query_params = parse_qs(qs=parsed.query)
122+
query_params = {k: list(set(v)) for k, v in query_params.items()}
123+
for key, value in query_params.items():
124+
validators.validate_utf8(key)
125+
if len(value) > 1:
126+
raise ValidationError(
127+
"List values are currently not supported for query parameters."
128+
)
129+
for v in value:
130+
validators.validate_utf8(v)
131+
132+
# Reconstruct the path with cleaned params
133+
query_string = "&".join(
134+
f"{key}={value}" for key, values in query_params.items() for value in values
135+
)
136+
return parsed.path + ("?" + query_string if query_string else "")
137+
112138

113139
class ServiceRegister(forms.ModelForm):
114140
class Meta:

promgen/prometheus.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import subprocess
88
import tempfile
9-
from urllib.parse import urljoin
9+
from urllib.parse import parse_qs, urljoin, urlparse
1010

1111
import yaml
1212
from dateutil import parser
@@ -133,15 +133,20 @@ def render_config(service=None, project=None, services=None, projects=None, farm
133133
"job": exporter.job,
134134
"__scheme__": exporter.scheme,
135135
}
136+
136137
if exporter.path:
137-
labels["__metrics_path__"] = exporter.path
138+
parsed = urlparse(exporter.path)
139+
labels["__metrics_path__"] = parsed.path
140+
query_params = parse_qs(parsed.query)
141+
for key, value in query_params.items():
142+
labels[f"__param_{key}"] = value[0]
138143

139144
hosts = []
140145
if farms is None or exporter.project.farm in farms:
141146
for host in exporter.project.farm.host_set.all():
142147
hosts.append(f"{host.name}:{exporter.port}")
143148

144-
data.append({"labels": labels, "targets": hosts})
149+
data.append({"labels": labels, "targets": hosts})
145150
return json.dumps(data, indent=2, sort_keys=True)
146151

147152

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"labels": {
4+
"__farm_source": "promgen",
5+
"__scheme__": "http",
6+
"__shard": "test-shard",
7+
"farm": "test-farm",
8+
"job": "node",
9+
"project": "test-project",
10+
"service": "test-service",
11+
"__metrics_path__": "/metrics",
12+
"__param_match[]": "{job=\"prometheus\"}",
13+
"__param_param1": "foo",
14+
"__param_param2": "bar"
15+
},
16+
"targets": [
17+
"example.com:9100"
18+
]
19+
}
20+
]

promgen/tests/test_renderers.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.urls import reverse
55
from yaml import safe_load
66

7-
from promgen import tests
7+
from promgen import models, tests
88

99

1010
class RendererTests(tests.PromgenTest):
@@ -32,3 +32,15 @@ def test_global_urls(self):
3232
response = self.client.get(reverse("api:all-urls"))
3333
self.assertEqual(response.status_code, 200)
3434
self.assertEqual(response.json(), expected)
35+
36+
def test_targets_with_query_params(self):
37+
exporter = models.Exporter.objects.get(pk=1)
38+
exporter.path = (
39+
'/metrics?match[]={job="prometheus"}&param1=foo&param2=bar'
40+
)
41+
exporter.save()
42+
43+
expected = tests.Data("examples", "export.targets_with_query_params.json").json()
44+
response = self.client.get(reverse("api:all-targets"))
45+
self.assertEqual(response.status_code, 200)
46+
self.assertEqual(response.json(), expected)

promgen/tests/test_web.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Copyright (c) 2022 LINE Corporation
22
# These sources are released under the terms of the MIT license: see LICENSE
3+
from unittest import mock
4+
35
from django.urls import reverse
46
from guardian.shortcuts import assign_perm, remove_perm
57

@@ -285,3 +287,71 @@ def test_merge_users_no_permissions(self):
285287
reverse("admin:login") + "?next=" + reverse("admin:user-merge"),
286288
"Non-admin user redirected to login page",
287289
)
290+
291+
@mock.patch("promgen.models.Audit.log")
292+
@mock.patch("promgen.signals.trigger_write_config.send")
293+
def test_register_exporter(self, log_mock, write_mock):
294+
self.force_login(username="demo")
295+
assign_perm("project_editor", self.user, models.Project.objects.get(pk=1))
296+
297+
# Check creating an exporter with just the required fields and no path
298+
response = self.client.post(
299+
reverse("project-exporter", kwargs={"pk": 1}),
300+
{"job": "node", "port": 9101, "scheme": "http"},
301+
)
302+
self.assertEqual(
303+
response.status_code, 302, msg="Exporter created successfully without path"
304+
)
305+
306+
# Check successfully creating an exporter with query parameters in the path
307+
response = self.client.post(
308+
reverse("project-exporter", kwargs={"pk": 1}),
309+
{
310+
"job": "node",
311+
"port": 9101,
312+
"scheme": "http",
313+
"path": "/metrics?param1=foo&param2=bar",
314+
},
315+
)
316+
self.assertEqual(response.status_code, 302)
317+
self.assertTrue(
318+
models.Exporter.objects.filter(
319+
path="/metrics?param1=foo&param2=bar",
320+
).exists(),
321+
"Exporter with query parameters in path created successfully",
322+
)
323+
324+
# Check creating an exporter with array query parameter should not be allowed
325+
response = self.client.post(
326+
reverse("project-exporter", kwargs={"pk": 1}),
327+
{
328+
"job": "node",
329+
"port": 9101,
330+
"scheme": "http",
331+
"path": "/metrics?list[]=foo&list[]=bar",
332+
},
333+
)
334+
self.assertEqual(response.status_code, 200, msg="Exporter created failed")
335+
self.assertEqual(
336+
response.context_data["form"].errors,
337+
{"path": ["List values are currently not supported for query parameters."]},
338+
msg="Exporter with array query parameters should not be allowed",
339+
)
340+
341+
# Check creating an exporter with redundant query parameters should be cleaned up
342+
response = self.client.post(
343+
reverse("project-exporter", kwargs={"pk": 1}),
344+
{
345+
"job": "node",
346+
"port": 9101,
347+
"scheme": "http",
348+
"path": "/metrics?param=value&param&param=",
349+
},
350+
)
351+
self.assertEqual(response.status_code, 302)
352+
self.assertTrue(
353+
models.Exporter.objects.filter(
354+
path="/metrics?param=value",
355+
).exists(),
356+
"Redundant query parameters should be cleaned up and exporter created successfully",
357+
)

promgen/validators.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,10 @@ def datetime(value):
6565
parser.parse(value)
6666
except ValueError:
6767
raise ValidationError("Invalid timestamp")
68+
69+
70+
def validate_utf8(value):
71+
try:
72+
value.encode("utf-8").decode("utf-8")
73+
except (UnicodeEncodeError, UnicodeDecodeError):
74+
raise ValidationError("Invalid UTF-8 string.")

0 commit comments

Comments
 (0)