diff --git a/README.md b/README.md index f44b130..052d2d6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A simple Flask REST API for managing a todo list. | POST | `/items` | Create an item (`{"name": "..."}`) | | PATCH | `/items/:id` | Update an item (`{"done": true}`) | | DELETE | `/items/:id` | Delete an item | +| GET | `/metrics` | Prometheus metrics | ## Development diff --git a/app.py b/app.py index a2d2561..7dcba93 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,10 @@ from flask import Flask, jsonify, request from datetime import datetime, timezone +from metrics import init_metrics + app = Flask(__name__) +init_metrics(app) VERSION = "0.1.0" diff --git a/metrics.py b/metrics.py new file mode 100644 index 0000000..3838d50 --- /dev/null +++ b/metrics.py @@ -0,0 +1,105 @@ +import threading +import time + +from flask import g, request +from prometheus_client import ( + CollectorRegistry, + Counter, + Gauge, + Histogram, + generate_latest, + CONTENT_TYPE_LATEST, +) + +registry = CollectorRegistry() + +REQUEST_COUNT = Counter( + "http_requests_total", + "Total HTTP request count", + ["method", "endpoint", "status"], + registry=registry, +) + +REQUEST_DURATION = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], + registry=registry, +) + +REQUEST_DURATION_MAX = Gauge( + "http_request_duration_max_seconds", + "Maximum HTTP request duration in seconds", + ["method", "endpoint"], + registry=registry, +) + +REQUEST_DURATION_MIN = Gauge( + "http_request_duration_min_seconds", + "Minimum HTTP request duration in seconds", + ["method", "endpoint"], + registry=registry, +) + +# Thread-safe tracking of observed min/max values per (method, endpoint). +_extremes_lock = threading.Lock() +_extremes: dict[tuple[str, str], tuple[float, float]] = {} + + +def init_metrics(app): + """Register Prometheus metrics hooks and /metrics endpoint on the app.""" + + @app.before_request + def _start_timer(): + if request.path == "/metrics": + return + g.start_time = time.monotonic() + + @app.after_request + def _record_metrics(response): + if request.path == "/metrics": + return response + + start = g.pop("start_time", None) + if start is None: + return response + + duration = time.monotonic() - start + rule = request.url_rule + endpoint = rule.rule if rule is not None else "unmatched" + method = request.method + status = str(response.status_code) + + REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc() + REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration) + + key = (method, endpoint) + with _extremes_lock: + cur = _extremes.get(key) + if cur is None: + _extremes[key] = (duration, duration) + REQUEST_DURATION_MAX.labels(method=method, endpoint=endpoint).set( + duration + ) + REQUEST_DURATION_MIN.labels(method=method, endpoint=endpoint).set( + duration + ) + else: + cur_min, cur_max = cur + new_min = min(cur_min, duration) + new_max = max(cur_max, duration) + _extremes[key] = (new_min, new_max) + if new_max != cur_max: + REQUEST_DURATION_MAX.labels(method=method, endpoint=endpoint).set( + new_max + ) + if new_min != cur_min: + REQUEST_DURATION_MIN.labels(method=method, endpoint=endpoint).set( + new_min + ) + + return response + + @app.route("/metrics") + def metrics(): + return generate_latest(registry), 200, {"Content-Type": CONTENT_TYPE_LATEST} diff --git a/pyproject.toml b/pyproject.toml index 6aa14d8..cfbcdb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Simple Flask API for demonstrating fullsend agent capabilities" requires-python = ">=3.11" dependencies = [ "flask>=3.0", + "prometheus_client>=0.20", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 001e7c4..6864028 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask>=3.0 +prometheus_client>=0.20 diff --git a/test_app.py b/test_app.py index d5ae8f6..a996322 100644 --- a/test_app.py +++ b/test_app.py @@ -1,5 +1,28 @@ +import re + import pytest from app import app, reset_items +from metrics import ( + REQUEST_COUNT, + REQUEST_DURATION, + REQUEST_DURATION_MAX, + REQUEST_DURATION_MIN, + _extremes, +) + +_ALL_METRICS = [ + REQUEST_COUNT, + REQUEST_DURATION, + REQUEST_DURATION_MAX, + REQUEST_DURATION_MIN, +] + + +def _reset_collectors(): + """Clear all prometheus metric values between tests.""" + for metric in _ALL_METRICS: + metric._metrics.clear() + _extremes.clear() @pytest.fixture @@ -7,6 +30,7 @@ def client(): app.config["TESTING"] = True with app.test_client() as client: reset_items() + _reset_collectors() yield client @@ -63,3 +87,75 @@ def test_delete_item(client): def test_delete_item_not_found(client): resp = client.delete("/items/999") assert resp.status_code == 404 + + +def test_metrics_endpoint_returns_200(client): + resp = client.get("/metrics") + assert resp.status_code == 200 + assert resp.content_type.startswith("text/plain") + + +def test_metrics_contains_help_and_type(client): + resp = client.get("/metrics") + body = resp.data.decode() + assert "# HELP" in body + assert "# TYPE" in body + + +def test_metrics_tracks_request_count(client): + client.get("/items") + resp = client.get("/metrics") + body = resp.data.decode() + assert "http_requests_total{" in body + assert 'endpoint="/items"' in body + assert 'method="GET"' in body + assert 'status="200"' in body + + +def test_metrics_tracks_request_duration(client): + client.get("/items") + resp = client.get("/metrics") + body = resp.data.decode() + assert "http_request_duration_seconds" in body + assert 'endpoint="/items"' in body + + +def test_metrics_tracks_status_codes(client): + client.post("/items", json={}) + resp = client.get("/metrics") + body = resp.data.decode() + assert 'status="400"' in body + + +def test_metrics_excludes_metrics_endpoint(client): + # Hit /metrics a few times + client.get("/metrics") + client.get("/metrics") + resp = client.get("/metrics") + body = resp.data.decode() + assert 'endpoint="/metrics"' not in body + + +def test_metrics_tracks_min_max_duration(client): + client.get("/items") + resp = client.get("/metrics") + body = resp.data.decode() + assert "http_request_duration_max_seconds" in body + assert "http_request_duration_min_seconds" in body + + # Verify values are positive floats and min <= max + max_match = re.search( + r'http_request_duration_max_seconds\{.*endpoint="/items".*\}\s+([\d.e+-]+)', + body, + ) + min_match = re.search( + r'http_request_duration_min_seconds\{.*endpoint="/items".*\}\s+([\d.e+-]+)', + body, + ) + assert max_match is not None, "max duration metric not found for /items" + assert min_match is not None, "min duration metric not found for /items" + max_val = float(max_match.group(1)) + min_val = float(min_match.group(1)) + assert max_val > 0, "max duration should be positive" + assert min_val > 0, "min duration should be positive" + assert min_val <= max_val, "min duration should be <= max duration"