From eb163d4131f13b9c69ef47a667b392e04811ac79 Mon Sep 17 00:00:00 2001 From: PePe Amengual <2208324+jamengual@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:39:07 -0800 Subject: [PATCH 1/2] Add use cases to README and expand Prometheus metrics coverage Add a Use Cases section to the README explaining core functionality (domain migrations, SEO, vanity URLs, API deprecation, bot mitigation, multi-team config). Expand Prometheus metrics with build info, uptime, config info, rate limiter observability, standard Go/process collectors, and full syncer instrumentation. Add Prometheus usage guide to README with scrape config, key metrics, and example Grafana alerts. Co-Authored-By: Claude Opus 4.6 --- README.md | 81 +++++++++++ cmd/redirector-sync/main.go | 61 +++++++- cmd/redirector/main.go | 2 +- docs/MANAGEMENT_API.md | 23 +++ internal/metrics/metrics.go | 106 ++++++++++++++ internal/metrics/metrics_test.go | 158 +++++++++++++++++++++ internal/metrics/syncer_metrics.go | 178 ++++++++++++++++++++++++ internal/metrics/syncer_metrics_test.go | 139 ++++++++++++++++++ internal/ratelimit/ratelimit.go | 19 +++ internal/server/server.go | 32 ++++- 10 files changed, 794 insertions(+), 5 deletions(-) create mode 100644 internal/metrics/syncer_metrics.go create mode 100644 internal/metrics/syncer_metrics_test.go diff --git a/README.md b/README.md index edb295c..71b880d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,20 @@ The Redirector handles URL redirects and custom responses at massive scale with Built by a DevOps/Platform Engineer with an emphasis on **decentralized configuration ownership**. Teams that own redirects can manage their own rules — via GitHub repos, S3 buckets, or any supported source — without depending on a DevOps or Platform Engineering team to make changes on their behalf. The syncer merges multiple team configs with conflict detection and priority-based resolution. For organizations that prefer centralized management, the same architecture works with a single config source. +### Use Cases + +**Domain & URL migrations** — Redirect entire domains or URL structures during rebrands, acquisitions, or site reorganizations. Exact, prefix, regex, and glob matching let you handle everything from simple page moves to complex path transformations with capture groups. + +**SEO preservation** — Maintain search engine rankings by issuing proper 301/308 redirects when content moves. Per-rule status codes mean you can return 410 Gone for permanently removed content or 404 for paths you want to disappear from crawlers. + +**Vanity URLs & short links** — Serve `/go/slack`, `/go/wiki`, or marketing campaign URLs that redirect to internal or external destinations. Teams manage their own redirect rules via GitHub repos, S3 buckets, or any supported source — no tickets to a platform team required. + +**Legacy API deprecation** — Redirect old API versions to new ones with path rewriting and capture groups (`/api/v1/users/123` -> `/api/v2/users/123`). Return 503 with `Retry-After` headers during maintenance windows. + +**Bot & abuse mitigation** — Return 404 or 403 for known bot paths (`/wp-admin`, `/.env`) and use the host allowlist to reject traffic for unknown domains at O(1) cost before any rule evaluation, providing built-in DDoS protection. + +**Multi-team configuration at scale** — Each team owns their redirect rules in their own repo or config source. The `redirector-sync` service merges configs from GitHub, GitLab, S3, Azure Blob, GCS, Consul, etcd, and more — with conflict detection, linting, and priority-based resolution. + ### Key Features - **Blazing Fast**: Built on fasthttp with radix tree routing for < 1ms p99 latency @@ -170,6 +184,73 @@ curl -X POST http://localhost:8081/api/v1/reload # Trigger reload For all endpoints, Prometheus metrics, and authentication details, see **[docs/MANAGEMENT_API.md](docs/MANAGEMENT_API.md)**. +### Prometheus Metrics + +Both the redirector and redirector-sync expose a `/metrics` endpoint in Prometheus text format. + +**Scrape the redirector:** + +```yaml +# prometheus.yml +scrape_configs: + - job_name: redirector + static_configs: + - targets: ['localhost:8081'] + + - job_name: redirector-sync + static_configs: + - targets: ['localhost:9090'] # webhook server port +``` + +**Verify locally:** + +```bash +# Redirector metrics +curl -s http://localhost:8081/metrics | head -20 + +# Key metrics to watch: +# redirector_requests_total - request volume by status/rule +# redirector_request_duration_seconds - latency histogram (p50/p99) +# redirector_requests_in_flight - current concurrency +# redirector_config_rules_count - loaded rules +# redirector_rate_limited_total - rate-limited requests by scope +# redirector_host_rejected_total - DDoS-rejected requests +# redirector_build_info - version/commit for deploy tracking +# redirector_uptime_seconds - process uptime +# redirector_config_info - current config version/hash +# process_* / go_* - standard Go runtime metrics +``` + +**Syncer metrics** (available when webhook server is enabled): + +```bash +curl -s http://localhost:9090/metrics | head -20 + +# Key metrics: +# redirector_sync_sync_total - sync success/failure count +# redirector_sync_last_sync_success - 1 if last sync OK, 0 if failed +# redirector_sync_fetch_duration_seconds - source fetch latency +# redirector_sync_push_total - push success/failure per target +# redirector_sync_lint_errors_total - config validation failures +``` + +**Example Grafana alert** (PromQL): + +```promql +# Alert if no successful sync in 10 minutes +time() - redirector_sync_last_sync_timestamp_seconds > 600 + and redirector_sync_last_sync_success == 0 + +# Alert on high error rate +rate(redirector_requests_total{status="5xx"}[5m]) + / rate(redirector_requests_total[5m]) > 0.01 + +# Alert on rate limiting +rate(redirector_rate_limited_total[5m]) > 0 +``` + +For the full metrics reference, see **[docs/MANAGEMENT_API.md](docs/MANAGEMENT_API.md)**. + --- ## redirector-sync diff --git a/cmd/redirector-sync/main.go b/cmd/redirector-sync/main.go index 9d75704..6077c73 100644 --- a/cmd/redirector-sync/main.go +++ b/cmd/redirector-sync/main.go @@ -15,12 +15,15 @@ import ( "syscall" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" "github.com/jamengual/the-redirector/internal/config" "github.com/jamengual/the-redirector/internal/lint" + "github.com/jamengual/the-redirector/internal/metrics" "github.com/jamengual/the-redirector/internal/providers" ) @@ -326,6 +329,13 @@ func startWebhookServer(ctx context.Context, port int, secret string, syncer *Sy json.NewEncoder(w).Encode(status) }) + // Prometheus metrics endpoint + if syncer.promRegistry != nil { + mux.Handle("/metrics", promhttp.HandlerFor(syncer.promRegistry, promhttp.HandlerOpts{ + EnableOpenMetrics: true, + })) + } + server := &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: mux, @@ -382,8 +392,10 @@ func loadSyncerConfig(path string) (*SyncerConfig, error) { // Syncer handles config synchronization. type Syncer struct { - cfg *SyncerConfig - sources []ConfigSource + cfg *SyncerConfig + sources []ConfigSource + metrics *metrics.SyncerMetrics + promRegistry *prometheus.Registry mu sync.RWMutex syncCount int64 @@ -438,7 +450,10 @@ type ConfigSource interface { // NewSyncer creates a new syncer from configuration. func NewSyncer(cfg *SyncerConfig) *Syncer { - s := &Syncer{cfg: cfg} + registry := prometheus.NewRegistry() + syncerMetrics := metrics.NewSyncerMetrics(registry) + + s := &Syncer{cfg: cfg, metrics: syncerMetrics, promRegistry: registry} // Initialize sources for _, srcCfg := range cfg.Sources { @@ -653,18 +668,30 @@ func sourceConfigToMap(cfg SourceConfig) map[string]interface{} { // SyncOnce performs a single sync operation. func (s *Syncer) SyncOnce(ctx context.Context, dryRun bool) error { + syncStart := time.Now() + // Try sources in priority order var lastErr error for _, src := range s.sources { log.Info().Str("source", src.Name()).Msg("Attempting to fetch config") + fetchStart := time.Now() data, err := src.Fetch(ctx) + fetchDuration := time.Since(fetchStart).Seconds() + if err != nil { log.Warn().Err(err).Str("source", src.Name()).Msg("Failed to fetch from source") + if s.metrics != nil { + s.metrics.RecordFetch(src.Name(), false, fetchDuration) + } lastErr = err continue } + if s.metrics != nil { + s.metrics.RecordFetch(src.Name(), true, fetchDuration) + } + log.Info(). Str("source", src.Name()). Int("bytes", len(data)). @@ -678,6 +705,10 @@ func (s *Syncer) SyncOnce(ctx context.Context, dryRun bool) error { continue } + if s.metrics != nil { + s.metrics.RulesFetched.Set(float64(len(parsedCfg.Rules))) + } + linter := lint.New(parsedCfg) lintResult := linter.Lint() @@ -685,6 +716,10 @@ func (s *Syncer) SyncOnce(ctx context.Context, dryRun bool) error { for _, issue := range lintResult.Errors() { log.Error().Str("rule_id", issue.RuleID).Str("source", src.Name()).Msg(issue.Message) } + if s.metrics != nil { + s.metrics.RecordLintError(src.Name()) + s.metrics.RecordSync(false, time.Since(syncStart).Seconds()) + } s.mu.Lock() s.syncErrors++ s.mu.Unlock() @@ -697,11 +732,17 @@ func (s *Syncer) SyncOnce(ctx context.Context, dryRun bool) error { if dryRun { log.Info().Msg("Dry run - not writing output") + if s.metrics != nil { + s.metrics.RecordSync(true, time.Since(syncStart).Seconds()) + } return nil } // Write output if err := s.writeOutput(ctx, data); err != nil { + if s.metrics != nil { + s.metrics.RecordSync(false, time.Since(syncStart).Seconds()) + } s.mu.Lock() s.syncErrors++ s.mu.Unlock() @@ -713,9 +754,16 @@ func (s *Syncer) SyncOnce(ctx context.Context, dryRun bool) error { s.lastSyncTime = time.Now() s.mu.Unlock() + if s.metrics != nil { + s.metrics.RecordSync(true, time.Since(syncStart).Seconds()) + } return nil } + if s.metrics != nil { + s.metrics.RecordSync(false, time.Since(syncStart).Seconds()) + } + s.mu.Lock() s.syncErrors++ s.mu.Unlock() @@ -861,6 +909,7 @@ func (s *Syncer) pushToTargetWithRetry(ctx context.Context, target *TargetConfig delay = 1 * time.Second } + pushStart := time.Now() var lastErr error for attempt := 0; attempt <= attempts; attempt++ { if attempt > 0 { @@ -881,12 +930,18 @@ func (s *Syncer) pushToTargetWithRetry(ctx context.Context, target *TargetConfig err := s.doPush(ctx, target, data) if err == nil { + if s.metrics != nil { + s.metrics.RecordPush(target.Name, true, time.Since(pushStart).Seconds()) + } return nil } lastErr = err log.Warn().Err(err).Str("target", target.Name).Int("attempt", attempt).Msg("Push attempt failed") } + if s.metrics != nil { + s.metrics.RecordPush(target.Name, false, time.Since(pushStart).Seconds()) + } return lastErr } diff --git a/cmd/redirector/main.go b/cmd/redirector/main.go index 96d2d08..3a5b354 100644 --- a/cmd/redirector/main.go +++ b/cmd/redirector/main.go @@ -150,7 +150,7 @@ func runServer() { Msg("Configuration loaded") // Create and start server - srv, err := server.New(cfg, *configPath) + srv, err := server.New(cfg, *configPath, server.WithBuildInfo(version, "", buildTime)) if err != nil { log.Fatal().Err(err).Msg("Failed to create server") } diff --git a/docs/MANAGEMENT_API.md b/docs/MANAGEMENT_API.md index d8748a9..1628ddb 100644 --- a/docs/MANAGEMENT_API.md +++ b/docs/MANAGEMENT_API.md @@ -84,10 +84,33 @@ The `/metrics` endpoint exposes Prometheus-format metrics: | `redirector_config_rules_count` | Gauge | — | Current rule count | | `redirector_config_last_reload_timestamp_seconds` | Gauge | — | Last reload timestamp | | `redirector_config_load_duration_seconds` | Histogram | — | Config load time | +| `redirector_config_info` | Gauge | version, hash, source | Current config metadata | | `redirector_rule_matches_total` | Counter | rule_id, match_type | Rule match count | | `redirector_host_rejected_total` | Counter | — | Rejected unknown host requests | +| `redirector_rate_limited_total` | Counter | scope | Requests rejected by rate limiting | +| `redirector_build_info` | Gauge | version, commit, build_time, go_version | Build metadata | +| `redirector_uptime_seconds` | Gauge | — | Time since server started | | `redirector_goroutines` | Gauge | — | Current goroutine count | | `redirector_memory_alloc_bytes` | Gauge | — | Current memory allocation | +| `go_*` | Various | — | Standard Go runtime metrics | +| `process_*` | Various | — | Standard process metrics (CPU, FDs, memory) | + +### redirector-sync Metrics + +The syncer exposes metrics at `/metrics` on its webhook server port: + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `redirector_sync_sync_total` | Counter | status | Total sync operations | +| `redirector_sync_sync_duration_seconds` | Histogram | status | Sync operation duration | +| `redirector_sync_last_sync_timestamp_seconds` | Gauge | — | Last sync attempt timestamp | +| `redirector_sync_last_sync_success` | Gauge | — | Whether last sync succeeded (1/0) | +| `redirector_sync_fetch_total` | Counter | source, status | Source fetch operations | +| `redirector_sync_fetch_duration_seconds` | Histogram | source | Source fetch duration | +| `redirector_sync_push_total` | Counter | target, status | Config push operations | +| `redirector_sync_push_duration_seconds` | Histogram | target | Config push duration | +| `redirector_sync_rules_fetched` | Gauge | — | Rules from last successful fetch | +| `redirector_sync_lint_errors_total` | Counter | source | Lint errors during sync | --- diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 80496f1..6ad4a98 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -2,10 +2,22 @@ package metrics import ( + "runtime" + "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promauto" ) +// BuildInfo holds version metadata exposed via the build_info gauge. +type BuildInfo struct { + Version string + Commit string + BuildTime string + GoVersion string +} + // Metrics holds all Prometheus metrics for the redirector. type Metrics struct { // Request metrics @@ -21,16 +33,24 @@ type Metrics struct { ConfigRulesCount prometheus.Gauge ConfigLastReloadTime prometheus.Gauge ConfigLoadDuration prometheus.Histogram + ConfigInfo *prometheus.GaugeVec // System metrics Goroutines prometheus.GaugeFunc MemoryAlloc prometheus.GaugeFunc + // Build & uptime metrics + Info prometheus.Gauge + UptimeSeconds prometheus.GaugeFunc + // Rule metrics RuleMatchesTotal *prometheus.CounterVec // Host rejection metrics HostRejectedTotal prometheus.Counter + + // Rate limiter metrics + RateLimitedTotal *prometheus.CounterVec } // New creates and registers all metrics. @@ -39,6 +59,8 @@ func New(registry prometheus.Registerer) *Metrics { registry = prometheus.DefaultRegisterer } + startTime := time.Now() + m := &Metrics{ // Request metrics RequestsTotal: promauto.With(registry).NewCounterVec( @@ -114,6 +136,27 @@ func New(registry prometheus.Registerer) *Metrics { }, ), + ConfigInfo: promauto.With(registry).NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "redirector", + Name: "config_info", + Help: "Current configuration metadata", + }, + []string{"version", "hash", "source"}, + ), + + // Uptime metric + UptimeSeconds: promauto.With(registry).NewGaugeFunc( + prometheus.GaugeOpts{ + Namespace: "redirector", + Name: "uptime_seconds", + Help: "Time in seconds since the server started", + }, + func() float64 { + return time.Since(startTime).Seconds() + }, + ), + // Rule metrics RuleMatchesTotal: promauto.With(registry).NewCounterVec( prometheus.CounterOpts{ @@ -132,12 +175,62 @@ func New(registry prometheus.Registerer) *Metrics { Help: "Total requests rejected due to unknown Host header", }, ), + + // Rate limiter metrics + RateLimitedTotal: promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Namespace: "redirector", + Name: "rate_limited_total", + Help: "Total requests rejected by rate limiting", + }, + []string{"scope"}, // global, per_ip, path + ), } return m } +// RegisterBuildInfo registers a build_info gauge with version metadata labels. +func (m *Metrics) RegisterBuildInfo(registry prometheus.Registerer, info BuildInfo) { + if registry == nil { + registry = prometheus.DefaultRegisterer + } + + goVersion := info.GoVersion + if goVersion == "" { + goVersion = runtime.Version() + } + + m.Info = promauto.With(registry).NewGauge( + prometheus.GaugeOpts{ + Namespace: "redirector", + Name: "build_info", + Help: "Build information for the redirector", + ConstLabels: prometheus.Labels{ + "version": info.Version, + "commit": info.Commit, + "build_time": info.BuildTime, + "go_version": goVersion, + }, + }, + ) + m.Info.Set(1) +} + +// SetConfigInfo updates the config_info gauge with current config metadata. +func (m *Metrics) SetConfigInfo(version, hash, source string) { + m.ConfigInfo.Reset() + m.ConfigInfo.WithLabelValues(version, hash, source).Set(1) +} + +// RecordRateLimited records a rate-limited request. +func (m *Metrics) RecordRateLimited(scope string) { + m.RateLimitedTotal.WithLabelValues(scope).Inc() +} + // NewWithRuntimeMetrics creates metrics including Go runtime metrics. +// The registry parameter must also implement prometheus.Gatherer (e.g. *prometheus.Registry) +// for the standard Go and process collectors to be registered. func NewWithRuntimeMetrics(registry prometheus.Registerer) *Metrics { m := New(registry) @@ -168,9 +261,22 @@ func NewWithRuntimeMetrics(registry prometheus.Registerer) *Metrics { }, ) + // Register standard Go and process collectors (go_*, process_*) + registerStandardCollectors(registry) + return m } +// registerStandardCollectors adds the standard Go and process metric collectors. +// These expose go_gc_duration_seconds, go_memstats_*, process_cpu_seconds_total, +// process_open_fds, process_resident_memory_bytes, etc. +func registerStandardCollectors(registry prometheus.Registerer) { + registry.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) +} + // RecordRequest records metrics for a completed request. func (m *Metrics) RecordRequest(method string, status int, ruleID string, durationSeconds float64, responseBytes int) { statusStr := statusToString(status) diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 9b8f4b1..d8875f7 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -1,9 +1,11 @@ package metrics import ( + "strings" "testing" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" ) func TestNew(t *testing.T) { @@ -22,6 +24,15 @@ func TestNew(t *testing.T) { if m.ConfigReloadsTotal == nil { t.Error("ConfigReloadsTotal not initialized") } + if m.ConfigInfo == nil { + t.Error("ConfigInfo not initialized") + } + if m.UptimeSeconds == nil { + t.Error("UptimeSeconds not initialized") + } + if m.RateLimitedTotal == nil { + t.Error("RateLimitedTotal not initialized") + } } func TestNewWithRuntimeMetrics(t *testing.T) { @@ -37,6 +48,31 @@ func TestNewWithRuntimeMetrics(t *testing.T) { if m.MemoryAlloc == nil { t.Error("MemoryAlloc metric not initialized") } + + // Verify standard collectors are registered by gathering metrics + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + // Look for go_* and process_* metrics from standard collectors + hasGoMetric := false + hasProcessMetric := false + for _, mf := range families { + if strings.HasPrefix(mf.GetName(), "go_") { + hasGoMetric = true + } + if strings.HasPrefix(mf.GetName(), "process_") { + hasProcessMetric = true + } + } + + if !hasGoMetric { + t.Error("Expected go_* metrics from GoCollector") + } + if !hasProcessMetric { + t.Error("Expected process_* metrics from ProcessCollector") + } } func TestRecordRequest(t *testing.T) { @@ -132,3 +168,125 @@ func TestRuntimeMetrics(t *testing.T) { t.Error("Expected non-zero memory allocation") } } + +func TestRegisterBuildInfo(t *testing.T) { + registry := prometheus.NewRegistry() + m := New(registry) + + m.RegisterBuildInfo(registry, BuildInfo{ + Version: "1.2.3", + Commit: "abc123", + BuildTime: "2025-01-15T10:00:00Z", + GoVersion: "go1.25", + }) + + if m.Info == nil { + t.Fatal("Info gauge not set after RegisterBuildInfo") + } + + // Verify the metric has value 1 + expected := ` + # HELP redirector_build_info Build information for the redirector + # TYPE redirector_build_info gauge + redirector_build_info{build_time="2025-01-15T10:00:00Z",commit="abc123",go_version="go1.25",version="1.2.3"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_build_info"); err != nil { + t.Errorf("Build info metric mismatch: %v", err) + } +} + +func TestRegisterBuildInfoDefaultGoVersion(t *testing.T) { + registry := prometheus.NewRegistry() + m := New(registry) + + // When GoVersion is empty, should use runtime.Version() + m.RegisterBuildInfo(registry, BuildInfo{ + Version: "dev", + }) + + if m.Info == nil { + t.Fatal("Info gauge not set") + } +} + +func TestSetConfigInfo(t *testing.T) { + registry := prometheus.NewRegistry() + m := New(registry) + + m.SetConfigInfo("1.0", "sha256:abc123", "config.yaml") + + expected := ` + # HELP redirector_config_info Current configuration metadata + # TYPE redirector_config_info gauge + redirector_config_info{hash="sha256:abc123",source="config.yaml",version="1.0"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_config_info"); err != nil { + t.Errorf("Config info metric mismatch: %v", err) + } + + // Updating should reset old labels and set new ones + m.SetConfigInfo("2.0", "sha256:def456", "new-config.yaml") + + expected2 := ` + # HELP redirector_config_info Current configuration metadata + # TYPE redirector_config_info gauge + redirector_config_info{hash="sha256:def456",source="new-config.yaml",version="2.0"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected2), "redirector_config_info"); err != nil { + t.Errorf("Updated config info metric mismatch: %v", err) + } +} + +func TestUptimeSeconds(t *testing.T) { + registry := prometheus.NewRegistry() + m := New(registry) + + if m.UptimeSeconds == nil { + t.Fatal("UptimeSeconds not initialized") + } + + // Gather and verify it's a positive value + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + found := false + for _, mf := range families { + if mf.GetName() == "redirector_uptime_seconds" { + found = true + if len(mf.GetMetric()) == 0 { + t.Error("Expected at least one metric value") + } else { + val := mf.GetMetric()[0].GetGauge().GetValue() + if val < 0 { + t.Errorf("Expected non-negative uptime, got %f", val) + } + } + } + } + if !found { + t.Error("redirector_uptime_seconds metric not found") + } +} + +func TestRecordRateLimited(t *testing.T) { + registry := prometheus.NewRegistry() + m := New(registry) + + m.RecordRateLimited("global") + m.RecordRateLimited("global") + m.RecordRateLimited("per_ip") + m.RecordRateLimited("path") + + expected := ` + # HELP redirector_rate_limited_total Total requests rejected by rate limiting + # TYPE redirector_rate_limited_total counter + redirector_rate_limited_total{scope="global"} 2 + redirector_rate_limited_total{scope="path"} 1 + redirector_rate_limited_total{scope="per_ip"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_rate_limited_total"); err != nil { + t.Errorf("Rate limited metric mismatch: %v", err) + } +} diff --git a/internal/metrics/syncer_metrics.go b/internal/metrics/syncer_metrics.go new file mode 100644 index 0000000..f68daed --- /dev/null +++ b/internal/metrics/syncer_metrics.go @@ -0,0 +1,178 @@ +// Package metrics provides Prometheus metrics for the redirector. +// This file defines metrics specific to the redirector-sync service. +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// SyncerMetrics holds Prometheus metrics for the redirector-sync service. +type SyncerMetrics struct { + // Sync lifecycle + SyncTotal *prometheus.CounterVec + SyncDuration *prometheus.HistogramVec + LastSyncTimestamp prometheus.Gauge + LastSyncSuccess prometheus.Gauge + + // Source fetch metrics + FetchTotal *prometheus.CounterVec + FetchDuration *prometheus.HistogramVec + + // Target push metrics + PushTotal *prometheus.CounterVec + PushDuration *prometheus.HistogramVec + + // Config metrics + RulesFetched prometheus.Gauge + LintErrors *prometheus.CounterVec +} + +// NewSyncerMetrics creates and registers syncer-specific Prometheus metrics. +func NewSyncerMetrics(registry prometheus.Registerer) *SyncerMetrics { + if registry == nil { + registry = prometheus.DefaultRegisterer + } + + m := &SyncerMetrics{ + SyncTotal: promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Namespace: "redirector_sync", + Name: "sync_total", + Help: "Total number of sync operations", + }, + []string{"status"}, // success, failure + ), + + SyncDuration: promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "redirector_sync", + Name: "sync_duration_seconds", + Help: "Duration of sync operations in seconds", + Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 10, 30, 60}, + }, + []string{"status"}, + ), + + LastSyncTimestamp: promauto.With(registry).NewGauge( + prometheus.GaugeOpts{ + Namespace: "redirector_sync", + Name: "last_sync_timestamp_seconds", + Help: "Unix timestamp of the last sync attempt", + }, + ), + + LastSyncSuccess: promauto.With(registry).NewGauge( + prometheus.GaugeOpts{ + Namespace: "redirector_sync", + Name: "last_sync_success", + Help: "Whether the last sync was successful (1=success, 0=failure)", + }, + ), + + FetchTotal: promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Namespace: "redirector_sync", + Name: "fetch_total", + Help: "Total number of source fetch operations", + }, + []string{"source", "status"}, + ), + + FetchDuration: promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "redirector_sync", + Name: "fetch_duration_seconds", + Help: "Duration of source fetch operations in seconds", + Buckets: []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 30}, + }, + []string{"source"}, + ), + + PushTotal: promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Namespace: "redirector_sync", + Name: "push_total", + Help: "Total number of config push operations to targets", + }, + []string{"target", "status"}, + ), + + PushDuration: promauto.With(registry).NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "redirector_sync", + Name: "push_duration_seconds", + Help: "Duration of config push operations in seconds", + Buckets: []float64{.05, .1, .25, .5, 1, 2.5, 5, 10}, + }, + []string{"target"}, + ), + + RulesFetched: promauto.With(registry).NewGauge( + prometheus.GaugeOpts{ + Namespace: "redirector_sync", + Name: "rules_fetched", + Help: "Number of rules from the last successful fetch", + }, + ), + + LintErrors: promauto.With(registry).NewCounterVec( + prometheus.CounterOpts{ + Namespace: "redirector_sync", + Name: "lint_errors_total", + Help: "Total config lint errors encountered during sync", + }, + []string{"source"}, + ), + } + + // Register standard process collectors + registry.MustRegister( + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) + + return m +} + +// RecordSync records a completed sync operation. +func (m *SyncerMetrics) RecordSync(success bool, durationSeconds float64) { + status := "success" + if !success { + status = "failure" + } + m.SyncTotal.WithLabelValues(status).Inc() + m.SyncDuration.WithLabelValues(status).Observe(durationSeconds) + m.LastSyncTimestamp.SetToCurrentTime() + if success { + m.LastSyncSuccess.Set(1) + } else { + m.LastSyncSuccess.Set(0) + } +} + +// RecordFetch records a source fetch operation. +func (m *SyncerMetrics) RecordFetch(source string, success bool, durationSeconds float64) { + status := "success" + if !success { + status = "failure" + } + m.FetchTotal.WithLabelValues(source, status).Inc() + m.FetchDuration.WithLabelValues(source).Observe(durationSeconds) +} + +// RecordPush records a config push to a target. +func (m *SyncerMetrics) RecordPush(target string, success bool, durationSeconds float64) { + status := "success" + if !success { + status = "failure" + } + m.PushTotal.WithLabelValues(target, status).Inc() + m.PushDuration.WithLabelValues(target).Observe(durationSeconds) +} + +// RecordLintError records a lint error for a source. +func (m *SyncerMetrics) RecordLintError(source string) { + m.LintErrors.WithLabelValues(source).Inc() +} diff --git a/internal/metrics/syncer_metrics_test.go b/internal/metrics/syncer_metrics_test.go new file mode 100644 index 0000000..97208eb --- /dev/null +++ b/internal/metrics/syncer_metrics_test.go @@ -0,0 +1,139 @@ +package metrics + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestNewSyncerMetrics(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewSyncerMetrics(registry) + + if m == nil { + t.Fatal("NewSyncerMetrics returned nil") + } + if m.SyncTotal == nil { + t.Error("SyncTotal not initialized") + } + if m.FetchTotal == nil { + t.Error("FetchTotal not initialized") + } + if m.PushTotal == nil { + t.Error("PushTotal not initialized") + } + if m.LastSyncTimestamp == nil { + t.Error("LastSyncTimestamp not initialized") + } + if m.RulesFetched == nil { + t.Error("RulesFetched not initialized") + } + if m.LintErrors == nil { + t.Error("LintErrors not initialized") + } + + // Verify standard collectors are registered + families, err := registry.Gather() + if err != nil { + t.Fatalf("Failed to gather metrics: %v", err) + } + + hasGoMetric := false + for _, mf := range families { + if strings.HasPrefix(mf.GetName(), "go_") { + hasGoMetric = true + break + } + } + if !hasGoMetric { + t.Error("Expected go_* metrics from GoCollector") + } +} + +func TestRecordSync(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewSyncerMetrics(registry) + + m.RecordSync(true, 1.5) + m.RecordSync(false, 0.5) + m.RecordSync(true, 2.0) + + expected := ` + # HELP redirector_sync_sync_total Total number of sync operations + # TYPE redirector_sync_sync_total counter + redirector_sync_sync_total{status="failure"} 1 + redirector_sync_sync_total{status="success"} 2 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_sync_sync_total"); err != nil { + t.Errorf("Sync total metric mismatch: %v", err) + } + + // Last sync should be success (value=1) + expectedSuccess := ` + # HELP redirector_sync_last_sync_success Whether the last sync was successful (1=success, 0=failure) + # TYPE redirector_sync_last_sync_success gauge + redirector_sync_last_sync_success 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expectedSuccess), "redirector_sync_last_sync_success"); err != nil { + t.Errorf("Last sync success metric mismatch: %v", err) + } +} + +func TestRecordFetch(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewSyncerMetrics(registry) + + m.RecordFetch("github-primary", true, 0.5) + m.RecordFetch("github-primary", false, 1.0) + m.RecordFetch("s3-backup", true, 0.3) + + expected := ` + # HELP redirector_sync_fetch_total Total number of source fetch operations + # TYPE redirector_sync_fetch_total counter + redirector_sync_fetch_total{source="github-primary",status="failure"} 1 + redirector_sync_fetch_total{source="github-primary",status="success"} 1 + redirector_sync_fetch_total{source="s3-backup",status="success"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_sync_fetch_total"); err != nil { + t.Errorf("Fetch total metric mismatch: %v", err) + } +} + +func TestRecordPush(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewSyncerMetrics(registry) + + m.RecordPush("redirector-1", true, 0.1) + m.RecordPush("redirector-2", false, 5.0) + + expected := ` + # HELP redirector_sync_push_total Total number of config push operations to targets + # TYPE redirector_sync_push_total counter + redirector_sync_push_total{status="success",target="redirector-1"} 1 + redirector_sync_push_total{status="failure",target="redirector-2"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_sync_push_total"); err != nil { + t.Errorf("Push total metric mismatch: %v", err) + } +} + +func TestRecordLintError(t *testing.T) { + registry := prometheus.NewRegistry() + m := NewSyncerMetrics(registry) + + m.RecordLintError("github-primary") + m.RecordLintError("github-primary") + m.RecordLintError("s3-backup") + + expected := ` + # HELP redirector_sync_lint_errors_total Total config lint errors encountered during sync + # TYPE redirector_sync_lint_errors_total counter + redirector_sync_lint_errors_total{source="github-primary"} 2 + redirector_sync_lint_errors_total{source="s3-backup"} 1 + ` + if err := testutil.GatherAndCompare(registry, strings.NewReader(expected), "redirector_sync_lint_errors_total"); err != nil { + t.Errorf("Lint errors metric mismatch: %v", err) + } +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index 2880050..3dbee93 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -67,6 +67,10 @@ type ipLimiter struct { lastAccess time.Time } +// OnLimitedFunc is called when a request is rate-limited, with the scope +// that triggered the limit (e.g. "global", "per_ip", "path"). +type OnLimitedFunc func(scope string) + // Limiter provides rate limiting functionality. type Limiter struct { cfg *Config @@ -74,6 +78,7 @@ type Limiter struct { ipLimiters map[string]*ipLimiter pathLimiters map[string]*rate.Limiter exemptNets []*net.IPNet + onLimited OnLimitedFunc mu sync.RWMutex stopCleanup chan struct{} } @@ -158,6 +163,11 @@ func (l *Limiter) cleanup() { } } +// SetOnLimited registers a callback invoked when a request is rate-limited. +func (l *Limiter) SetOnLimited(fn OnLimitedFunc) { + l.onLimited = fn +} + // Allow checks if a request should be allowed. func (l *Limiter) Allow(ctx *fasthttp.RequestCtx) bool { if !l.cfg.Enabled { @@ -174,6 +184,9 @@ func (l *Limiter) Allow(ctx *fasthttp.RequestCtx) bool { // Check global limit if l.globalLimiter != nil && !l.globalLimiter.Allow() { log.Debug().Msg("Global rate limit exceeded") + if l.onLimited != nil { + l.onLimited("global") + } return false } @@ -182,6 +195,9 @@ func (l *Limiter) Allow(ctx *fasthttp.RequestCtx) bool { if pathLimiter := l.getPathLimiter(path); pathLimiter != nil { if !pathLimiter.Allow() { log.Debug().Str("path", path).Msg("Path rate limit exceeded") + if l.onLimited != nil { + l.onLimited("path") + } return false } } @@ -191,6 +207,9 @@ func (l *Limiter) Allow(ctx *fasthttp.RequestCtx) bool { ipLimiter := l.getIPLimiter(clientIP.String()) if !ipLimiter.Allow() { log.Debug().Str("ip", clientIP.String()).Msg("IP rate limit exceeded") + if l.onLimited != nil { + l.onLimited("per_ip") + } return false } } diff --git a/internal/server/server.go b/internal/server/server.go index e53ee28..5a18c08 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -58,6 +58,7 @@ type Server struct { auditLog *versioning.AuditLog tracingProvider *tracing.Provider rateLimiter *ratelimit.Limiter + buildInfo metrics.BuildInfo httpServer *fasthttp.Server managementServer *fasthttp.Server @@ -66,9 +67,23 @@ type Server struct { mu sync.RWMutex } +// Option configures optional server parameters. +type Option func(*Server) + +// WithBuildInfo sets version metadata for the build_info Prometheus metric. +func WithBuildInfo(version, commit, buildTime string) Option { + return func(s *Server) { + s.buildInfo = metrics.BuildInfo{ + Version: version, + Commit: commit, + BuildTime: buildTime, + } + } +} + // New creates a new server instance. // configPath is stored for reload operations. -func New(cfg *config.Config, configPath string) (*Server, error) { +func New(cfg *config.Config, configPath string, opts ...Option) (*Server, error) { r, err := router.New(cfg.Rules) if err != nil { return nil, fmt.Errorf("creating router: %w", err) @@ -172,6 +187,9 @@ func New(cfg *config.Config, configPath string) (*Server, error) { }) } rateLimiter = ratelimit.New(rateLimitCfg) + rateLimiter.SetOnLimited(func(scope string) { + m.RecordRateLimited(scope) + }) log.Info(). Float64("global_rps", rateLimitCfg.GlobalRPS). Float64("per_ip_rps", rateLimitCfg.PerIPRPS). @@ -221,11 +239,22 @@ func New(cfg *config.Config, configPath string) (*Server, error) { promHandler: promHandler, } + // Apply options + for _, opt := range opts { + opt(s) + } + + // Register build info metric + m.RegisterBuildInfo(registry, s.buildInfo) + // Record initial config version initialVersion := versionStore.Add(cfg, configPath) auditLog.LogConfigChange(versioning.AuditEventConfigLoaded, initialVersion, "system", "startup") log.Info().Int("version", initialVersion.Version).Str("hash", initialVersion.Hash).Msg("Initial config version recorded") + // Set initial config info metric + m.SetConfigInfo(cfg.Version, initialVersion.Hash, configPath) + // Configure main HTTP server handler with optional rate limiting redirectHandler := s.handleRedirect if rateLimiter != nil { @@ -712,6 +741,7 @@ func (s *Server) ReloadConfig(path string) error { // Record successful reload if s.metrics != nil { s.metrics.RecordConfigReload(true, len(cfg.Rules), time.Since(start).Seconds()) + s.metrics.SetConfigInfo(cfg.Version, version.Hash, path) } // Record in tracing span From 36d512ac69061c7c7c5d13434f297e30bab84361 Mon Sep 17 00:00:00 2001 From: PePe Amengual <2208324+jamengual@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:17:23 -0800 Subject: [PATCH 2/2] Fix gofmt formatting in metrics struct fields Co-Authored-By: Claude Opus 4.6 --- internal/metrics/metrics.go | 2 +- internal/metrics/syncer_metrics.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 6ad4a98..12455c9 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -40,7 +40,7 @@ type Metrics struct { MemoryAlloc prometheus.GaugeFunc // Build & uptime metrics - Info prometheus.Gauge + Info prometheus.Gauge UptimeSeconds prometheus.GaugeFunc // Rule metrics diff --git a/internal/metrics/syncer_metrics.go b/internal/metrics/syncer_metrics.go index f68daed..b3dbf67 100644 --- a/internal/metrics/syncer_metrics.go +++ b/internal/metrics/syncer_metrics.go @@ -13,7 +13,7 @@ type SyncerMetrics struct { // Sync lifecycle SyncTotal *prometheus.CounterVec SyncDuration *prometheus.HistogramVec - LastSyncTimestamp prometheus.Gauge + LastSyncTimestamp prometheus.Gauge LastSyncSuccess prometheus.Gauge // Source fetch metrics