From 4f04a89031d88477f77aa3f3df95a4184db378bd Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Fri, 17 Jul 2026 18:12:48 +0000 Subject: [PATCH] add a prometheus /metrics endpoint (std.prometheus) first phase of observability support. std.prometheus.serve(host, port) runs an accept loop that answers GET /metrics with the text exposition of every registered std.metrics series (labels included), and 404 for anything else. spawn it alongside an app and prometheus scrapes it on its interval: spawn prometheus.serve("0.0.0.0", 9464) it reuses what already exists: metrics.snapshot_text() for the body and the std.net.http request/response helpers over the raw tcp accept primitives, so no server framework is needed. the endpoint is read-only and pull-based, so serve() just renders the current snapshot per request; concurrent scrapes are safe because the metric maps are mutex-guarded. verified: a unit test on the response (content type + snapshot), a live self-scrape (spawn serve, GET /metrics over the http client -> 200 with the labeled counter and gauge present), a new deterministic example, and 86 examples. --- examples/expected/prometheus_metrics.txt | 35 ++++++++++++++++ examples/prometheus_metrics.pith | 23 ++++++++++ std/prometheus.pith | 53 ++++++++++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 examples/expected/prometheus_metrics.txt create mode 100644 examples/prometheus_metrics.pith create mode 100644 std/prometheus.pith diff --git a/examples/expected/prometheus_metrics.txt b/examples/expected/prometheus_metrics.txt new file mode 100644 index 00000000..6f065aea --- /dev/null +++ b/examples/expected/prometheus_metrics.txt @@ -0,0 +1,35 @@ +# TYPE http_requests_total counter +http_requests_total 0 +http_requests_total{route="/api",status="200"} 3 +http_requests_total{route="/health",status="200"} 1 +# TYPE inflight_requests gauge +inflight_requests 2 +# TYPE request_latency_ms histogram +request_latency_ms_bucket{le="1"} 0 +request_latency_ms_bucket{le="5"} 0 +request_latency_ms_bucket{le="10"} 0 +request_latency_ms_bucket{le="25"} 0 +request_latency_ms_bucket{le="50"} 0 +request_latency_ms_bucket{le="100"} 0 +request_latency_ms_bucket{le="250"} 0 +request_latency_ms_bucket{le="500"} 0 +request_latency_ms_bucket{le="1000"} 0 +request_latency_ms_bucket{le="5000"} 0 +request_latency_ms_bucket{le="+Inf"} 0 +request_latency_ms_count 0 +request_latency_ms_sum 0 +request_latency_ms_bucket{le="1",route="/api"} 0 +request_latency_ms_bucket{le="5",route="/api"} 0 +request_latency_ms_bucket{le="10",route="/api"} 0 +request_latency_ms_bucket{le="25",route="/api"} 1 +request_latency_ms_bucket{le="50",route="/api"} 1 +request_latency_ms_bucket{le="100",route="/api"} 2 +request_latency_ms_bucket{le="250",route="/api"} 2 +request_latency_ms_bucket{le="500",route="/api"} 2 +request_latency_ms_bucket{le="1000",route="/api"} 2 +request_latency_ms_bucket{le="5000",route="/api"} 2 +request_latency_ms_bucket{le="+Inf",route="/api"} 2 +request_latency_ms_count{route="/api"} 2 +request_latency_ms_sum{route="/api"} 97 +request_latency_ms_min{route="/api"} 12 +request_latency_ms_max{route="/api"} 85 diff --git a/examples/prometheus_metrics.pith b/examples/prometheus_metrics.pith new file mode 100644 index 00000000..411cdfd1 --- /dev/null +++ b/examples/prometheus_metrics.pith @@ -0,0 +1,23 @@ +# expose pith metrics for prometheus. +# +# in a real app you spawn the endpoint and let prometheus scrape it: +# import std.prometheus as prometheus +# spawn prometheus.serve("0.0.0.0", 9464) +# prometheus then GETs http://host:9464/metrics on its scrape interval. here we +# just register a few labeled metrics and print exactly what a scrape returns. +import std.metrics as metrics + +fn main() -> Int!: + reqs := metrics.counter("http_requests_total") + reqs.labels(["route", "/api", "status", "200"]).add(3) + reqs.labels(["route", "/health", "status", "200"]).inc() + + metrics.gauge("inflight_requests").set(2) + + lat := metrics.histogram("request_latency_ms") + lat.labels(["route", "/api"]).observe(12) + lat.labels(["route", "/api"]).observe(85) + + # the text prometheus would receive from GET /metrics + print(metrics.snapshot_text()) + return 0 diff --git a/std/prometheus.pith b/std/prometheus.pith new file mode 100644 index 00000000..936072c1 --- /dev/null +++ b/std/prometheus.pith @@ -0,0 +1,53 @@ +# std.prometheus - expose pith metrics for prometheus to scrape. +# +# spawn serve() alongside your app to answer GET /metrics with the text +# exposition of every registered std.metrics series (labels included): +# +# import std.metrics as metrics +# import std.prometheus as prometheus +# +# fn main(): +# spawn prometheus.serve("0.0.0.0", 9464) +# metrics.counter("requests_total").labels(["route", "/api"]).inc() +# ... run your app ... +# +# the endpoint is read-only and pull-based: prometheus scrapes it on an interval, +# so serve() just renders the current snapshot on each request. + +import std.metrics as metrics +import std.net.http as http + +# prometheus's text-exposition format content type. +PROMETHEUS_CONTENT_TYPE := "text/plain; version=0.0.4; charset=utf-8" + +# the /metrics response for the current metric snapshot. +pub fn metrics_response() -> http.HttpResponse: + body := metrics.snapshot_text() + chr(10) + return http.response(200).with_body_text(body).header("Content-Type", PROMETHEUS_CONTENT_TYPE) + +# handle one scrape connection: answer GET /metrics, 404 for anything else. a +# request that fails to parse is dropped so it can't wedge the accept loop. +fn handle_scrape(fd: Int) -> Int!: + tcp_set_timeout(fd, 2000) + req := http.read_request_bytes(fd)! + if req.is_get("/metrics")!: + return http.send(fd, metrics_response()) + return http.send(fd, http.not_found_response()) + +# serve the /metrics endpoint on host:port. this blocks in an accept loop, so run +# it in the background: `spawn prometheus.serve("0.0.0.0", 9464)`. +pub fn serve(host: String, port: Int) -> Int!: + fd := tcp_listen(host, port)! + while true: + client := tcp_accept(fd)! + handle_scrape(client) catch 0 + return 0 + +test "metrics_response carries the prometheus content type and current snapshot": + metrics.reset() + metrics.counter("http_requests_total").labels(["route", "/api"]).inc() + resp := metrics_response() + rendered := resp.to_bytes()!.to_string_utf8()! + assert(rendered.contains("Content-Type: text/plain; version=0.0.4")) + assert(rendered.contains("# TYPE http_requests_total counter")) + assert(rendered.contains("http_requests_total" + chr(123) + "route=" + chr(34) + "/api" + chr(34) + chr(125) + " 1"))