Monitoring & Observability · lesson 1 of 5
Intermediate
2Advanced
2Also part of Monitoring & Observability
3Also part of: DevOps
Prometheus Scrapes You — It's Pull, Not Push
Prometheus doesn't wait for your app to send it metrics. It calls your app's /metrics endpoint on a timer — and a wrong port doesn't error on your side, it just shows up as a real 'down' target in Prometheus's own API.
By SkillByExample · Updated
Prometheus never asks your application to send it anything. It shows up, on its own schedule, and asks your application for its current numbers.
A metrics endpoint, in plain text
# metrics_app.py (excerpt)
def do_GET(self):
if self.path == "/metrics":
body = (
"# HELP app_requests_total Total HTTP requests handled\n"
"# TYPE app_requests_total counter\n"
f"app_requests_total {request_count}\n"
"# HELP app_uptime_seconds Seconds since process start\n"
"# TYPE app_uptime_seconds gauge\n"
f"app_uptime_seconds {uptime:.2f}\n"
).encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4")
self.end_headers()
self.wfile.write(body)
curl -s http://localhost:8000/metrics
# HELP app_requests_total Total HTTP requests handled
# TYPE app_requests_total counter
app_requests_total 0
# HELP app_errors_total Total HTTP 500 responses
# TYPE app_errors_total counter
app_errors_total 0
# HELP app_uptime_seconds Seconds since process start
# TYPE app_uptime_seconds gauge
app_uptime_seconds 10.35
No Prometheus client library, no special protocol — plain text over HTTP, from Python’s
standard library alone. # TYPE ... counter versus ... gauge isn’t decoration: it’s how
Prometheus’s own tooling later knows a counter only ever increases and a gauge can move both
ways.
Prometheus does the asking
# prometheus.yml
global:
scrape_interval: 5s
scrape_configs:
- job_name: "demo-app"
static_configs:
- targets: ["metrics-app:8000"]
curl -s http://localhost:9090/api/v1/targets
{"status":"success","data":{"activeTargets":[{"discoveredLabels":{"__address__":"metrics-app:8000", ...},"labels":{"instance":"metrics-app:8000","job":"demo-app"},"scrapePool":"demo-app","scrapeUrl":"http://metrics-app:8000/metrics","lastError":"","lastScrape":"2026-09-14T06:55:34.557188228Z","lastScrapeDuration":0.016734081,"health":"up", ...}]}}
"health":"up" and an empty "lastError" confirm Prometheus successfully connected to the
app’s /metrics endpoint on its own — the app did nothing to make this scrape happen beyond
existing and answering when asked.
Querying what was scraped
curl -s "http://localhost:9090/api/v1/query?query=app_requests_total"
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"app_requests_total","instance":"metrics-app:8000","job":"demo-app"},"value":[1789368946.111,"0"]}]}}
The value Prometheus reports ("0") is exactly the number the app’s /metrics endpoint
printed at the last scrape — Prometheus stores what it pulled, on its own timeline, not
whatever the app’s value happens to be right now.
The bug: a wrong port doesn’t error where you’d expect
# prometheus.yml (wrong port on purpose)
scrape_configs:
- job_name: "demo-app"
static_configs:
- targets: ["metrics-app:9999"]
curl -s http://localhost:9090/api/v1/targets
{"status":"success","data":{"activeTargets":[{"discoveredLabels":{"__address__":"metrics-app:9999", ...},"labels":{"instance":"metrics-app:9999","job":"demo-app"},"scrapePool":"demo-app","scrapeUrl":"http://metrics-app:9999/metrics","lastError":"Get \"http://metrics-app:9999/metrics\": dial tcp 172.19.0.2:9999: connect: connection refused","lastScrape":"2026-09-14T06:56:40.825577992Z","lastScrapeDuration":0.001024624,"health":"down", ...}]}}
The application never saw this request at all — there’s nothing in the app’s own logs because
the connection was refused before it ever reached the app’s port. The failure is entirely on
Prometheus’s side, visible only by checking Prometheus’s own /api/v1/targets — "health": "down" with the real connection refused error naming the exact address it tried.
Practice
1. Query app_uptime_seconds directly and confirm it increases between two queries a few seconds apart.
curl -s "http://localhost:9090/api/v1/query?query=app_uptime_seconds"
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"app_uptime_seconds","instance":"metrics-app:8000","job":"demo-app"},"value":[1789369059.657,"179.23"]}]}}
A gauge like app_uptime_seconds is free to report any value each scrape — here it climbs
steadily because the app just reports real elapsed time, but a gauge could just as easily go
down (queue depth, active connections).
2. Hit the app's /work endpoint a few times, then confirm app_requests_total went up by exactly that many.
curl -s http://localhost:8000/work
curl -s http://localhost:8000/work
curl -s http://localhost:8000/work
curl -s "http://localhost:9090/api/v1/query?query=app_requests_total"
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"app_requests_total","instance":"metrics-app:8000","job":"demo-app"},"value":[1789369301.111,"3"]}]}}
The counter only reflects what the app itself counted — Prometheus doesn’t count requests to
/work on its own; it just reports whatever number the app’s /metrics endpoint prints at
the next scrape.
3. Query a metric name that doesn't exist and see what Prometheus returns.
curl -s "http://localhost:9090/api/v1/query?query=nonexistent_metric_name"
{"status":"success","data":{"resultType":"vector","result":[]}}
Still "status":"success" — an empty result set, not an error. A PromQL query for a metric
that was never scraped isn’t invalid syntax, it’s just a query that correctly finds nothing.
Next: PromQL’s most common gotcha — a raw counter value is almost never the number you actually want.