031 Project 31: Prometheus Exporter

Updated

September 8, 2026

031 Build a Prometheus Exporter

Expose custom application metrics at /metrics using the official Prometheus Go client. Instrument a small HTTP handler with counter, histogram, and gauge—the three metric types you will use constantly.

/work  -> update metrics
/metrics -> promhttp scrape text

Problem statement

Build a demo service on :9100:

  • GET /work simulates work and updates metrics
  • GET /metrics Prometheus text exposition
  • Metrics: demo_requests_total, demo_latency_seconds, demo_inflight

Acceptance criteria

  • /metrics returns Prometheus text format
  • Counter increases on /work
  • Histogram observes latency
  • Gauge tracks in-flight correctly (inc/dec with defer)
  • Module builds with client_golang

Setup

mkdir exporter && cd exporter
go mod init example.com/exporter
go get github.com/prometheus/client_golang/prometheus@latest
go get github.com/prometheus/client_golang/prometheus/promhttp@latest
# go 1.27
go mod tidy

Full main.go

package main

import (
    "log"
    "math/rand"
    "net/http"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
    reqTotal := prometheus.NewCounter(prometheus.CounterOpts{
        Name: "demo_requests_total",
        Help: "Total completed /work requests",
    })
    latency := prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "demo_latency_seconds",
        Help:    "Request latency for /work",
        Buckets: prometheus.DefBuckets,
    })
    inflight := prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "demo_inflight",
        Help: "In-flight /work requests",
    })
    prometheus.MustRegister(reqTotal, latency, inflight)

    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
        inflight.Inc()
        defer inflight.Dec()
        start := time.Now()
        time.Sleep(time.Duration(50+rand.Intn(200)) * time.Millisecond)
        reqTotal.Inc()
        latency.Observe(time.Since(start).Seconds())
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("ok\n"))
    })

    addr := ":9100"
    log.Println("listening", addr, " metrics=/metrics work=/work")
    log.Fatal(http.ListenAndServe(addr, mux))
}

Run and verification

go run .
# terminal B
curl -s localhost:9100/work
curl -s localhost:9100/metrics | grep demo_

Expected fragments:

demo_requests_total ...
demo_inflight ...
demo_latency_seconds_bucket ...

Load a bit:

for i in $(seq 1 20); do curl -s localhost:9100/work >/dev/null; done
curl -s localhost:9100/metrics | grep demo_requests_total

Step-by-step build path

  1. Define metric types and Help strings.
  2. MustRegister with default registry.
  3. Instrument handler with defer for gauges.
  4. Expose promhttp.Handler().
  5. Scrape with curl; later add Prometheus scrape config.

Label cardinality warning

// BAD: user_id label → unbounded series
// GOOD: low-cardinality labels: method, code, route template

Stretch goals

  1. CounterVec by HTTP method/status.
  2. Custom registry (not global) for tests.
  3. /healthz separate from metrics.
  4. Exemplars / native histograms (advanced).

Pitfalls

Pitfall Fix
High-cardinality labels static enum labels only
Forgetting gauge Dec always defer
Blocking default mux pollution use dedicated ServeMux
Measuring after sleep only observe full handler duration

Learning goals

  • Practical service metrics design
  • Counter / histogram / gauge roles
  • Observability by default for Go services