Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Observe request summary, size, and duration with labels #3461

Merged
merged 8 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions kinto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"record_id_generator": "kinto.views.RelaxedUUID",
"project_name": "kinto",
"admin_assets_path": None,
"metrics_matchdict_fields": ["bucket_id", "collection_id", "group_id", "record_id"],
}


Expand Down
1 change: 1 addition & 0 deletions kinto/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"statsd_backend": "kinto.core.statsd",
"statsd_prefix": "kinto.core",
"statsd_url": None,
"metrics_matchdict_fields": [],
"storage_backend": "",
"storage_url": "",
"storage_max_fetch_size": 10000,
Expand Down
46 changes: 46 additions & 0 deletions kinto/core/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,52 @@ def on_new_response(event):
auth, user_id = user_id.split(":")
metrics_service.count("users", unique=[("auth", auth), ("userid", user_id)])

# Add extra labels to metrics, based on fields extracted from the request matchdict.
metrics_matchdict_fields = aslist(settings["metrics_matchdict_fields"])
# Turn the `id` field of object endpoints into `{resource}_id` (eg. `mushroom_id`, `bucket_id`)
enhanced_matchdict = dict(request.matchdict or {})
try:
enhanced_matchdict[request.current_resource_name + "_id"] = enhanced_matchdict.get(
"id", ""
)
except AttributeError:
# Not on a resource.
pass
metrics_matchdict_labels = [
(field, enhanced_matchdict.get(field, "")) for field in metrics_matchdict_fields
]

# Count served requests.
metrics_service.count(
"request_summary",
unique=[
("method", request.method.lower()),
("endpoint", utils.strip_uri_prefix(request.path)),
("status", str(request.response.status_code)),
]
+ metrics_matchdict_labels,
)

try:
current = utils.msec_time()
duration = current - request._received_at
metrics_service.observe(
"request_duration",
duration,
labels=[("endpoint", utils.strip_uri_prefix(request.path))]
+ metrics_matchdict_labels,
)
except AttributeError: # pragma: no cover
# Logging was not setup in this Kinto app (unlikely but possible)
pass

# Observe response size.
metrics_service.observe(
"request_size",
len(request.response.body or b""),
labels=[("endpoint", utils.strip_uri_prefix(request.path))] + metrics_matchdict_labels,
)

# Count authentication verifications.
if hasattr(request, "authn_type"):
metrics_service.count(f"authn_type.{request.authn_type}")
Expand Down
8 changes: 8 additions & 0 deletions kinto/core/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ def timer(key):
Watch execution time.
"""

def observe(self, key, value, labels=[]):
"""
Observe a give `value` for the specified `key`.
"""

def count(key, count=1, unique=None):
"""
Count occurrences. If `unique` is set, overwrites the counter value
Expand Down Expand Up @@ -45,6 +50,9 @@ class NoOpMetricsService:
def timer(self, key):
return NoOpTimer()

def observe(self, key, value, labels=[]):
pass

def count(self, key, count=1, unique=None):
pass

Expand Down
33 changes: 33 additions & 0 deletions kinto/plugins/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ def timer(self, key):

return Timer(_METRICS[key])

def observe(self, key, value, labels=[]):
global _METRICS

if key not in _METRICS:
_METRICS[key] = prometheus_module.Summary(
_fix_metric_name(key),
f"Summary of {key}",
labelnames=[label_name for label_name, _ in labels],
registry=get_registry(),
)

if not isinstance(_METRICS[key], prometheus_module.Summary):
raise RuntimeError(
f"Metric {key} already exists with different type ({_METRICS[key]})"
)

m = _METRICS[key]
if labels:
m = m.labels(*(label_value for _, label_value in labels))

m.observe(value)

def count(self, key, count=1, unique=None):
global _METRICS

Expand Down Expand Up @@ -150,4 +172,15 @@ def includeme(config):
config.add_route("prometheus_metrics", "/__metrics__")
config.add_view(metrics_view, route_name="prometheus_metrics")

# Reinitialize the registry on initialization.
# This is mainly useful in tests, where the plugin is included
# several times with different settings.
registry = get_registry()
for collector in _METRICS.values():
try:
registry.unregister(collector)
except KeyError: # pragma: no cover
pass
_METRICS.clear()

config.registry.registerUtility(PrometheusService(), metrics.IMetricsService)
3 changes: 3 additions & 0 deletions kinto/plugins/statsd.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def __init__(self, host, port, prefix):
def timer(self, key):
return self._client.timer(key)

def observe(self, key, value, labels=[]):
return self._client.gauge(key, value)

def count(self, key, count=1, unique=None):
if unique is None:
return self._client.incr(key, count=count)
Expand Down
29 changes: 29 additions & 0 deletions tests/core/test_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,35 @@ def test_statsd_counts_nothing_on_anonymous_requests(self):
app.get("/")
self.assertFalse(self.mocked.count.called)

