Add production observability manifests

This commit is contained in:
2026-05-05 10:32:24 +01:00
parent bbecfeef63
commit 8ffe23ab7a
12 changed files with 471 additions and 20 deletions
+4
View File
@@ -5,6 +5,7 @@ resources:
- webshare-api-externalsecret.yaml - webshare-api-externalsecret.yaml
- site-analyzer-deployment.yaml - site-analyzer-deployment.yaml
- site-analyzer-service.yaml - site-analyzer-service.yaml
- site-analyzer-servicemonitor.yaml
- site-analyzer-hpa.yaml - site-analyzer-hpa.yaml
- lightpanda-cdp-deployment.yaml - lightpanda-cdp-deployment.yaml
- lightpanda-cdp-service.yaml - lightpanda-cdp-service.yaml
@@ -14,7 +15,10 @@ resources:
- lightpanda-mcp-hpa.yaml - lightpanda-mcp-hpa.yaml
- scraper-deployment.yaml - scraper-deployment.yaml
- scraper-service.yaml - scraper-service.yaml
- scraper-servicemonitor.yaml
- scraper-hpa.yaml - scraper-hpa.yaml
- microservices-workload-prometheusrule.yaml
- microservices-http-prometheusrule.yaml
- traefik-middleware-strip-prefix.yaml - traefik-middleware-strip-prefix.yaml
- ingressroute-microservices.yaml - ingressroute-microservices.yaml
images: images:
+2
View File
@@ -3,6 +3,8 @@ kind: Service
metadata: metadata:
name: lightpanda-cdp name: lightpanda-cdp
namespace: microservices namespace: microservices
labels:
app: lightpanda-cdp
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
+2
View File
@@ -3,6 +3,8 @@ kind: Service
metadata: metadata:
name: lightpanda-mcp name: lightpanda-mcp
namespace: microservices namespace: microservices
labels:
app: lightpanda-mcp
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
@@ -0,0 +1,27 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: microservices-http
namespace: microservices
labels:
app: microservices
spec:
groups:
- name: microservices.http
rules:
- alert: MicroserviceHigh5xxRate
expr: sum by (service) (rate(http_requests_total{service=~"scraper|site-analyzer",status=~"5.."}[5m])) / clamp_min(sum by (service) (rate(http_requests_total{service=~"scraper|site-analyzer"}[5m])), 0.001) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "Microservice has elevated 5xx errors"
description: "{{ $labels.service }} has more than 5% 5xx responses over 10 minutes."
- alert: MicroserviceHighP95Latency
expr: histogram_quantile(0.95, sum by (service, le) (rate(http_request_duration_seconds_bucket{service=~"scraper|site-analyzer",path!~"/health|/ready"}[5m]))) > 2
for: 10m
labels:
severity: warning
annotations:
summary: "Microservice has high p95 latency"
description: "{{ $labels.service }} p95 HTTP latency is above 2 seconds over 10 minutes."
@@ -0,0 +1,27 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: microservices-workloads
namespace: microservices
labels:
app: microservices
spec:
groups:
- name: microservices.workloads
rules:
- alert: MicroserviceDeploymentUnavailable
expr: kube_deployment_status_replicas_unavailable{namespace="microservices",deployment=~"scraper|site-analyzer|lightpanda-cdp|lightpanda-mcp"} > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Microservice deployment has unavailable replicas"
description: "{{ $labels.deployment }} has {{ $value }} unavailable replica(s) in namespace {{ $labels.namespace }}."
- alert: MicroservicePodRestarting
expr: increase(kube_pod_container_status_restarts_total{namespace="microservices",container=~"scraper|site-analyzer|lightpanda-cdp|lightpanda-mcp"}[15m]) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "Microservice pod is restarting"
description: "{{ $labels.pod }}/{{ $labels.container }} restarted {{ $value }} times in the last 15 minutes."
+1 -1
View File
@@ -56,7 +56,7 @@ spec:
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /ready
port: 8080 port: 8080
initialDelaySeconds: 2 initialDelaySeconds: 2
periodSeconds: 5 periodSeconds: 5
+2
View File
@@ -3,6 +3,8 @@ kind: Service
metadata: metadata:
name: scraper name: scraper
namespace: microservices namespace: microservices
labels:
app: scraper
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
+16
View File
@@ -0,0 +1,16 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: scraper
namespace: microservices
labels:
app: scraper
spec:
selector:
matchLabels:
app: scraper
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
+2
View File
@@ -3,6 +3,8 @@ kind: Service
metadata: metadata:
name: site-analyzer name: site-analyzer
namespace: microservices namespace: microservices
labels:
app: site-analyzer
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
@@ -0,0 +1,16 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: site-analyzer
namespace: microservices
labels:
app: site-analyzer
spec:
selector:
matchLabels:
app: site-analyzer
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
+201 -5
View File
@@ -8,9 +8,11 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"runtime"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
@@ -46,6 +48,8 @@ type ErrorResponse struct {
const webshareProxyCacheTTL = 5 * time.Minute const webshareProxyCacheTTL = 5 * time.Minute
var metricsState = newMetricsState()
type webshareProxy struct { type webshareProxy struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -75,31 +79,218 @@ func main() {
port = "8080" port = "8080"
} }
http.HandleFunc("/scrape", handleScrape) http.Handle("/scrape", instrumentHandler("/scrape", http.HandlerFunc(handleScrape)))
http.HandleFunc("/health", handleHealth) http.Handle("/health", instrumentHandler("/health", http.HandlerFunc(handleHealth)))
http.HandleFunc("/ready", handleHealth)
http.HandleFunc("/metrics", handleMetrics)
log.Printf("scraper listening on :%s", port) log.Printf("scraper listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil)) log.Fatal(http.ListenAndServe(":"+port, nil))
} }
type metricsStateData struct {
startedAt time.Time
inFlight atomic.Int64
proxyErrorsTotal atomic.Int64
mu sync.Mutex
httpRequests map[string]int64
httpErrors map[string]int64
httpDuration map[string]*histogram
scraperJobs map[string]int64
scraperDuration map[string]*histogram
}
type histogram struct {
buckets []float64
counts []int64
sum float64
count int64
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func newMetricsState() *metricsStateData {
return &metricsStateData{
startedAt: time.Now(),
httpRequests: map[string]int64{},
httpErrors: map[string]int64{},
httpDuration: map[string]*histogram{},
scraperJobs: map[string]int64{},
scraperDuration: map[string]*histogram{},
}
}
func newHistogram() *histogram {
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}
return &histogram{buckets: buckets, counts: make([]int64, len(buckets))}
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func instrumentHandler(path string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
metricsState.inFlight.Add(1)
defer metricsState.inFlight.Add(-1)
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
metricsState.recordHTTP(r.Method, path, recorder.status, time.Since(start).Seconds())
})
}
func (m *metricsStateData) recordHTTP(method string, path string, status int, seconds float64) {
m.mu.Lock()
defer m.mu.Unlock()
statusCode := fmt.Sprintf("%d", status)
key := method + "|" + path + "|" + statusCode
m.httpRequests[key]++
if status >= 500 {
m.httpErrors[key]++
}
histogramKey := method + "|" + path
m.histogram(m.httpDuration, histogramKey).observe(seconds)
}
func (m *metricsStateData) recordScrape(result string, seconds float64) {
m.mu.Lock()
defer m.mu.Unlock()
m.scraperJobs[result]++
m.histogram(m.scraperDuration, result).observe(seconds)
}
func (m *metricsStateData) histogram(histograms map[string]*histogram, key string) *histogram {
h, ok := histograms[key]
if !ok {
h = newHistogram()
histograms[key] = h
}
return h
}
func (h *histogram) observe(seconds float64) {
h.count++
h.sum += seconds
for i, bucket := range h.buckets {
if seconds <= bucket {
h.counts[i]++
}
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) { func handleHealth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok") fmt.Fprint(w, "ok")
} }
func handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
metricsState.writePrometheus(w)
}
func (m *metricsStateData) writePrometheus(w io.Writer) {
m.mu.Lock()
defer m.mu.Unlock()
var runtimeStats runtime.MemStats
runtime.ReadMemStats(&runtimeStats)
fmt.Fprintf(w, "# HELP http_requests_total Total HTTP requests by method, path, and status.\n")
fmt.Fprintf(w, "# TYPE http_requests_total counter\n")
for key, count := range m.httpRequests {
method, path, status := splitMetricKey(key)
fmt.Fprintf(w, "http_requests_total{service=\"scraper\",method=\"%s\",path=\"%s\",status=\"%s\"} %d\n", method, path, status, count)
}
fmt.Fprintf(w, "# HELP http_errors_total Total HTTP 5xx responses by method, path, and status.\n")
fmt.Fprintf(w, "# TYPE http_errors_total counter\n")
for key, count := range m.httpErrors {
method, path, status := splitMetricKey(key)
fmt.Fprintf(w, "http_errors_total{service=\"scraper\",method=\"%s\",path=\"%s\",status=\"%s\"} %d\n", method, path, status, count)
}
fmt.Fprintf(w, "# HELP http_requests_in_flight Current in-flight HTTP requests.\n")
fmt.Fprintf(w, "# TYPE http_requests_in_flight gauge\n")
fmt.Fprintf(w, "http_requests_in_flight{service=\"scraper\"} %d\n", m.inFlight.Load())
fmt.Fprintf(w, "# HELP http_request_duration_seconds HTTP request duration by method and path.\n")
fmt.Fprintf(w, "# TYPE http_request_duration_seconds histogram\n")
for key, histogram := range m.httpDuration {
method, path, _ := splitMetricKey(key)
writeHistogram(w, "http_request_duration_seconds", fmt.Sprintf("service=\"scraper\",method=\"%s\",path=\"%s\"", method, path), histogram)
}
fmt.Fprintf(w, "# HELP scraper_jobs_total Total scrape jobs by result.\n")
fmt.Fprintf(w, "# TYPE scraper_jobs_total counter\n")
for result, count := range m.scraperJobs {
fmt.Fprintf(w, "scraper_jobs_total{result=\"%s\"} %d\n", result, count)
}
fmt.Fprintf(w, "# HELP scraper_job_duration_seconds Scrape job duration by result.\n")
fmt.Fprintf(w, "# TYPE scraper_job_duration_seconds histogram\n")
for result, histogram := range m.scraperDuration {
writeHistogram(w, "scraper_job_duration_seconds", fmt.Sprintf("result=\"%s\"", result), histogram)
}
fmt.Fprintf(w, "# HELP scraper_proxy_errors_total Total Webshare proxy selection/configuration errors.\n")
fmt.Fprintf(w, "# TYPE scraper_proxy_errors_total counter\n")
fmt.Fprintf(w, "scraper_proxy_errors_total %d\n", m.proxyErrorsTotal.Load())
fmt.Fprintf(w, "# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.\n")
fmt.Fprintf(w, "# TYPE process_start_time_seconds gauge\n")
fmt.Fprintf(w, "process_start_time_seconds %.0f\n", float64(m.startedAt.Unix()))
fmt.Fprintf(w, "# HELP go_goroutines Number of goroutines that currently exist.\n")
fmt.Fprintf(w, "# TYPE go_goroutines gauge\n")
fmt.Fprintf(w, "go_goroutines %d\n", runtime.NumGoroutine())
fmt.Fprintf(w, "# HELP go_memstats_alloc_bytes Number of bytes allocated and still in use.\n")
fmt.Fprintf(w, "# TYPE go_memstats_alloc_bytes gauge\n")
fmt.Fprintf(w, "go_memstats_alloc_bytes %d\n", runtimeStats.Alloc)
}
func writeHistogram(w io.Writer, name string, labels string, histogram *histogram) {
for i, bucket := range histogram.buckets {
fmt.Fprintf(w, "%s_bucket{%s,le=\"%g\"} %d\n", name, labels, bucket, histogram.counts[i])
}
fmt.Fprintf(w, "%s_bucket{%s,le=\"+Inf\"} %d\n", name, labels, histogram.count)
fmt.Fprintf(w, "%s_sum{%s} %.6f\n", name, labels, histogram.sum)
fmt.Fprintf(w, "%s_count{%s} %d\n", name, labels, histogram.count)
}
func splitMetricKey(key string) (string, string, string) {
parts := strings.Split(key, "|")
for len(parts) < 3 {
parts = append(parts, "")
}
return parts[0], parts[1], parts[2]
}
func handleScrape(w http.ResponseWriter, r *http.Request) { func handleScrape(w http.ResponseWriter, r *http.Request) {
start := time.Now()
result := "success"
defer func() {
metricsState.recordScrape(result, time.Since(start).Seconds())
}()
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
result = "error"
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only POST requests are supported") respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only POST requests are supported")
return return
} }
var req ScrapeRequest var req ScrapeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
result = "error"
respondWithError(w, http.StatusBadRequest, "Invalid JSON", err.Error()) respondWithError(w, http.StatusBadRequest, "Invalid JSON", err.Error())
return return
} }
if req.URL == "" { if req.URL == "" {
result = "error"
respondWithError(w, http.StatusBadRequest, "Missing URL", "url field is required") respondWithError(w, http.StatusBadRequest, "Missing URL", "url field is required")
return return
} }
@@ -109,29 +300,32 @@ func handleScrape(w http.ResponseWriter, r *http.Request) {
format = "json" format = "json"
} }
if format != "md" && format != "json" { if format != "md" && format != "json" {
result = "error"
respondWithError(w, http.StatusBadRequest, "Invalid format", "format must be 'md' or 'json'") respondWithError(w, http.StatusBadRequest, "Invalid format", "format must be 'md' or 'json'")
return return
} }
country := normalizeCountryCode(req.Country) country := normalizeCountryCode(req.Country)
if err := validateCountry(country); err != nil { if err := validateCountry(country); err != nil {
result = "error"
respondWithError(w, http.StatusBadRequest, "Invalid country", err.Error()) respondWithError(w, http.StatusBadRequest, "Invalid country", err.Error())
return return
} }
req.URL = normalizeURL(req.URL) req.URL = normalizeURL(req.URL)
result, err := scrapeURL(req.URL, format, country) scrapeResult, err := scrapeURL(req.URL, format, country)
if err != nil { if err != nil {
result = "error"
respondWithError(w, http.StatusInternalServerError, "Scraping failed", err.Error()) respondWithError(w, http.StatusInternalServerError, "Scraping failed", err.Error())
return return
} }
if format == "md" { if format == "md" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, result.Content) fmt.Fprint(w, scrapeResult.Content)
} else { } else {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result) json.NewEncoder(w).Encode(scrapeResult)
} }
} }
@@ -139,10 +333,12 @@ func scrapeURL(url, format string, country string) (*ScrapeResponse, error) {
if country != "" { if country != "" {
proxy, err := selectWebshareProxy(country) proxy, err := selectWebshareProxy(country)
if err != nil { if err != nil {
metricsState.proxyErrorsTotal.Add(1)
return nil, err return nil, err
} }
transport, err := webshareProxyTransport(proxy) transport, err := webshareProxyTransport(proxy)
if err != nil { if err != nil {
metricsState.proxyErrorsTotal.Add(1)
return nil, err return nil, err
} }
return scrapeURLWithTransport(url, format, country, transport) return scrapeURLWithTransport(url, format, country, transport)
+171 -14
View File
@@ -12,9 +12,11 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"runtime"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -114,6 +116,7 @@ type app struct {
} }
type metrics struct { type metrics struct {
startedAt time.Time
requestsTotal atomic.Int64 requestsTotal atomic.Int64
analyzeRequestsTotal atomic.Int64 analyzeRequestsTotal atomic.Int64
analyzeRejectedTotal atomic.Int64 analyzeRejectedTotal atomic.Int64
@@ -121,6 +124,23 @@ type metrics struct {
analyzeDurationCount atomic.Int64 analyzeDurationCount atomic.Int64
analyzeDurationNanos atomic.Int64 analyzeDurationNanos atomic.Int64
activeAnalyses atomic.Int64 activeAnalyses atomic.Int64
mu sync.Mutex
httpRequests map[string]int64
httpErrors map[string]int64
httpDuration map[string]*histogram
analyzeDuration *histogram
}
type histogram struct {
buckets []float64
counts []int64
sum float64
count int64
}
type statusRecorder struct {
http.ResponseWriter
status int
} }
var errUnsafeTarget = errors.New("target resolves to a private or otherwise unsafe address") var errUnsafeTarget = errors.New("target resolves to a private or otherwise unsafe address")
@@ -130,9 +150,9 @@ func main() {
application := newApp(cfg) application := newApp(cfg)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/analyze", application.handleAnalyze) mux.Handle("/analyze", application.instrumentHandler("/analyze", http.HandlerFunc(application.handleAnalyze)))
mux.HandleFunc("/health", application.handleHealth) mux.Handle("/health", application.instrumentHandler("/health", http.HandlerFunc(application.handleHealth)))
mux.HandleFunc("/ready", application.handleReady) mux.Handle("/ready", application.instrumentHandler("/ready", http.HandlerFunc(application.handleReady)))
mux.HandleFunc("/metrics", application.handleMetrics) mux.HandleFunc("/metrics", application.handleMetrics)
server := &http.Server{ server := &http.Server{
@@ -176,7 +196,7 @@ func newApp(cfg config) *app {
application := &app{ application := &app{
config: cfg, config: cfg,
semaphore: make(chan struct{}, cfg.maxConcurrentAnalyses), semaphore: make(chan struct{}, cfg.maxConcurrentAnalyses),
metrics: &metrics{}, metrics: newMetrics(),
} }
transport := &http.Transport{ transport := &http.Transport{
@@ -203,6 +223,73 @@ func newApp(cfg config) *app {
return application return application
} }
func newMetrics() *metrics {
return &metrics{
startedAt: time.Now(),
httpRequests: map[string]int64{},
httpErrors: map[string]int64{},
httpDuration: map[string]*histogram{},
analyzeDuration: newHistogram(),
}
}
func newHistogram() *histogram {
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}
return &histogram{buckets: buckets, counts: make([]int64, len(buckets))}
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (a *app) instrumentHandler(path string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
a.metrics.recordHTTP(r.Method, path, recorder.status, time.Since(start).Seconds())
})
}
func (m *metrics) recordHTTP(method string, path string, status int, seconds float64) {
m.mu.Lock()
defer m.mu.Unlock()
statusCode := fmt.Sprintf("%d", status)
key := method + "|" + path + "|" + statusCode
m.httpRequests[key]++
if status >= 500 {
m.httpErrors[key]++
}
histogramKey := method + "|" + path
m.histogram(m.httpDuration, histogramKey).observe(seconds)
}
func (m *metrics) recordAnalyzeDuration(seconds float64) {
m.mu.Lock()
defer m.mu.Unlock()
m.analyzeDuration.observe(seconds)
}
func (m *metrics) histogram(histograms map[string]*histogram, key string) *histogram {
h, ok := histograms[key]
if !ok {
h = newHistogram()
histograms[key] = h
}
return h
}
func (h *histogram) observe(seconds float64) {
h.count++
h.sum += seconds
for i, bucket := range h.buckets {
if seconds <= bucket {
h.counts[i]++
}
}
}
func loadConfig() config { func loadConfig() config {
analysisTimeout := durationEnv("ANALYSIS_TIMEOUT", 15*time.Second) analysisTimeout := durationEnv("ANALYSIS_TIMEOUT", 15*time.Second)
fetchTimeout := durationEnv("FETCH_TIMEOUT", 10*time.Second) fetchTimeout := durationEnv("FETCH_TIMEOUT", 10*time.Second)
@@ -285,32 +372,100 @@ func (a *app) handleReady(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handleMetrics(w http.ResponseWriter, r *http.Request) { func (a *app) handleMetrics(w http.ResponseWriter, r *http.Request) {
a.metrics.requestsTotal.Add(1)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
a.metrics.writePrometheus(w, a.config)
}
durationSeconds := float64(a.metrics.analyzeDurationNanos.Load()) / float64(time.Second) func (m *metrics) writePrometheus(w io.Writer, cfg config) {
m.mu.Lock()
defer m.mu.Unlock()
var runtimeStats runtime.MemStats
runtime.ReadMemStats(&runtimeStats)
durationSeconds := float64(m.analyzeDurationNanos.Load()) / float64(time.Second)
fmt.Fprintf(w, "# HELP site_analyzer_requests_total Total HTTP requests received.\n") fmt.Fprintf(w, "# HELP site_analyzer_requests_total Total HTTP requests received.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_requests_total counter\n") fmt.Fprintf(w, "# TYPE site_analyzer_requests_total counter\n")
fmt.Fprintf(w, "site_analyzer_requests_total %d\n", a.metrics.requestsTotal.Load()) fmt.Fprintf(w, "site_analyzer_requests_total %d\n", m.requestsTotal.Load())
fmt.Fprintf(w, "# HELP site_analyzer_analyze_requests_total Total analyze requests received.\n") fmt.Fprintf(w, "# HELP site_analyzer_analyze_requests_total Total analyze requests received.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_requests_total counter\n") fmt.Fprintf(w, "# TYPE site_analyzer_analyze_requests_total counter\n")
fmt.Fprintf(w, "site_analyzer_analyze_requests_total %d\n", a.metrics.analyzeRequestsTotal.Load()) fmt.Fprintf(w, "site_analyzer_analyze_requests_total %d\n", m.analyzeRequestsTotal.Load())
fmt.Fprintf(w, "# HELP site_analyzer_analyze_rejected_total Analyze requests rejected because the pod is saturated.\n") fmt.Fprintf(w, "# HELP site_analyzer_analyze_rejected_total Analyze requests rejected because the pod is saturated.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_rejected_total counter\n") fmt.Fprintf(w, "# TYPE site_analyzer_analyze_rejected_total counter\n")
fmt.Fprintf(w, "site_analyzer_analyze_rejected_total %d\n", a.metrics.analyzeRejectedTotal.Load()) fmt.Fprintf(w, "site_analyzer_analyze_rejected_total %d\n", m.analyzeRejectedTotal.Load())
fmt.Fprintf(w, "# HELP site_analyzer_analyze_errors_total Analyze requests that failed before producing a response.\n") fmt.Fprintf(w, "# HELP site_analyzer_analyze_errors_total Analyze requests that failed before producing a response.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_errors_total counter\n") fmt.Fprintf(w, "# TYPE site_analyzer_analyze_errors_total counter\n")
fmt.Fprintf(w, "site_analyzer_analyze_errors_total %d\n", a.metrics.analyzeErrorsTotal.Load()) fmt.Fprintf(w, "site_analyzer_analyze_errors_total %d\n", m.analyzeErrorsTotal.Load())
fmt.Fprintf(w, "# HELP site_analyzer_active_analyses Current number of in-flight analyses.\n") fmt.Fprintf(w, "# HELP site_analyzer_active_analyses Current number of in-flight analyses.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_active_analyses gauge\n") fmt.Fprintf(w, "# TYPE site_analyzer_active_analyses gauge\n")
fmt.Fprintf(w, "site_analyzer_active_analyses %d\n", a.metrics.activeAnalyses.Load()) fmt.Fprintf(w, "site_analyzer_active_analyses %d\n", m.activeAnalyses.Load())
fmt.Fprintf(w, "# HELP site_analyzer_max_concurrent_analyses Configured maximum in-flight analyses per pod.\n") fmt.Fprintf(w, "# HELP site_analyzer_max_concurrent_analyses Configured maximum in-flight analyses per pod.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_max_concurrent_analyses gauge\n") fmt.Fprintf(w, "# TYPE site_analyzer_max_concurrent_analyses gauge\n")
fmt.Fprintf(w, "site_analyzer_max_concurrent_analyses %d\n", a.config.maxConcurrentAnalyses) fmt.Fprintf(w, "site_analyzer_max_concurrent_analyses %d\n", cfg.maxConcurrentAnalyses)
fmt.Fprintf(w, "# HELP site_analyzer_analyze_duration_seconds Total analyze request duration.\n") fmt.Fprintf(w, "# HELP site_analyzer_analyze_duration_seconds Total analyze request duration.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_duration_seconds summary\n") fmt.Fprintf(w, "# TYPE site_analyzer_analyze_duration_seconds summary\n")
fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_sum %.6f\n", durationSeconds) fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_sum %.6f\n", durationSeconds)
fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_count %d\n", a.metrics.analyzeDurationCount.Load()) fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_count %d\n", m.analyzeDurationCount.Load())
fmt.Fprintf(w, "# HELP http_requests_total Total HTTP requests by method, path, and status.\n")
fmt.Fprintf(w, "# TYPE http_requests_total counter\n")
for key, count := range m.httpRequests {
method, path, status := splitMetricKey(key)
fmt.Fprintf(w, "http_requests_total{service=\"site-analyzer\",method=\"%s\",path=\"%s\",status=\"%s\"} %d\n", method, path, status, count)
}
fmt.Fprintf(w, "# HELP http_errors_total Total HTTP 5xx responses by method, path, and status.\n")
fmt.Fprintf(w, "# TYPE http_errors_total counter\n")
for key, count := range m.httpErrors {
method, path, status := splitMetricKey(key)
fmt.Fprintf(w, "http_errors_total{service=\"site-analyzer\",method=\"%s\",path=\"%s\",status=\"%s\"} %d\n", method, path, status, count)
}
fmt.Fprintf(w, "# HELP http_requests_in_flight Current in-flight HTTP analyze requests.\n")
fmt.Fprintf(w, "# TYPE http_requests_in_flight gauge\n")
fmt.Fprintf(w, "http_requests_in_flight{service=\"site-analyzer\"} %d\n", m.activeAnalyses.Load())
fmt.Fprintf(w, "# HELP http_request_duration_seconds HTTP request duration by method and path.\n")
fmt.Fprintf(w, "# TYPE http_request_duration_seconds histogram\n")
for key, histogram := range m.httpDuration {
method, path, _ := splitMetricKey(key)
writeHistogram(w, "http_request_duration_seconds", fmt.Sprintf("service=\"site-analyzer\",method=\"%s\",path=\"%s\"", method, path), histogram)
}
fmt.Fprintf(w, "# HELP site_analyzer_duration_seconds Analyze request duration.\n")
fmt.Fprintf(w, "# TYPE site_analyzer_duration_seconds histogram\n")
writeHistogram(w, "site_analyzer_duration_seconds", "", m.analyzeDuration)
fmt.Fprintf(w, "# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.\n")
fmt.Fprintf(w, "# TYPE process_start_time_seconds gauge\n")
fmt.Fprintf(w, "process_start_time_seconds %.0f\n", float64(m.startedAt.Unix()))
fmt.Fprintf(w, "# HELP go_goroutines Number of goroutines that currently exist.\n")
fmt.Fprintf(w, "# TYPE go_goroutines gauge\n")
fmt.Fprintf(w, "go_goroutines %d\n", runtime.NumGoroutine())
fmt.Fprintf(w, "# HELP go_memstats_alloc_bytes Number of bytes allocated and still in use.\n")
fmt.Fprintf(w, "# TYPE go_memstats_alloc_bytes gauge\n")
fmt.Fprintf(w, "go_memstats_alloc_bytes %d\n", runtimeStats.Alloc)
}
func writeHistogram(w io.Writer, name string, labels string, histogram *histogram) {
labelPrefix := ""
if labels != "" {
labelPrefix = labels + ","
}
for i, bucket := range histogram.buckets {
fmt.Fprintf(w, "%s_bucket{%sle=\"%g\"} %d\n", name, labelPrefix, bucket, histogram.counts[i])
}
fmt.Fprintf(w, "%s_bucket{%sle=\"+Inf\"} %d\n", name, labelPrefix, histogram.count)
if labels == "" {
fmt.Fprintf(w, "%s_sum %.6f\n", name, histogram.sum)
fmt.Fprintf(w, "%s_count %d\n", name, histogram.count)
return
}
fmt.Fprintf(w, "%s_sum{%s} %.6f\n", name, labels, histogram.sum)
fmt.Fprintf(w, "%s_count{%s} %d\n", name, labels, histogram.count)
}
func splitMetricKey(key string) (string, string, string) {
parts := strings.Split(key, "|")
for len(parts) < 3 {
parts = append(parts, "")
}
return parts[0], parts[1], parts[2]
} }
func (a *app) handleAnalyze(w http.ResponseWriter, r *http.Request) { func (a *app) handleAnalyze(w http.ResponseWriter, r *http.Request) {
@@ -318,8 +473,10 @@ func (a *app) handleAnalyze(w http.ResponseWriter, r *http.Request) {
a.metrics.requestsTotal.Add(1) a.metrics.requestsTotal.Add(1)
a.metrics.analyzeRequestsTotal.Add(1) a.metrics.analyzeRequestsTotal.Add(1)
defer func() { defer func() {
duration := time.Since(start)
a.metrics.analyzeDurationCount.Add(1) a.metrics.analyzeDurationCount.Add(1)
a.metrics.analyzeDurationNanos.Add(time.Since(start).Nanoseconds()) a.metrics.analyzeDurationNanos.Add(duration.Nanoseconds())
a.metrics.recordAnalyzeDuration(duration.Seconds())
}() }()
if r.Method != http.MethodPost { if r.Method != http.MethodPost {