From 7c1e79534e7455458639fde1a077eac2a350520b Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Sun, 20 Sep 2026 11:15:45 -0400 Subject: [PATCH] Emit Prometheus 0.0.4 format version in /metrics Content-Type The /metrics endpoint returned `Content-Type: text/plain; charset=utf-8`, with no exposition format version. Prometheus v2 tolerated that and fell back to the 0.0.4 text format, but Prometheus v3 fails the scrape outright when the Content-Type carries no recognized format version. This surfaced during the DecaturMakers monitoring stack's v2 -> v3 upgrade, where the `kiosk` scrape job needed a `fallback_scrape_protocol` shim to keep working. The endpoint now sets `text/plain; version=0.0.4; charset=utf-8`, matching prometheus_client's CONTENT_TYPE_LATEST, as a new module-level PROMETHEUS_CONTENT_TYPE constant. Two notes on the implementation: - The issue suggested importing CONTENT_TYPE_LATEST from prometheus_client. This module hand-builds its exposition text and has no prometheus_client registry, so `generate_latest(registry)` does not apply without rewriting the whole collector. Rather than add a runtime dependency solely for a string constant, the value is defined locally with a comment recording that it is the same value. - The response is now built with `content_type=` instead of `mimetype=`. Werkzeug appends its own charset to a mimetype, so passing the full value as a mimetype produced a duplicated parameter -- the previous code was in fact emitting `text/plain; charset=utf-8; charset=utf-8`. Adds a test asserting the exact Content-Type header. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019XAhg2bNjxS9PMFUtTVT1J --- kiosk_show_replacement/metrics.py | 11 ++++++++++- tests/unit/test_metrics.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/kiosk_show_replacement/metrics.py b/kiosk_show_replacement/metrics.py index 0132526..83c2d3b 100644 --- a/kiosk_show_replacement/metrics.py +++ b/kiosk_show_replacement/metrics.py @@ -23,6 +23,13 @@ metrics_bp = Blueprint("metrics", __name__) +# Content type for the Prometheus text exposition format, version 0.0.4. +# The ``version`` parameter is required: Prometheus v3 fails a scrape whose +# Content-Type carries no recognized format version, where v2 silently fell +# back to this format. This is the same value as prometheus_client's +# ``CONTENT_TYPE_LATEST``. +PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8" + class MetricsCollector: """Thread-safe metrics collector for Prometheus-style metrics.""" @@ -504,7 +511,9 @@ def metrics_endpoint() -> Response: # Add summary metrics output += "\n" + get_summary_metrics() - return Response(output, mimetype="text/plain; charset=utf-8") + # Use content_type rather than mimetype: Werkzeug appends its own + # charset to a mimetype, which would duplicate the parameter here. + return Response(output, content_type=PROMETHEUS_CONTENT_TYPE) # Convenience functions for recording metrics from other modules diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index f2773b9..3f17f7d 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -8,6 +8,7 @@ """ from kiosk_show_replacement.metrics import ( + PROMETHEUS_CONTENT_TYPE, MetricsCollector, metrics_collector, record_database_error, @@ -28,6 +29,18 @@ def test_metrics_endpoint_returns_text_plain(self, app, client): response = client.get("/metrics") assert "text/plain" in response.content_type + def test_metrics_endpoint_declares_exposition_format_version(self, app, client): + """Test the Content-Type names the Prometheus 0.0.4 text format. + + Prometheus v3 fails a scrape whose Content-Type carries no recognized + format version, so the ``version`` parameter must be present, and the + header must not repeat ``charset``. + """ + response = client.get("/metrics") + + assert response.content_type == "text/plain; version=0.0.4; charset=utf-8" + assert response.content_type == PROMETHEUS_CONTENT_TYPE + def test_metrics_contains_http_requests_help(self, app, client): """Test metrics output contains HTTP requests metric definition.""" response = client.get("/metrics")