How to check metrics endpoint
Run curl -sS -D - -o /dev/null http://127.0.0.1:39117/metrics and read two lines: the status and Content-Type. A scraper wants text/plain; version=0.0.4. Then parse the body and count series per metric name. The run below answered 200 and grew from two series to seven.
Why check this
Run this after wiring a new exporter, after a reverse proxy or ingress rule changes, and before anyone builds an alert on the numbers. A metrics endpoint fails quietly. Nothing errors, the dashboard is empty, and the team argues about the scrape config for a day.
Two failures this catches. The endpoint answers 200 with a body no scraper can read, which is what happens when a service hands /metrics to its JSON serialiser. And an id in a label, which turns one metric name into a series per order and fills the time series database that every other team also uses.
Prerequisites
- Node 22 and curl. The commands here use the Git Bash
curl 8.1.2against a loopback port, so no TLS or HTTP/2 support is involved. npm i parse-prometheus-text-format prom-client. The exposition formats page is the grammar these commands check against.- Everything below runs on loopback, so no proxy, ingress or authentication layer sits between the request and the app. Repeat the same steps against the deployed URL before trusting the result.
- A local target. Save this as
metrics-app.mjsand start it withnode metrics-app.mjs. It counts requests per path, which is the shape most web frameworks ship.
// metrics-app.mjs node 22, ESM. One app, one /metrics, one JSON twin.
import { createServer } from 'node:http';
const hits = new Map();
const startedAt = Date.now();
createServer((req, res) => {
const path = req.url.split('?')[0];
if (path !== '/metrics' && path !== '/metrics.json') {
hits.set(path, (hits.get(path) || 0) + 1);
}
if (path === '/metrics') {
const body = [
'# HELP http_requests_total Requests handled, by path.',
'# TYPE http_requests_total counter',
...[...hits].map(([p, n]) => `http_requests_total{method="GET",path="${p}"} ${n}`),
'# HELP app_uptime_seconds Seconds since process start.',
'# TYPE app_uptime_seconds gauge',
`app_uptime_seconds ${((Date.now() - startedAt) / 1000).toFixed(1)}`,
'',
].join('\n');
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
return res.end(body);
}
if (path === '/metrics.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ http_requests_total: Object.fromEntries(hits) }));
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok\n');
}).listen(39117, '127.0.0.1', () => console.log('app on 127.0.0.1:39117'));
- The reader, saved as
list.mjs. It prints one line per metric name with the number of series behind it.
// list.mjs <url> : series count and type per metric name.
import parse from 'parse-prometheus-text-format';
const res = await fetch(process.argv[2]);
const body = await res.text();
const parsed = parse(body);
for (const m of parsed) console.log(String(m.metrics.length).padStart(3), m.type.padEnd(9), m.name);
console.log(`${parsed.length} metric names, ${body.length} bytes, ${res.headers.get('content-type')}`);
Steps
- Step 1.
Send some traffic, then read the response headers of the endpoint itself.
curl -sS -D - -o /dev/null http://127.0.0.1:39117/metricsHTTP/1.1 200 OK Content-Type: text/plain; version=0.0.4; charset=utf-8 Date: Sat, 12 Sep 2026 07:35:24 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked200 and
text/plainwithversion=0.0.4. That parameter names the text exposition format. - Step 2.
Read the body and confirm each name has a
# HELPand a# TYPEline above its samples.curl -s http://127.0.0.1:39117/metrics# HELP http_requests_total Requests handled, by path. # TYPE http_requests_total counter http_requests_total{method="GET",path="/hello"} 2 http_requests_total{method="GET",path="/orders"} 1 # HELP app_uptime_seconds Seconds since process start. # TYPE app_uptime_seconds gauge app_uptime_seconds 9.8Two names, three samples.
app_uptime_secondscarries no labels, which is legal. - Step 3.
List what the endpoint exposes, with the series count per name.
node list.mjs http://127.0.0.1:39117/metrics2 COUNTER http_requests_total 1 GAUGE app_uptime_seconds 2 metric names, 300 bytes, text/plain; version=0.0.4; charset=utf-8This is the inventory to review, not the raw body. Two names, three series, 300 bytes.
- Step 4.
Send five requests whose path carries an id, then list again.
for i in 1001 1002 1003 1004 1005; do curl -s -o /dev/null "http://127.0.0.1:39117/orders/$i"; done; node list.mjs http://127.0.0.1:39117/metrics7 COUNTER http_requests_total 1 GAUGE app_uptime_seconds 2 metric names, 581 bytes, text/plain; version=0.0.4; charset=utf-8Five requests, five new series, and the body nearly doubled. The metric name count did not move, so a review that counts names sees nothing.
- Step 5.
Point the same reader at the JSON twin of the endpoint.
node list.mjs http://127.0.0.1:39117/metrics.json0 metric names, 133 bytes, application/json133 bytes of real data, zero metrics, and no error anywhere. This is the commonest cause of "my metrics do not appear".
- Step 6.
Confirm the header string your service sends is the one an instrumentation library sends.
node -e "import('prom-client').then((c) => console.log(c.default.register.contentType))"text/plain; version=0.0.4; charset=utf-8Character for character the header from step 1. Compare your own service against this rather than against memory.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 and version=0.0.4 | The endpoint serves the text exposition format | Move on to the body and the series count. |
| 200 and application/json | The route was handed to a JSON serialiser | Return the text format. The scrape stores nothing and reports no error. |
| 200 and zero metric names | The body parsed but held no samples | Check the registry is the one the handler reads from. |
| Series count rising per request | A label carries an id or a raw URL path | Replace the value with the route template before this reaches production. |
| 404 on /metrics | The route is not mounted, or a proxy swallowed it | Request the app port directly, then the proxy, and compare. |
Common mistakes
What to check next
- Prometheus text exposition format: the grammar of the body, and the four bucket rules a hand-rolled histogram usually breaks.
- Prometheus metrics endpoint authentication: who else can reach the endpoint you just read.
- How to test a health check endpoint: the other endpoint every deployment asks about.
- X request id header: ties a single slow request back to the counters here.
FAQ
How to check available metrics in Prometheus?
Read the exporter directly. curl -s host:port/metrics | grep '^# TYPE' lists every name and its type in the order the process exposes them. Step 3 does the same with the series count added, which is the number worth reviewing.
What Content-Type must a metrics endpoint return?
text/plain; version=0.0.4, optionally with charset=utf-8. Step 6 shows prom-client 15.1.3 sending exactly text/plain; version=0.0.4; charset=utf-8.
Does a 200 mean the endpoint is healthy?
No. Step 5 returned 200 with a body that produced zero metrics. Assert on the parsed sample count instead.
How do I know the numbers are live?
Scrape, send one request to the app, scrape again, and compare one counter. A value that never moves points at a cached response or a static file behind the route.
Verified
Verified by Maks Vernynode 22.23.2curl 8.1.2parse-prometheus-text-format 1.1.1prom-client 15.1.3
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic8 minpublished updated Maks Verny