From 842635476ddb9429fd2c7fbe3b40856c10fa09f3 Mon Sep 17 00:00:00 2001 From: fullsend-code Date: Fri, 5 Jun 2026 12:05:41 +0000 Subject: [PATCH 1/3] feat(#13): expose Prometheus metrics via /metrics endpoint Add prometheus_client instrumentation to the Flask app with before_request/after_request hooks that track: - http_requests_total (Counter): request count per method, endpoint, and HTTP status code - http_request_duration_seconds (Histogram): request duration per method and endpoint, enabling average calculation via sum/count - http_request_duration_max_seconds (Gauge): maximum observed request duration per method and endpoint - http_request_duration_min_seconds (Gauge): minimum observed request duration per method and endpoint The /metrics endpoint itself is excluded from instrumentation to avoid recursive inflation of counters. Added prometheus_client>=0.20 to requirements.txt and pyproject.toml. Added 7 new tests covering the metrics endpoint response, request counting, duration tracking, status code labels, min/max gauges, and the /metrics exclusion behavior. Closes #13 --- app.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + requirements.txt | 1 + test_app.py | 67 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index a2d2561..af8d716 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,88 @@ -from flask import Flask, jsonify, request +from flask import Flask, g, jsonify, request from datetime import datetime, timezone +import time + +from prometheus_client import ( + Counter, + Gauge, + Histogram, + generate_latest, + CONTENT_TYPE_LATEST, +) app = Flask(__name__) +REQUEST_COUNT = Counter( + "http_requests_total", + "Total HTTP request count", + ["method", "endpoint", "status"], +) + +REQUEST_DURATION = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], +) + +REQUEST_DURATION_MAX = Gauge( + "http_request_duration_max_seconds", + "Maximum HTTP request duration in seconds", + ["method", "endpoint"], +) + +REQUEST_DURATION_MIN = Gauge( + "http_request_duration_min_seconds", + "Minimum HTTP request duration in seconds", + ["method", "endpoint"], +) + + +@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 + endpoint = request.path + 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) + + max_gauge = REQUEST_DURATION_MAX.labels(method=method, endpoint=endpoint) + min_gauge = REQUEST_DURATION_MIN.labels(method=method, endpoint=endpoint) + + current_max = max_gauge._value.get() + if current_max == 0.0 or duration > current_max: + max_gauge.set(duration) + + current_min = min_gauge._value.get() + if current_min == 0.0 or duration < current_min: + min_gauge.set(duration) + + return response + + VERSION = "0.1.0" +@app.route("/metrics") +def metrics(): + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} + + @app.route("/health") def health(): return jsonify( 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..ba761d8 100644 --- a/test_app.py +++ b/test_app.py @@ -1,12 +1,24 @@ import pytest +from prometheus_client import REGISTRY from app import app, reset_items +def _reset_collectors(): + """Clear all prometheus metric values between tests.""" + collectors = list(REGISTRY._names_to_collectors.values()) + for collector in collectors: + try: + collector._metrics.clear() + except AttributeError: + pass + + @pytest.fixture def client(): app.config["TESTING"] = True with app.test_client() as client: reset_items() + _reset_collectors() yield client @@ -63,3 +75,58 @@ 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 From 37a4f5cb5493715abcfc00281f221bbb70b887b3 Mon Sep 17 00:00:00 2001 From: fullsend-fix Date: Fri, 5 Jun 2026 12:23:32 +0000 Subject: [PATCH 2/3] fix(#14): address review feedback on metrics instrumentation - Fix race condition in min/max gauge updates by using a threading lock and a separate dict for tracking extremes instead of reading private Gauge._value API - Fix unbounded cardinality by using request.url_rule (route template) instead of request.path for the endpoint label - Use a custom CollectorRegistry to avoid exposing process-level collectors via /metrics - Add /metrics endpoint to README endpoints table - Improve min/max test to verify values are positive and min <= max - Replace REGISTRY._names_to_collectors access in test reset with direct metric object references Addresses review feedback on #14 Signed-off-by: fullsend-fix --- README.md | 1 + app.py | 47 +++++++++++++++++++++++++++++++++++------------ test_app.py | 46 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 74 insertions(+), 20 deletions(-) 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 af8d716..5283695 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,10 @@ from flask import Flask, g, jsonify, request from datetime import datetime, timezone +import threading import time from prometheus_client import ( + CollectorRegistry, Counter, Gauge, Histogram, @@ -12,30 +14,40 @@ app = Flask(__name__) +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]] = {} + @app.before_request def _start_timer(): @@ -54,23 +66,34 @@ def _record_metrics(response): return response duration = time.monotonic() - start - endpoint = request.path + 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) - max_gauge = REQUEST_DURATION_MAX.labels(method=method, endpoint=endpoint) - min_gauge = REQUEST_DURATION_MIN.labels(method=method, endpoint=endpoint) - - current_max = max_gauge._value.get() - if current_max == 0.0 or duration > current_max: - max_gauge.set(duration) - - current_min = min_gauge._value.get() - if current_min == 0.0 or duration < current_min: - min_gauge.set(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 @@ -80,7 +103,7 @@ def _record_metrics(response): @app.route("/metrics") def metrics(): - return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} + return generate_latest(registry), 200, {"Content-Type": CONTENT_TYPE_LATEST} @app.route("/health") diff --git a/test_app.py b/test_app.py index ba761d8..9d4fede 100644 --- a/test_app.py +++ b/test_app.py @@ -1,16 +1,29 @@ +import re + import pytest -from prometheus_client import REGISTRY -from app import app, reset_items +from app import ( + REQUEST_COUNT, + REQUEST_DURATION, + REQUEST_DURATION_MAX, + REQUEST_DURATION_MIN, + _extremes, + app, + reset_items, +) + +_ALL_METRICS = [ + REQUEST_COUNT, + REQUEST_DURATION, + REQUEST_DURATION_MAX, + REQUEST_DURATION_MIN, +] def _reset_collectors(): """Clear all prometheus metric values between tests.""" - collectors = list(REGISTRY._names_to_collectors.values()) - for collector in collectors: - try: - collector._metrics.clear() - except AttributeError: - pass + for metric in _ALL_METRICS: + metric._metrics.clear() + _extremes.clear() @pytest.fixture @@ -130,3 +143,20 @@ def test_metrics_tracks_min_max_duration(client): 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" From 724fbf2c82c80c184be3eebee65dc81a36bb150e Mon Sep 17 00:00:00 2001 From: fullsend-fix Date: Fri, 5 Jun 2026 12:49:08 +0000 Subject: [PATCH 3/3] refactor(#14): extract Prometheus metrics into separate module Move all Prometheus-related code (registry, metric objects, request hooks, /metrics endpoint) from app.py into a new metrics.py module. The app.py now calls init_metrics(app) to register the instrumentation. Test imports updated accordingly. Addresses human feedback on #14 Co-Authored-By: Claude Opus 4.6 Signed-off-by: fullsend-fix --- app.py | 105 ++-------------------------------------------------- metrics.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++ test_app.py | 5 +-- 3 files changed, 111 insertions(+), 104 deletions(-) create mode 100644 metrics.py diff --git a/app.py b/app.py index 5283695..7dcba93 100644 --- a/app.py +++ b/app.py @@ -1,111 +1,14 @@ -from flask import Flask, g, jsonify, request +from flask import Flask, jsonify, request from datetime import datetime, timezone -import threading -import time - -from prometheus_client import ( - CollectorRegistry, - Counter, - Gauge, - Histogram, - generate_latest, - CONTENT_TYPE_LATEST, -) -app = Flask(__name__) - -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]] = {} - - -@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 +from metrics import init_metrics +app = Flask(__name__) +init_metrics(app) VERSION = "0.1.0" -@app.route("/metrics") -def metrics(): - return generate_latest(registry), 200, {"Content-Type": CONTENT_TYPE_LATEST} - - @app.route("/health") def health(): return jsonify( 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/test_app.py b/test_app.py index 9d4fede..a996322 100644 --- a/test_app.py +++ b/test_app.py @@ -1,14 +1,13 @@ import re import pytest -from app import ( +from app import app, reset_items +from metrics import ( REQUEST_COUNT, REQUEST_DURATION, REQUEST_DURATION_MAX, REQUEST_DURATION_MIN, _extremes, - app, - reset_items, ) _ALL_METRICS = [