Files
archivdms/internal/api/metrics_handlers.go
T
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00

156 lines
5.5 KiB
Go

// FDN-08 — /metrics im Prometheus-Textformat, ohne Fremdabhängigkeit.
//
// Zugriff: bewusst OHNE Login (Scrape-Clients haben keine Session), dafür
// IP-beschränkt. Default ist loopback-only; weitere Scraper werden über
// config api.metrics_allowed_ips (IP oder CIDR) freigeschaltet. Es werden
// ausschließlich aggregierte Zähler ausgegeben — keine Tenant-Daten, keine
// Pfadsegmente mit IDs oder Tokens (siehe normalizeRoute).
package api
import (
"context"
"fmt"
"net"
"net/http"
"runtime"
"sort"
"strings"
"time"
)
// handleMetrics rendert die Registry im Prometheus-Textformat.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if !s.metricsAllowed(r) {
writeError(w, http.StatusForbidden, "metrics endpoint not allowed from this address")
return
}
routes, inFlight, panics := s.metrics.snapshot()
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
var b strings.Builder
fmt.Fprintf(&b, "# HELP archivdms_build_info Statische Build-Information.\n")
fmt.Fprintf(&b, "# TYPE archivdms_build_info gauge\n")
fmt.Fprintf(&b, "archivdms_build_info{version=\"%s\"} 1\n", escapeLabel(s.appVersion))
fmt.Fprintf(&b, "# HELP archivdms_uptime_seconds Laufzeit des Prozesses in Sekunden.\n")
fmt.Fprintf(&b, "# TYPE archivdms_uptime_seconds gauge\n")
fmt.Fprintf(&b, "archivdms_uptime_seconds %.3f\n", time.Since(s.startTime).Seconds())
fmt.Fprintf(&b, "# HELP archivdms_goroutines Aktuelle Anzahl Goroutinen.\n")
fmt.Fprintf(&b, "# TYPE archivdms_goroutines gauge\n")
fmt.Fprintf(&b, "archivdms_goroutines %d\n", runtime.NumGoroutine())
fmt.Fprintf(&b, "# HELP archivdms_http_requests_in_flight Aktuell laufende HTTP-Anfragen.\n")
fmt.Fprintf(&b, "# TYPE archivdms_http_requests_in_flight gauge\n")
fmt.Fprintf(&b, "archivdms_http_requests_in_flight %d\n", inFlight)
fmt.Fprintf(&b, "# HELP archivdms_panics_total Zentral abgefangene Panics (unbehandelte Fehler).\n")
fmt.Fprintf(&b, "# TYPE archivdms_panics_total counter\n")
fmt.Fprintf(&b, "archivdms_panics_total %d\n", panics)
// Requests + Latenz je Route/Status.
keys := make([]routeKey, 0, len(routes))
for k := range routes {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].route != keys[j].route {
return keys[i].route < keys[j].route
}
if keys[i].method != keys[j].method {
return keys[i].method < keys[j].method
}
return keys[i].status < keys[j].status
})
fmt.Fprintf(&b, "# HELP archivdms_http_requests_total Anzahl HTTP-Anfragen je Route und Status.\n")
fmt.Fprintf(&b, "# TYPE archivdms_http_requests_total counter\n")
for _, k := range keys {
st := routes[k]
fmt.Fprintf(&b, "archivdms_http_requests_total{method=\"%s\",route=\"%s\",status=\"%d\"} %d\n",
escapeLabel(k.method), escapeLabel(k.route), k.status, st.count)
}
fmt.Fprintf(&b, "# HELP archivdms_http_request_duration_seconds Latenz der HTTP-Anfragen.\n")
fmt.Fprintf(&b, "# TYPE archivdms_http_request_duration_seconds histogram\n")
for _, k := range keys {
st := routes[k]
labels := fmt.Sprintf("method=\"%s\",route=\"%s\",status=\"%d\"",
escapeLabel(k.method), escapeLabel(k.route), k.status)
for i, ub := range latencyBuckets {
fmt.Fprintf(&b, "archivdms_http_request_duration_seconds_bucket{%s,le=\"%g\"} %d\n",
labels, ub, st.bucketCount[i])
}
fmt.Fprintf(&b, "archivdms_http_request_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, st.count)
fmt.Fprintf(&b, "archivdms_http_request_duration_seconds_sum{%s} %.6f\n", labels, st.sumSeconds)
fmt.Fprintf(&b, "archivdms_http_request_duration_seconds_count{%s} %d\n", labels, st.count)
}
// Queue-Länge (Akzeptanzkriterium 2). Fehler hier dürfen den Scrape nicht
// scheitern lassen — dann fehlt die Metrik einfach für diesen Durchlauf.
if s.store != nil {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
counts, err := s.store.CountProcessingJobsByStatus(ctx)
if err != nil {
s.reqLog(r.Context()).Warn("metrics: queue length query failed", "err", err)
} else {
fmt.Fprintf(&b, "# HELP archivdms_processing_jobs Länge der Verarbeitungswarteschlange je Status.\n")
fmt.Fprintf(&b, "# TYPE archivdms_processing_jobs gauge\n")
statuses := make([]string, 0, len(counts))
for st := range counts {
statuses = append(statuses, st)
}
sort.Strings(statuses)
for _, st := range statuses {
fmt.Fprintf(&b, "archivdms_processing_jobs{status=\"%s\"} %d\n", escapeLabel(st), counts[st])
}
}
}
w.Write([]byte(b.String()))
}
// metricsAllowed prüft die Herkunft des Scrape-Requests: loopback immer,
// sonst nur konfigurierte IPs/CIDRs (config api.metrics_allowed_ips).
func (s *Server) metricsAllowed(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(strings.TrimSpace(host))
if ip == nil {
return false
}
if ip.IsLoopback() {
return true
}
for _, entry := range s.cfg.MetricsAllowedIPs {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
if _, cidr, err := net.ParseCIDR(entry); err == nil && cidr.Contains(ip) {
return true
}
continue
}
if parsed := net.ParseIP(entry); parsed != nil && parsed.Equal(ip) {
return true
}
}
return false
}
// escapeLabel entschärft Anführungszeichen/Backslashes/Zeilenumbrüche in
// Prometheus-Labelwerten.
func escapeLabel(v string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`)
return r.Replace(v)
}