textmachine/platform/internal/metrics/metrics.go

356 lines
16 KiB
Go

// Package metrics is the platform's telemetry: the numbers an operator needs to see that the
// control plane is doing its job, in the form the ecosystem scrapes.
//
// # Why a Prometheus client and not expvar
//
// stdlib `expvar` is the alternative the zone's own norm points at first ("stdlib before a library",
// ENGINEERING_STANDARDS §1), and it does not carry this job: it has no labels, so "requests by route
// and status" cannot be expressed at all; no histograms, so a latency question is answerable only as
// an average, which is the one statistic that hides the tail; and its JSON is not a format any
// scraper reads without a translator. What it would save is a dependency, and what it would cost is
// writing the missing three by hand — which is the self-written path the same norm rules out.
//
// `prometheus/client_golang` v1.24.1 (released 24.07.2026, pin verified live) is the de-facto
// standard exposition, and the OpenTelemetry alternative is heavier for what this deployment is: a
// collector process to run, an export protocol to configure, and a scrape endpoint at the end of it
// anyway. The baseline this axis is measured against — the external one PD-115 asks for — is
// Prometheus's own naming practices (base units, `_total` on counters, no units in labels) and the
// four golden signals for WHAT to expose.
//
// # Cardinality
//
// Labels carry the route PATTERN, never the path: a raw path carries book and run ids, which is a
// user's library in an operator's index (PD-3) and an unbounded number of time series besides.
package metrics
import (
"math"
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// namespace prefixes every series of this service.
const namespace = "tm_platform"
// Metrics is the registry and the instruments of one process.
type Metrics struct {
registry *prometheus.Registry
// The runner's state, refreshed by the sweep. Gauges rather than collectors that query on
// scrape: the sweep already reads the database on a timer, and a scrape that ran its own queries
// would let anyone with access to the endpoint set the load on the control plane's database.
queueDepth prometheus.Gauge
oldestHold prometheus.Gauge
quarantined prometheus.Gauge
parked prometheus.Gauge
liveRuns prometheus.Gauge
tailerLag prometheus.Gauge
booksInIntake *prometheus.GaugeVec
sweepDuration *prometheus.HistogramVec
sweepUnfinished *prometheus.CounterVec
// stalledRuns and abandonedSurfaces are gauges rather than counters: the question is "how many
// are stuck RIGHT NOW", and a counter of crossings answers a different one — it never comes back
// down when the operator has dealt with them.
stalledRuns prometheus.Gauge
abandonedSurfaces prometheus.Gauge
// backupAge is how long ago the newest restore point was taken. In SECONDS, which is the base
// unit Prometheus naming asks for, and a GAUGE rather than a counter of successes: a job that
// has failed every night for a week and one that was never configured both show a flat success
// counter, while age answers the question an alert is actually for — how much of the paid work
// and of the credit ledger cannot be recovered right now.
//
// ⚠ It is set to +Inf when there is no restore point at all. NaN would be the other candidate
// and it is the wrong one: `> threshold` is false for NaN, so an instance that never made a
// backup would sit under every alert ever written for this series.
backupAge prometheus.Gauge
// The host's cap on concurrent engine cuts, and how hard it is being hit. Without these a cap is
// indistinguishable from latency an operator has to guess the cause of — which is what the intake
// looked like when it had no cap at all.
cutsInFlight prometheus.Gauge
cutsWaiting prometheus.Gauge
cutSlots prometheus.Gauge
cutWaits prometheus.Counter
cutGiveUps prometheus.Counter
// What the two counters above have already been told. The service counts cumulatively and a
// Prometheus counter takes increments, so the difference is what is added; both are touched only
// from the telemetry pass, which is one goroutine (cmd/tmplatformd, observe).
countedCutWaits, countedCutGiveUps uint64
requests *prometheus.CounterVec
latency *prometheus.HistogramVec
}
// New builds the instruments on a registry of this process's own.
//
// Its own, and not the package-global default: a global registry is shared with every library that
// ever registers into it, and a duplicate registration there is a panic at init in a process whose
// job is to stay up.
func New() *Metrics {
m := &Metrics{registry: prometheus.NewRegistry()}
m.queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "queue_depth",
Help: "Jobs in the queue that have not finished (pending, available, running, scheduled or retryable).",
})
m.oldestHold = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "oldest_open_hold_seconds",
Help: "Age of the oldest reservation still open. A hold outlives its run only when a settlement could not be made.",
})
m.quarantined = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "quarantined_attempts",
Help: "Live attempts whose journal is no longer being materialized. Such a run keeps going and keeps spending.",
})
m.parked = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "parked_attempts",
Help: "Live attempts whose journal continued under another stream id, so their projection stopped there. Such a run keeps going and keeps spending; unlike a quarantine, nobody has to lift this.",
})
m.liveRuns = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "live_runs",
Help: "Runs the database believes are still going.",
})
m.tailerLag = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "tailer_lag_bytes",
Help: "Largest number of bytes any live run's journal holds beyond the platform's cursor.",
})
m.booksInIntake = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace, Name: "books_in_intake",
Help: "Books that have not finished intake, by status.",
}, []string{"status"})
m.sweepDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: namespace, Name: "sweep_duration_seconds",
Help: "How long one pass of a sweep took.",
Buckets: sweepBuckets,
}, []string{"sweep"})
m.sweepUnfinished = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace, Name: "sweep_unfinished_total",
Help: "Passes that ran out of their time budget with work left. A rising count is starvation: the tail of the list is never reached (register row PD-169).",
}, []string{"sweep"})
m.stalledRuns = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "runs_stalled",
Help: "Run attempts the reconciler has failed on enough times running to need an operator, whether the run is still live or has ENDED with its money unsettled (tmplatformctl runs).",
})
m.abandonedSurfaces = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "reading_surfaces_abandoned",
Help: "Books whose reading surface could not be materialized and was written off; their text is whatever was materialized before (tmplatformctl books --abandoned).",
})
m.backupAge = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "backup_age_seconds",
Help: "Age of the newest restore point; +Inf when this deployment has none.",
})
m.backupAge.Set(math.Inf(1))
m.requests = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace, Name: "http_requests_total",
Help: "Requests served, by route pattern, method and status.",
}, []string{"route", "method", "code"})
m.latency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: namespace, Name: "http_request_duration_seconds",
Help: "Time to serve a request, by route pattern and method.",
Buckets: latencyBuckets,
}, []string{"route", "method"})
m.cutsInFlight = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "cuts_in_flight",
Help: "Engine cuts running right now, across the upload that cuts its own book, the queue's workers and the backstop sweep.",
})
m.cutsWaiting = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "cuts_waiting",
Help: "Cuts queued for a slot right now. Sustained above zero means the cap, not the engine, is what shapes intake latency.",
})
m.cutSlots = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: namespace, Name: "cut_slots",
Help: "The cap itself (TM_PLATFORM_MAX_CUTS), published so saturation can be read without knowing the deployment's configuration.",
})
m.cutWaits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "cut_waits_total",
Help: "Cuts that found every slot taken and had to wait for one.",
})
m.cutGiveUps = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: namespace, Name: "cut_slot_timeouts_total",
Help: "Cuts whose caller ran out of budget while waiting for a slot. An upload counted here is accepted `parsing` and finished by the queue, not refused.",
})
m.registry.MustRegister(m.queueDepth, m.oldestHold, m.quarantined, m.parked, m.liveRuns, m.tailerLag,
m.booksInIntake, m.sweepDuration, m.sweepUnfinished, m.stalledRuns, m.abandonedSurfaces,
m.backupAge, m.requests, m.latency,
m.cutsInFlight, m.cutsWaiting, m.cutSlots, m.cutWaits, m.cutGiveUps,
// The runtime and the process itself: memory, goroutines, file descriptors, CPU. They are
// what answers "is this instance healthy" when none of the numbers above has moved.
collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
return m
}
// sweepBuckets span one tick to well past the sweep's own budget: the question they answer is "is a
// pass still finishing", and the interesting shape is entirely in the tail.
var sweepBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300}
// latencyBuckets carry both kinds of request this service serves: a read that must be milliseconds
// and an upload that is legitimately minutes.
var latencyBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 300}
// Runner is what the reconciler's sweep observed about the world. Written as one struct because the
// numbers are read together, in one query, and a set of them from different moments would describe a
// state that never existed.
type Runner struct {
QueueDepth int64
OldestHoldSeconds float64
QuarantinedAttempts int64
LiveRuns int64
BooksUploading int64
BooksParsing int64
// StalledRuns and AbandonedSurfaces are the two states an operator has to ACT on, as opposed to
// wait out. `sweep_unfinished_total` said a pass ran out of time and never said on what, so it
// could rise for days with nothing to look at (register row PD-169); these name the state, and
// `tmplatformctl runs` / `books --abandoned` name the rows behind each.
StalledRuns int64
AbandonedSurfaces int64
// ParkedAttempts counts live attempts whose projection parked. Its own series: a park and a
// quarantine freeze a run's figures the same way and take opposite actions (PD-438).
ParkedAttempts int64
}
// ObserveBackupAge publishes how old the newest restore point is. `has` false means there is none,
// and the series then reads +Inf — see the field.
func (m *Metrics) ObserveBackupAge(age time.Duration, has bool) {
if m == nil {
return
}
if !has {
m.backupAge.Set(math.Inf(1))
return
}
m.backupAge.Set(age.Seconds())
}
// ObserveRunner publishes one reading of the runner's state.
func (m *Metrics) ObserveRunner(r Runner) {
if m == nil {
return
}
m.queueDepth.Set(float64(r.QueueDepth))
m.oldestHold.Set(r.OldestHoldSeconds)
m.quarantined.Set(float64(r.QuarantinedAttempts))
m.parked.Set(float64(r.ParkedAttempts))
m.liveRuns.Set(float64(r.LiveRuns))
m.booksInIntake.WithLabelValues("uploading").Set(float64(r.BooksUploading))
m.booksInIntake.WithLabelValues("parsing").Set(float64(r.BooksParsing))
m.stalledRuns.Set(float64(r.StalledRuns))
m.abandonedSurfaces.Set(float64(r.AbandonedSurfaces))
}
// Cuts is one reading of the host's cap on concurrent engine cuts. Waited and GaveUp are cumulative
// since the process started, which is what lets this publish increments to a counter.
type Cuts struct {
Limit int
InFlight int
Waiting int
Waited uint64
GaveUp uint64
}
// ObserveCuts publishes where the host's cut capacity stands.
func (m *Metrics) ObserveCuts(c Cuts) {
if m == nil {
return
}
m.cutSlots.Set(float64(c.Limit))
m.cutsInFlight.Set(float64(c.InFlight))
m.cutsWaiting.Set(float64(c.Waiting))
// Only ever forward. A reading below what was already counted would mean the process restarted
// under the same registry, which cannot happen — but a counter that went backwards would break
// every rate() over it, so the guard costs one comparison and removes the class.
if c.Waited > m.countedCutWaits {
m.cutWaits.Add(float64(c.Waited - m.countedCutWaits))
m.countedCutWaits = c.Waited
}
if c.GaveUp > m.countedCutGiveUps {
m.cutGiveUps.Add(float64(c.GaveUp - m.countedCutGiveUps))
m.countedCutGiveUps = c.GaveUp
}
}
// ObserveTailerLag publishes how far the furthest-behind live run's cursor is from the end of its
// journal, in bytes.
func (m *Metrics) ObserveTailerLag(bytes int64) {
if m == nil {
return
}
m.tailerLag.Set(float64(bytes))
}
// ObserveSweep records one pass. unfinished means the pass ran out of time with work left, which is
// the starvation the per-run budget bounds and this counter makes visible.
func (m *Metrics) ObserveSweep(name string, d time.Duration, unfinished bool) {
if m == nil {
return
}
m.sweepDuration.WithLabelValues(name).Observe(d.Seconds())
if unfinished {
m.sweepUnfinished.WithLabelValues(name).Inc()
}
}
// Handler serves the exposition endpoint.
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
}
// Middleware counts and times every request.
//
// It sits where the access log sits — around the mux, so `Request.Pattern` is filled by the time it
// reads it, and outside the panic recovery, so a request that panicked is counted as the 500 it
// became.
func (m *Metrics) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if m == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
route := r.Pattern
if route == "" {
// One series for everything unmatched, never the path: an unrouted request is where
// an attacker chooses the label, and a label an attacker chooses is unbounded
// cardinality.
route = "(unmatched)"
}
method := knownMethod(r.Method)
m.requests.WithLabelValues(route, method, strconv.Itoa(rec.status)).Inc()
m.latency.WithLabelValues(route, method).Observe(time.Since(start).Seconds())
})
}
}
// knownMethod folds anything that is not a method this service serves into one label. A method is a
// token the CALLER chooses, and an unrouted request carries whatever they sent — so without this the
// series count is theirs to grow, exactly like the raw path would be.
func knownMethod(m string) string {
switch m {
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodDelete, http.MethodOptions:
return m
default:
return "(other)"
}
}
// recorder captures the status code.
//
// Unwrap is NOT optional: http.ResponseController walks the wrappers through it, and the upload
// route reaches its own read deadline that way. A wrapper without it silently turns that call into
// "not supported" — and the upload then dies at the server's 30-second ReadTimeout instead.
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }