Collect the numbers, ask questions, draw them
— one lap around a minimal metrics setup
top, it's too late$ top Processes: 612 total, 3 running CPU usage: 78.4% user, 9.1% sys # so what about 30 minutes ago? …no record
| Pillar | What it is | Typical tools | Today |
|---|---|---|---|
| Metrics | Numbers over time — CPU, memory, request counts | Prometheus, Datadog | covered |
| Logs | Event records — error messages, request logs | Loki, ELK Stack | not covered |
| Traces | Request paths — calls between services | Tempo, Jaeger | Part 3 |
| Part | Title | Area |
|---|---|---|
| Part 1 | Prometheus and Grafana Basics | Metrics basics |
| Part 2 | Custom Metrics in a Go Application | Metrics in depth |
| Part 3 | Distributed Tracing with Grafana Tempo | Traces |
| Part 4 | Continuous Profiling with Grafana Pyroscope | Profiles |
Some screens today borrow metrics from the sample app built in part 2.
The environment we set up today shows node-exporter and Prometheus' own metrics.
01 — Prometheus core concepts
02 — PromQL
03 — Grafana
04 — Hands-on setup
How metrics come in, and what shape they are stored in. Get this and PromQL and dashboards are just stories told on top of it.
As long as a target exposes a /metrics HTTP endpoint, Prometheus calls that address on a schedule (scrape) and pulls the values. The target never has to know where to send anything.
The arrows show which way the data flows. The calls go the other way — Prometheus calls each target's /metrics.
| Aspect | Pull — Prometheus | Push — Datadog, InfluxDB |
|---|---|---|
| Data flow | Server fetches from targets | Targets send to the server |
| Service discovery | Needed — must know what to scrape | Not needed — targets send directly |
| Network | Server → target reachability | Target → server reachability |
| Upside | Target health for free — failed scrape = down | Works behind a firewall |
| Downside | Hard to scrape targets behind firewalls | Missing data from a dead target is hard to spot |
| Type | Behavior | Example use | Key functions |
|---|---|---|---|
| Counter | Only goes up (back to 0 on restart) | HTTP request count, error count | rate(), increase() |
| Gauge | Goes up and down | CPU usage, memory, active connections | read directly, avg_over_time() |
| Histogram | Spreads values across buckets | Response time, request size | histogram_quantile() |
| Summary | Stores pre-computed quantiles | Response time (client-side) | read directly |
http_requests_total
All you can read is “it keeps going up”. The drop to the floor at 14:11 is an application restart.
rate(http_requests_total[5m])
5–10 per second — the real traffic shows. The line stays smooth across the restart — counter resets are corrected automatically.
rate() first.
rate()# in-flight requests right now http_requests_in_flight # 5-minute average avg_over_time(node_cpu_seconds_total{mode="idle"}[5m])
Here too you can see memory dip at the 14:11 restart and fill back up.
le="0.5" means everything under 0.5s, not the 0.25–0.5s sliceHeatmap# P99 response time histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
| Aspect | Histogram | Summary |
|---|---|---|
| Quantile computed | Server (at query time) | Client (at collection) |
| Merge across instances | Possible | Impossible |
| Change buckets/quantiles | Edit config, restart | Code change required |
| Recommended for | Most cases | When exact quantiles are a must |
A time series = one name tag with (timestamp, value) pairs appended forever. Every dot on a graph is one row.
time value
14:30:00 1,204
14:30:15 1,251 ← one row every 15s (scrape interval)
14:30:30 1,298
14:30:45 1,344
One name tag only tells you “requests went up” — not which API, or how many failed. So we attach labels.
http_requests_total{method="GET" , path="/api/orders", status="200"}
http_requests_total{method="POST", path="/api/orders", status="201"}
http_requests_total{method="POST", path="/api/orders", status="500"}
Every single series costs memory. Prometheus simply falls over. This is called a cardinality explosion.
Fine as a label
methodstatuspathSend it to logs
user_idsession_idhttp_requests_total is not built into Prometheus. Every one of these names was typed into someone's code.
var HttpRequestsTotal = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "http_requests_total", Help: "Total number of HTTP requests", }, []string{"method", "path", "status"}, )
The third argument lists the label names this metric will use. To Prometheus a name is just a string, so
my_stuff_counter would have worked identically.
| Official naming rules | Example |
|---|---|
| One domain word up front | node_ · process_ · prometheus_ |
| Plural unit at the end | _seconds · _bytes |
| Cumulative counts end in _total | http_requests_total |
| Where a name comes from | Who picks it |
|---|---|
| node_cpu_seconds_total | exporter author |
| go_gc_duration_seconds | registered by the library |
| http_requests_total | application developer |
No need to memorize names. Ask with curl -s :9090/api/v1/metadata, or use the autocomplete in the Prometheus UI.
# TYPE http_requests_total counter http_requests_total{method="GET",path="/api/orders",status="200"} 272 http_requests_total{method="POST",path="/api/orders",status="201"} 64 http_requests_total{method="POST",path="/api/orders",status="500"} 8 # TYPE http_requests_in_flight gauge http_requests_in_flight 0 # TYPE http_request_duration_seconds histogram ..._bucket{le="0.1"} 7 ..._bucket{le="0.25"} 32 ..._bucket{le="0.5"} 72 ..._bucket{le="+Inf"} 72 ..._sum 19.577 ..._count 72 # TYPE go_gc_duration_seconds summary go_gc_duration_seconds{quantile="0.5"} 4.82e-05 go_gc_duration_seconds_sum 8.74e-05 go_gc_duration_seconds_count 2
# TYPE line is where the type comes from. It starts with # but it is metadata, not a comment7 → 32 → 72 and then holding is what cumulative means — all 72 finished within 0.5sA Grafana panel is, in the end, one line of PromQL. There is no building a dashboard without it.
http_requests_total → keep only status="500" → take the last 5 minutes → turn it into a per-second rate → sum it by path.
| Operator | Meaning | Example |
|---|---|---|
| = | value is equal | {method="GET"} |
| != | value differs | {device!="lo"} |
| =~ | matches the regex | {status=~"5.."} |
| !~ | does not match it | {path!~"/health.*"} |
{status=~"5.."}, . is any single character. 500, 502 and 503 all match — that is, “all 5xx”.
# comma-separated conditions must all match http_requests_total{method="GET", status=~"5.."}
Dropping loopback with device!="lo" on a network panel,
or picking one filesystem with mountpoint="/" on a disk panel — it all happens right here.
[5m] is attachedThe most confusing spot in PromQL, because every function demands a different shape of input.
instant vector — one value, right now
http_requests_total
# 1 value per series (e.g. 1,344)
Enough when you just want to look at a Gauge.
range vector — every value in the window
http_requests_total[5m] # 20 values per series (15s × 5m)
rate() is “increase ÷ elapsed time”, so it needs at least two values.
rate(http_requests_total[5m]) ✓ works rate(http_requests_total) ✗ error
| Function | Meaning |
|---|---|
| sum() | adds them all up |
| avg() | takes the average |
| max() / min() | largest / smallest |
| count() | counts how many series |
sum(rate(http_requests_total[5m])) → total traffic · 1 line sum by(path) (rate(http_requests_total[5m])) → traffic per path · one line per path sum by(path, status) (...) → per path × status combination
by = keep only these labels. without = drop these labels and keep the rest.
| Function | What it does | Metric type | Example |
|---|---|---|---|
| rate() | per-second average rate | Counter | rate(http_requests_total[5m]) |
| increase() | total increase over a window | Counter | increase(http_requests_total[1h]) |
| histogram_quantile() | computes a percentile | Histogram | histogram_quantile(0.99, rate(...)) |
| avg_over_time() | average over a window | Gauge | avg_over_time(metric[5m]) |
rate(node_memory_MemAvailable_bytes[5m]) → no error. Computes on a Gauge (result is nonsense) rate(http_requests_total) → error. not a range vector
Errors catch the vector type only; matching the metric type is on you.
The name is a decent guess — _total means Counter, a _bucket/_sum/_count set means Histogram, a quantile label means Summary.
100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
sum() you always get 1sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100
sum(), the left {status="500"} pairs with the right {status="500"} — dividing something by itself, so always 1.
# without sum — always 1 {method="POST", path="/api/orders", status="500"} 1 # after sum strips the labels — the real rate {} 5.5
The point is removing status from the labels,
so instead of sum() you can name what to keep with by. For a per-path error rate:
sum by(path) (rate(http_requests_total{status=~"5.."}[5m])) / sum by(path) (rate(http_requests_total[5m])) * 100
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
rate() — both are Gauges, so the current value is the answerinstance, job labels, so they pair up by themselves. That is where this parts ways with the error-rate query(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100
mountpoint. Skip it and you get /, /boot and Docker overlays toocurl -s :9100/metrics | grep "^node_filesystem_size_bytes".
An open-source dashboard tool that plugs into many data sources and visualizes them. Besides Prometheus it can talk to Loki, Tempo, MySQL and Elasticsearch.
Option 1 — from the UI
prometheus:9090, not localhost:9090 — Grafana runs in a container too, so it must reach it by container name.
# Option 2 — auto-register via provisioning apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true
| Panel type | Good for |
|---|---|
| Time series | CPU usage, request rate, response time |
| Stat | Current uptime, total requests |
| Gauge | Disk usage, memory usage |
| Table | Per-instance status, Top N lists |
| Bar chart | Errors per service, requests per endpoint |
| Heatmap | Response time distribution, Histogram buckets |
| Field | Value |
|---|---|
| Name | instance |
| Type | Query |
| Data source | Prometheus |
| Query | label_values(node_cpu_seconds_total, instance) |
| Multi-value | enabled |
100 - (avg by(instance) (rate(node_cpu_seconds_total{ mode="idle", instance=~"$instance"}[5m])) * 100)
Pick an instance from the dropdown and only that instance's metrics remain.
Add servers and the list grows with whatever label_values() finds.
Bring up Prometheus · Grafana · node-exporter with a single docker-compose file, then build a four-panel system monitoring dashboard.
services: prometheus: image: prom/prometheus:v2.51.0 ports: ["9090:9090"] volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.retention.time=7d' grafana: image: grafana/grafana:11.4.0 ports: ["3000:3000"] environment: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - ./provisioning:/etc/grafana/provisioning node-exporter: image: prom/node-exporter:v1.8.1 ports: ["9100:9100"]
> docker compose up -d
| Service | Address |
|---|---|
| Prometheus | localhost:9090 |
| Grafana | localhost:3000 · admin/admin |
| node-exporter | localhost:9100/metrics |
--storage.tsdb.retention.time=7d — 7 days for a local lab. In production this is traded against disk.
scrape_configs decides what gets scrapedglobal: scrape_interval: 15s # scrape period evaluation_interval: 15s # rule evaluation period scrape_configs: # Prometheus' own metrics - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] # node-exporter system metrics - job_name: 'node-exporter' static_configs: - targets: ['node-exporter:9100']
scrape_interval: 15s — this becomes the spacing between points in a time seriesjob_name becomes the job label attached to every seriesstatic_configs, but real operations wire in service discovery such as Kubernetes or ConsulDOWN, no amount of dashboard tweaking gives you anything but empty graphs
# 1. CPU usage (%) · Unit: Percent (0-100) 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) # 2. Memory usage (%) (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 # 3. Disk I/O · Unit: Bytes/sec (IEC) rate(node_disk_read_bytes_total[5m]) rate(node_disk_written_bytes_total[5m]) # 4. Network traffic · Unit: Bytes/sec (IEC) rate(node_network_receive_bytes_total{device!="lo"}[5m]) rate(node_network_transmit_bytes_total{device!="lo"}[5m])
device=~"nvme.*|sd.*", or turn the legend off.
/metrics endpoints exporters exposerate() · increase() · histogram_quantile() · avg_over_time()rate() corrects for that break, so the line does not jump there. → Slide 10[5m] to make it a range vector. → Slide 21DOWN there is no data at all, so touching queries or panel settings is pointless. → Slide 34One step past the system metrics node-exporter gives you, we instrument a Go application directly. HTTP request counts and response times, of course, plus business metrics like order volume, all the way up into Grafana.
References
github.com/kenshin579/tutorials-go /monitoring/grafana-metricsprometheus.io/docsprometheus.io/docs/prometheus/latest/querying/basicsgrafana.com/docs/grafana/latestgithub.com/prometheus/node_exportergrafana.com/docs/grafana/latest/dashboards/build-dashboards/best-practicesadvenoh.pe.kr