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"))