Skip to content

Commit 321fd54

Browse files
Copilotluisgizirian
andcommitted
Extend StatsDataProvider coverage to /report, /timeseries, and /metrics endpoints
Co-authored-by: luisgizirian <598685+luisgizirian@users.noreply.github.com>
1 parent 35c7489 commit 321fd54

4 files changed

Lines changed: 341 additions & 19 deletions

File tree

README.md

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,7 +1262,7 @@ Ops Defender provides **four extensibility points** that allow external code to
12621262
1. **RequestPreHandler** - Intercept requests **before** processing (early bypass)
12631263
2. **PatternAnalyzer** - Inject custom pattern detection **during** deferred analysis
12641264
3. **RequestPostHandler** - Intercept requests **after** processing, before response (override decisions)
1265-
4. **StatsDataProvider** - Contribute custom data **to** `/stats` and `/events` responses
1265+
4. **StatsDataProvider** - Contribute custom data **to all informational endpoints** (`/stats`, `/report`, `/timeseries`, `/metrics`, `/events`)
12661266

12671267
### Extension Point 1: RequestPreHandler (Pre-Request Bypass)
12681268

@@ -1894,23 +1894,37 @@ Ops Defender's four extension points provide complete request lifecycle and obse
18941894
1. **PreHandler** - Early bypass (before any processing)
18951895
2. **PatternAnalyzer** - Custom detection (during deferred analysis)
18961896
3. **PostHandler** - Final override (after processing, before response)
1897-
4. **StatsDataProvider** - Custom data in `/stats` and `/events` responses
1897+
4. **StatsDataProvider** - Custom data in all informational endpoints (`/stats`, `/report`, `/timeseries`, `/metrics`, `/events`)
18981898

18991899
This architecture enables complete customization without modifying core code.
19001900

19011901
### Extension Point 4: StatsDataProvider (Custom Queryable Data)
19021902

1903-
Allows extensions to contribute custom data to the `/stats` and `/events` endpoints. Data is namespaced by provider name under an `"extensions"` key, so multiple providers cannot conflict with each other or with core fields.
1903+
Allows extensions to contribute custom data to **all informational endpoints**. Register once — your data appears across `/stats`, `/report`, `/timeseries`, `/events` (SSE), and `/metrics` (Prometheus) without creating per-extension routes.
1904+
1905+
Data is namespaced by provider name under an `"extensions"` key in JSON responses. In `/metrics`, numeric values are exposed as Prometheus gauges named `ops_defender_extension_<provider>_<key>`. Non-numeric values are skipped in the Prometheus output.
1906+
1907+
**Covered endpoints:**
1908+
1909+
| Endpoint | Format | How extension data appears |
1910+
|----------|--------|---------------------------|
1911+
| `/stats` | JSON | `"extensions": { "<name>": { ... } }` |
1912+
| `/report` | JSON | `"extensions": { "<name>": { ... } }` |
1913+
| `/timeseries` | JSON | `"extensions": { "<name>": { ... } }` |
1914+
| `/events` | SSE JSON | `"extensions": { "<name>": { ... } }` in `stats_update` events |
1915+
| `/metrics` | Prometheus text | `ops_defender_extension_<name>_<key> <numeric_value>` |
19041916

19051917
**Use Cases:**
1906-
- Expose extension-specific counters or state via the existing `/stats` endpoint
1918+
- Expose extension-specific counters or state via existing endpoints
19071919
- Include custom metrics in the real-time `/events` SSE stream
1908-
- Provide per-extension diagnostics to operators without creating per-extension endpoints
1920+
- Surface extension data in Prometheus/Grafana dashboards
1921+
- Provide per-extension diagnostics in periodic reports
19091922

19101923
**Interface:**
19111924
```go
19121925
type StatsDataProvider interface {
1913-
// GetStats returns custom key-value data included in /stats and /events responses.
1926+
// GetStats returns custom key-value data included in all informational endpoint responses.
1927+
// Numeric values are also emitted as Prometheus gauges in /metrics.
19141928
// Called on the response path - keep it fast (use cached/in-memory data).
19151929
GetStats() (map[string]interface{}, error)
19161930

@@ -1938,6 +1952,20 @@ type StatsDataProvider interface {
19381952
}
19391953
```
19401954