def test_statsd_counts_endpoints(self):
kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
app = webtest.TestApp(self.config.make_wsgi_app())
app.get("/v0/__heartbeat__")
self.mocked().count.assert_any_call(
"request_summary",
unique=[("method", "get"), ("endpoint", "/__heartbeat__"), ("status", "200")],
)

def test_statsd_observe_request_size(self):
kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
app = webtest.TestApp(self.config.make_wsgi_app())
app.get("/v0/__heartbeat__")
self.mocked().observe.assert_any_call(
"request_size",
len("{}"),
labels=[("endpoint", "/__heartbeat__")],
)

def test_statsd_observe_request_duration(self):
kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
app = webtest.TestApp(self.config.make_wsgi_app())
app.get("/v0/__heartbeat__")
self.mocked().observe.assert_any_call(
"request_duration",
mock.ANY,
labels=[("endpoint", "/__heartbeat__")],
)

def test_statsd_counts_views_and_methods(self):
kinto.core.initialize(self.config, "0.0.1", "settings_prefix")
app = webtest.TestApp(self.config.make_wsgi_app())
Expand Down
16 changes: 16 additions & 0 deletions tests/plugins/test_prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ def test_timer_can_be_used_as_decorator_on_partial_function(self):
resp = self.app.get("/__metrics__")
self.assertIn("TYPE func_latency_partial summary", resp.text)

def test_observe_a_single_value(self):
self.app.app.registry.metrics.observe("price", 111)

resp = self.app.get("/__metrics__")
self.assertIn("price_sum 111", resp.text)

def test_observe_a_single_value_with_labels(self):
self.app.app.registry.metrics.observe("size", 3.14, labels=[("endpoint", "/buckets")])

resp = self.app.get("/__metrics__")
self.assertIn('size_sum{endpoint="/buckets"} 3.14', resp.text)

def test_count_by_key(self):
self.app.app.registry.metrics.count("key")

Expand Down Expand Up @@ -107,6 +119,10 @@ def test_metrics_cant_be_mixed(self):
with self.assertRaises(RuntimeError):
self.app.app.registry.metrics.count("timer")

self.app.app.registry.metrics.count("observer")
with self.assertRaises(RuntimeError):
self.app.app.registry.metrics.observe("observer", 42)

def test_metrics_names_and_labels_are_transformed(self):
self.app.app.registry.metrics.count("http.home.status", unique=[("code.get", "200")])

Expand Down
5 changes: 5 additions & 0 deletions tests/plugins/test_statsd.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def setUp(self):
self.mocked_client = patch.start()
self.addCleanup(patch.stop)

def test_observe_a_single_value(self):
with mock.patch.object(self.client, "_client") as mocked_client:
self.client.observe("size", 3.14)
mocked_client.gauge.assert_called_with("size", 3.14)

def test_count_increments_the_counter_for_key(self):
with mock.patch.object(self.client, "_client") as mocked_client:
self.client.count("click")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_views_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import unittest
from unittest import mock

from kinto.core.testing import skip_if_no_prometheus

from .support import BaseWebTest


@skip_if_no_prometheus
class ViewsMetricsTest(BaseWebTest, unittest.TestCase):
@classmethod
def get_app_settings(cls, extras=None):
settings = super().get_app_settings(extras)
settings["includes"] += "\nkinto.plugins.prometheus"
return settings

def setUp(self):
super().setUp()
patch = mock.patch("kinto.plugins.prometheus.PrometheusService")
self.mocked = patch.start()
self.addCleanup(patch.stop)

def test_metrics_have_matchdict_labels(self):
self.app.put("/buckets/beers", headers=self.headers)
self.app.put("/buckets/beers/groups/amateurs", headers=self.headers)
self.app.put("/buckets/beers/collections/barley", headers=self.headers)
self.app.put("/buckets/beers/collections/barley/records/abc", headers=self.headers)

resp = self.app.get("/__metrics__")
self.assertIn(
'request_size_sum{bucket_id="beers",collection_id="",endpoint="/buckets/beers",group_id="",record_id=""}',
resp.text,
)
self.assertIn(
'request_size_sum{bucket_id="beers",collection_id="",endpoint="/buckets/beers/groups/amateurs",group_id="amateurs",record_id=""}',
resp.text,
)
self.assertIn(
'request_summary_total{bucket_id="beers",collection_id="barley",endpoint="/buckets/beers/collections/barley",group_id="",method="put",record_id="",status="201"}',
resp.text,
)
self.assertIn(
'request_duration_sum{bucket_id="beers",collection_id="barley",endpoint="/buckets/beers/collections/barley/records/abc",group_id="",record_id="abc"}',
resp.text,
)
Loading