- 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
156 lines
5.5 KiB
Go
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)
|
|
}
|