1955+
**Prometheus `/metrics` output** (numeric values only):
1956+
```text
1957+
# HELP ops_defender_extension_sql_injection_detector_sql_attempts_detected Extension metric from sql-injection-detector
1958+
# TYPE ops_defender_extension_sql_injection_detector_sql_attempts_detected gauge
1959+
ops_defender_extension_sql_injection_detector_sql_attempts_detected 42
1960+
1961+
# HELP ops_defender_extension_geo_blocker_blocked_by_geo Extension metric from geo-blocker
1962+
# TYPE ops_defender_extension_geo_blocker_blocked_by_geo gauge
1963+
ops_defender_extension_geo_blocker_blocked_by_geo 7
1964+
```
1965+
1966+
> Metric names are auto-sanitized: hyphens and spaces → underscores, lowercased.
1967+
> String/boolean values are silently skipped in Prometheus output but still appear in JSON responses.
1968+
19411969
**Example Implementation:**
19421970
```go
19431971
type SQLInjectionStats struct {
@@ -1953,7 +1981,7 @@ func (s *SQLInjectionStats) GetStats() (map[string]interface{}, error) {
19531981
defer s.mu.Unlock()
19541982
return map[string]interface{}{
19551983
"sql_attempts_detected": s.attempts,
1956-
"last_detected_ip": s.lastIP,
1984+
"last_detected_ip": s.lastIP, // appears in JSON; skipped in Prometheus
19571985
}, nil
19581986
}
19591987

pkg/defender/defender.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,14 +1450,15 @@ func (d *Defender) GetStats(w http.ResponseWriter, r *http.Request) {
14501450
}
14511451

14521452
type Report struct {
1453-
GeneratedAt string `json:"generated_at"`
1454-
Period string `json:"period"`
1455-
TotalRequests int64 `json:"total_requests"`
1456-
BlockedRequests int64 `json:"blocked_requests"`
1457-
UniqueIPs int `json:"unique_ips"`
1458-
BlockedIPs int `json:"blocked_ips_count"`
1459-
BlockEvents []storage.BlockEvent `json:"block_events"`
1460-
TopSuspiciousIPs []IPStats `json:"top_suspicious_ips"`
1453+
GeneratedAt string `json:"generated_at"`
1454+
Period string `json:"period"`
1455+
TotalRequests int64 `json:"total_requests"`
1456+
BlockedRequests int64 `json:"blocked_requests"`
1457+
UniqueIPs int `json:"unique_ips"`
1458+
BlockedIPs int `json:"blocked_ips_count"`
1459+
BlockEvents []storage.BlockEvent `json:"block_events"`
1460+
TopSuspiciousIPs []IPStats `json:"top_suspicious_ips"`
1461+
Extensions map[string]interface{} `json:"extensions,omitempty"`
14611462
}
14621463

14631464
func (d *Defender) GenerateReport(periodHours int) Report {
@@ -1520,6 +1521,9 @@ func (d *Defender) GenerateReport(periodHours int) Report {
15201521
}
15211522
}
15221523

1524+
// Collect data from registered StatsDataProviders
1525+
report.Extensions = d.collectExtensionStats()
1526+
15231527
return report
15241528
}
15251529

pkg/defender/defender_test.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"strings"
910
"sync"
1011
"testing"
1112
"time"
@@ -2397,3 +2398,224 @@ if _, exists := raw["extensions"]; exists {
23972398
t.Error("Expected 'extensions' field to be absent when no providers registered")
23982399
}
23992400
}
2401+
2402+
func TestDefender_GetReport_IncludesExtensions(t *testing.T) {
2403+
d := newTestDefender()
2404+
2405+
d.RegisterStatsProvider(&mockStatsDataProvider{
2406+
name: "report-ext",
2407+
data: map[string]interface{}{"report_metric": 7},
2408+
})
2409+
2410+
req := httptest.NewRequest("GET", "/report?period=1", nil)
2411+
w := httptest.NewRecorder()
2412+
d.GetReport(w, req)
2413+
2414+
if w.Code != http.StatusOK {
2415+
t.Fatalf("Expected 200, got %d", w.Code)
2416+
}
2417+
2418+
var report Report
2419+
if err := json.NewDecoder(w.Body).Decode(&report); err != nil {
2420+
t.Fatalf("Failed to decode report: %v", err)
2421+
}
2422+
2423+
if report.Extensions == nil {
2424+
t.Fatal("Expected extensions field to be non-nil")
2425+
}
2426+
2427+
extData, ok := report.Extensions["report-ext"]
2428+
if !ok {
2429+
t.Fatal("Expected 'report-ext' key in extensions")
2430+
}
2431+
2432+
dataMap, ok := extData.(map[string]interface{})
2433+
if !ok {
2434+
t.Fatalf("Expected map, got %T", extData)
2435+
}
2436+
2437+
if dataMap["report_metric"] != float64(7) {
2438+
t.Errorf("Expected report_metric=7, got %v", dataMap["report_metric"])
2439+
}
2440+
}
2441+
2442+
func TestDefender_GetReport_NoExtensions_OmitsField(t *testing.T) {
2443+
d := newTestDefender()
2444+
2445+
req := httptest.NewRequest("GET", "/report?period=1", nil)
2446+
w := httptest.NewRecorder()
2447+
d.GetReport(w, req)
2448+
2449+
if w.Code != http.StatusOK {
2450+
t.Fatalf("Expected 200, got %d", w.Code)
2451+
}
2452+
2453+
var raw map[string]interface{}
2454+
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
2455+
t.Fatalf("Failed to decode report: %v", err)
2456+
}
2457+
2458+
if _, exists := raw["extensions"]; exists {
2459+
t.Error("Expected 'extensions' field to be absent when no providers registered")
2460+
}
2461+
}
2462+
2463+
func TestDefender_MetricsHandler_IncludesExtensions(t *testing.T) {
2464+
d := newTestDefender()
2465+
2466+
d.RegisterStatsProvider(&mockStatsDataProvider{
2467+
name: "my-provider",
2468+
data: map[string]interface{}{
2469+
"hit_count": int64(123),
2470+
"string_skip": "ignored",
2471+
},
2472+
})
2473+
2474+
req := httptest.NewRequest("GET", "/metrics", nil)
2475+
w := httptest.NewRecorder()
2476+
d.MetricsHandler(w, req)
2477+
2478+
if w.Code != http.StatusOK {
2479+
t.Fatalf("Expected 200, got %d", w.Code)
2480+
}
2481+
2482+
body := w.Body.String()
2483+
2484+
// Numeric value should appear as a Prometheus gauge
2485+
if !strings.Contains(body, "ops_defender_extension_my_provider_hit_count") {
2486+
t.Errorf("Expected extension metric 'ops_defender_extension_my_provider_hit_count' in output, got:\n%s", body)
2487+
}
2488+
2489+
// String values must be skipped
2490+
if strings.Contains(body, "string_skip") {
2491+
t.Errorf("Expected string value to be skipped, but found 'string_skip' in output")
2492+
}
2493+
}
2494+
2495+
func TestDefender_MetricsHandler_NoExtensions_NoExtraOutput(t *testing.T) {
2496+
d := newTestDefender()
2497+
2498+
req := httptest.NewRequest("GET", "/metrics", nil)
2499+
w := httptest.NewRecorder()
2500+
d.MetricsHandler(w, req)
2501+
2502+
body := w.Body.String()
2503+
2504+
if strings.Contains(body, "ops_defender_extension_") {
2505+
t.Errorf("Expected no extension metrics with no providers, but found some in output:\n%s", body)
2506+
}
2507+
}
2508+
2509+
func TestDefender_TimeSeriesHandler_IncludesExtensions(t *testing.T) {
2510+
d := newTestDefender()
2511+
2512+
d.RegisterStatsProvider(&mockStatsDataProvider{
2513+
name: "ts-ext",
2514+
data: map[string]interface{}{"ts_counter": 55},
2515+
})
2516+
2517+
req := httptest.NewRequest("GET", "/timeseries?period=1&interval=1h", nil)
2518+
w := httptest.NewRecorder()
2519+
d.TimeSeriesHandler(w, req)
2520+
2521+
if w.Code != http.StatusOK {
2522+
t.Fatalf("Expected 200, got %d", w.Code)
2523+
}
2524+
2525+
var resp TimeSeriesResponse
2526+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
2527+
t.Fatalf("Failed to decode timeseries response: %v", err)
2528+
}
2529+
2530+
if resp.Extensions == nil {
2531+
t.Fatal("Expected extensions field to be non-nil")
2532+
}
2533+
2534+
extData, ok := resp.Extensions["ts-ext"]
2535+
if !ok {
2536+
t.Fatal("Expected 'ts-ext' key in extensions")
2537+
}
2538+
2539+
dataMap, ok := extData.(map[string]interface{})
2540+
if !ok {
2541+
t.Fatalf("Expected map, got %T", extData)
2542+
}
2543+
2544+
if dataMap["ts_counter"] != float64(55) {
2545+
t.Errorf("Expected ts_counter=55, got %v", dataMap["ts_counter"])
2546+
}
2547+
}
2548+
2549+
func TestDefender_TimeSeriesHandler_NoExtensions_OmitsField(t *testing.T) {
2550+
d := newTestDefender()
2551+
2552+
req := httptest.NewRequest("GET", "/timeseries?period=1&interval=1h", nil)
2553+
w := httptest.NewRecorder()
2554+
d.TimeSeriesHandler(w, req)
2555+
2556+
if w.Code != http.StatusOK {
2557+
t.Fatalf("Expected 200, got %d", w.Code)
2558+
}
2559+
2560+
var raw map[string]interface{}
2561+
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
2562+
t.Fatalf("Failed to decode timeseries response: %v", err)
2563+
}
2564+
2565+
if _, exists := raw["extensions"]; exists {
2566+
t.Error("Expected 'extensions' field to be absent when no providers registered")
2567+
}
2568+
}
2569+
2570+
func TestSanitizePrometheusName(t *testing.T) {
2571+
tests := []struct {
2572+
input string
2573+
expected string
2574+
}{
2575+
{"my-provider", "my_provider"},
2576+
{"My Provider", "my_provider"},
2577+
{"hit.count", "hit_count"},
2578+
{"sql-injection-detector", "sql_injection_detector"},
2579+
{"already_valid", "already_valid"},
2580+
{"has spaces", "has_spaces"},
2581+
}
2582+
2583+
for _, tt := range tests {
2584+
t.Run(tt.input, func(t *testing.T) {
2585+
got := sanitizePrometheusName(tt.input)
2586+
if got != tt.expected {
2587+
t.Errorf("sanitizePrometheusName(%q) = %q, want %q", tt.input, got, tt.expected)
2588+
}
2589+
})
2590+
}
2591+
}
2592+
2593+
func TestToFloat64(t *testing.T) {
2594+
tests := []struct {
2595+
input interface{}
2596+
expected float64
2597+
ok bool
2598+
}{
2599+
{int(42), 42.0, true},
2600+
{int32(10), 10.0, true},
2601+
{int64(100), 100.0, true},
2602+
{float32(3.14), float64(float32(3.14)), true},
2603+
{float64(2.71), 2.71, true},
2604+
{uint(5), 5.0, true},
2605+
{uint32(7), 7.0, true},
2606+
{uint64(9), 9.0, true},
2607+
{"string", 0, false},
2608+
{true, 0, false},
2609+
{nil, 0, false},
2610+
}
2611+
2612+
for _, tt := range tests {
2613+
got, ok := toFloat64(tt.input)
2614+
if ok != tt.ok {
2615+
t.Errorf("toFloat64(%v): ok=%v, want %v", tt.input, ok, tt.ok)
2616+
}
2617+
if ok && got != tt.expected {
2618+
t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.expected)
2619+
}
2620+
}
2621+
}

0 commit comments

Comments
 (0)