Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 58 additions & 3 deletions cmd/redirector-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)).
Expand All @@ -678,13 +705,21 @@ 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()

if lintResult.HasErrors() {
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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/redirector/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
23 changes: 23 additions & 0 deletions docs/MANAGEMENT_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
Loading
Loading