// 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) }