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

// 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'));
// 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

  1. 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/metrics
    
    HTTP/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: chunked

    200 and text/plain with version=0.0.4. That parameter names the text exposition format.

  2. Step 2.

    Read the body and confirm each name has a # HELP and a # TYPE line 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.8

    Two names, three samples. app_uptime_seconds carries no labels, which is legal.

  3. Step 3.

    List what the endpoint exposes, with the series count per name.

    node list.mjs http://127.0.0.1:39117/metrics
    
      2 COUNTER   http_requests_total
    1 GAUGE     app_uptime_seconds
    2 metric names, 300 bytes, text/plain; version=0.0.4; charset=utf-8

    This is the inventory to review, not the raw body. Two names, three series, 300 bytes.

  4. 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/metrics
    
      7 COUNTER   http_requests_total
    1 GAUGE     app_uptime_seconds
    2 metric names, 581 bytes, text/plain; version=0.0.4; charset=utf-8

    Five requests, five new series, and the body nearly doubled. The metric name count did not move, so a review that counts names sees nothing.

  5. Step 5.

    Point the same reader at the JSON twin of the endpoint.

    node list.mjs http://127.0.0.1:39117/metrics.json
    
    0 metric names, 133 bytes, application/json

    133 bytes of real data, zero metrics, and no error anywhere. This is the commonest cause of "my metrics do not appear".

  6. 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-8

    Character 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

Sign: The scrape works from the shell and returns nothing under an agent.Cause: A 200 with a JSON or HTML body is not a scrape failure. Nothing in the response says so, and the parser used here returned zero names with exit status 0. Assert on the number of samples, never on the status code.
Sign: The metrics list looks the same in staging and production, but production writes far more data.Cause: The count of metric names is identical while the count of series differs by orders of magnitude. Step 4 shows two names covering seven series. Read the series count, which is what the storage pays for.
Sign: A local request to 127.0.0.1 reports that nothing is listening while the server prints that it started.Cause: A sandboxed shell refuses loopback connections before the request leaves. Confirm the listener with netstat before deciding the endpoint is down.

What to check next

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.

basic8 minpublished updated Maks Verny