Skip to content

Commit e19d62c

Browse files
committed
Add path latency metrics and race-detection CI job
1 parent e3650d4 commit e19d62c

5 files changed

Lines changed: 100 additions & 50 deletions

File tree

.github/workflows/main-cicd.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,15 @@ jobs:
5959
with:
6060
name: benchmark-results
6161
path: /tmp/bench/benchmark.txt
62+
63+
race:
64+
runs-on: ubuntu-latest
65+
needs: build
66+
steps:
67+
- uses: actions/checkout@v4
68+
- name: Set up Go
69+
uses: actions/setup-go@v5
70+
with:
71+
go-version: "1.24.13"
72+
- name: Run race detector
73+
run: go test -race ./...

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ The wrapper process is still named `datafog-shim` for compatibility, but we desc
4040

4141
## Positioning
4242

43-
- **Developers and agent builders:** DataFog is a **privacy firewall for CLI tools and AI agents**. It sits in your PATH or runtime, inspects what is flowing through your commands, and enforces policy before data-sensitive actions execute.
44-
- **Security/compliance buyers:** DataFog is runtime policy-as-code enforcement at the process level with receipts for every decision.
45-
- **Broader view:** DataFog is the **data plane for agent governance** — detect, decide, enforce, and audit—not just “PII redaction.”
43+
- **Developers and agent builders:** DataFog is a **data-aware policy enforcement layer** for CLI tools and AI agents. It sits in your PATH or runtime, inspects data flowing through commands, and enforces policy before sensitive actions execute.
44+
- **Security/compliance buyers:** DataFog maps closely to runtime DLP for developer workstations, but without the legacy footprint: policy is programmable (OPA-style), decision-aware, and process-bound.
45+
- **Broader view:** DataFog is the **data plane for agent governance** — detect, decide, enforce, and audit.
4646

4747
## Repository layout
4848

@@ -373,8 +373,9 @@ spec:
373373
## If something fails, check these first
374374

375375
1. `go test ./...` (build/runtime validation before changing policy)
376-
2. `/health` response for policy id/version mismatch
377-
3. Environment variables are set and files are writable
378-
4. API token/header if `DATAFOG_API_TOKEN` is configured
379-
5. Policy JSON is valid and rules match expected action fields
380-
6. Optional benchmark sweep: `scripts/run-benchmarks.sh`
376+
2. `go test -race ./...` (check race conditions on concurrency-sensitive paths)
377+
3. `/health` response for policy id/version mismatch
378+
4. Environment variables are set and files are writable
379+
5. API token/header if `DATAFOG_API_TOKEN` is configured
380+
6. Policy JSON is valid and rules match expected action fields
381+
7. Optional benchmark sweep: `scripts/run-benchmarks.sh`

docs/OBSERVABILITY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ In-process counters are exposed at `GET /metrics`:
2121
- `by_status`
2222
- `by_path`
2323
- `by_method`
24+
- `avg_latency_ms`
25+
- `by_path_avg_latency_ms`
2426
- `uptime_seconds`
2527
- `started_at`
2628

internal/server/server.go

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,27 @@ import (
2727
)
2828

2929
type Server struct {
30-
policy models.Policy
31-
store *receipts.ReceiptStore
32-
eventReader shim.EventReader
33-
apiToken string
34-
rateLimiter *tokenBucket
35-
startedAt time.Time
36-
logger *log.Logger
37-
mu sync.Mutex
38-
statsMu sync.Mutex
39-
decisions map[string]idempotentDecision
40-
scans map[string]idempotentCachedResponse
41-
transforms map[string]idempotentCachedResponse
42-
anonymizes map[string]idempotentCachedResponse
43-
totalCount int64
44-
errorCount int64
45-
statusHits map[int]int64
46-
pathHits map[string]int64
47-
methodHits map[string]int64
30+
policy models.Policy
31+
store *receipts.ReceiptStore
32+
eventReader shim.EventReader
33+
apiToken string
34+
rateLimiter *tokenBucket
35+
startedAt time.Time
36+
logger *log.Logger
37+
mu sync.Mutex
38+
statsMu sync.Mutex
39+
decisions map[string]idempotentDecision
40+
scans map[string]idempotentCachedResponse
41+
transforms map[string]idempotentCachedResponse
42+
anonymizes map[string]idempotentCachedResponse
43+
totalCount int64
44+
errorCount int64
45+
statusHits map[int]int64
46+
pathHits map[string]int64
47+
methodHits map[string]int64
48+
totalLatencyNs int64
49+
pathLatencyNs map[string]int64
50+
pathLatencyCounts map[string]int64
4851
}
4952

5053
type requestIDContextKey struct{}
@@ -82,13 +85,15 @@ type idempotentCachedResponse struct {
8285
}
8386

8487
type metricsResponse struct {
85-
TotalRequests int64 `json:"total_requests"`
86-
ErrorRequests int64 `json:"error_requests"`
87-
ByStatus map[string]int64 `json:"by_status"`
88-
ByPath map[string]int64 `json:"by_path"`
89-
ByMethod map[string]int64 `json:"by_method"`
90-
StartedAt string `json:"started_at"`
91-
UptimeSeconds float64 `json:"uptime_seconds"`
88+
TotalRequests int64 `json:"total_requests"`
89+
ErrorRequests int64 `json:"error_requests"`
90+
ByStatus map[string]int64 `json:"by_status"`
91+
ByPath map[string]int64 `json:"by_path"`
92+
ByMethod map[string]int64 `json:"by_method"`
93+
ByPathLatency map[string]float64 `json:"by_path_avg_latency_ms"`
94+
AvgLatencyMs float64 `json:"avg_latency_ms"`
95+
StartedAt string `json:"started_at"`
96+
UptimeSeconds float64 `json:"uptime_seconds"`
9297
}
9398

9499
func New(policyData models.Policy, store *receipts.ReceiptStore, logger *log.Logger, apiToken string, rateLimitRPS int) *Server {
@@ -97,19 +102,22 @@ func New(policyData models.Policy, store *receipts.ReceiptStore, logger *log.Log
97102
}
98103
policyData = policy.NormalizeForEvaluation(policyData)
99104
return &Server{
100-
policy: policyData,
101-
store: store,
102-
apiToken: apiToken,
103-
rateLimiter: newTokenBucket(rateLimitRPS),
104-
startedAt: time.Now().UTC(),
105-
logger: logger,
106-
decisions: map[string]idempotentDecision{},
107-
scans: map[string]idempotentCachedResponse{},
108-
transforms: map[string]idempotentCachedResponse{},
109-
anonymizes: map[string]idempotentCachedResponse{},
110-
statusHits: map[int]int64{},
111-
pathHits: map[string]int64{},
112-
methodHits: map[string]int64{},
105+
policy: policyData,
106+
store: store,
107+
apiToken: apiToken,
108+
rateLimiter: newTokenBucket(rateLimitRPS),
109+
startedAt: time.Now().UTC(),
110+
logger: logger,
111+
decisions: map[string]idempotentDecision{},
112+
scans: map[string]idempotentCachedResponse{},
113+
transforms: map[string]idempotentCachedResponse{},
114+
anonymizes: map[string]idempotentCachedResponse{},
115+
statusHits: map[int]int64{},
116+
pathHits: map[string]int64{},
117+
methodHits: map[string]int64{},
118+
totalLatencyNs: 0,
119+
pathLatencyNs: map[string]int64{},
120+
pathLatencyCounts: map[string]int64{},
113121
}
114122
}
115123

@@ -164,6 +172,7 @@ func (s *Server) wrapMiddleware(mux *http.ServeMux) http.Handler {
164172
startedAt := time.Now()
165173
handler, pattern := mux.Handler(r)
166174
defer func() {
175+
latency := time.Since(startedAt)
167176
if rec := recover(); rec != nil {
168177
responseWriter.status = http.StatusInternalServerError
169178
s.logger.Printf("request panic request_id=%s method=%s path=%s err=%v", reqID, r.Method, r.URL.Path, rec)
@@ -173,11 +182,11 @@ func (s *Server) wrapMiddleware(mux *http.ServeMux) http.Handler {
173182
responseWriter.status = http.StatusOK
174183
}
175184
if pattern == "" {
176-
s.recordRequestMetrics(r.Method, "/_not_found", responseWriter.status)
185+
s.recordRequestMetrics(r.Method, "/_not_found", responseWriter.status, latency)
177186
} else {
178-
s.recordRequestMetrics(r.Method, canonicalizedRoute(pattern, r.URL.Path), responseWriter.status)
187+
s.recordRequestMetrics(r.Method, canonicalizedRoute(pattern, r.URL.Path), responseWriter.status, latency)
179188
}
180-
s.logger.Printf("request complete request_id=%s method=%s path=%s status=%d latency_ms=%d", reqID, r.Method, r.URL.Path, responseWriter.status, time.Since(startedAt).Milliseconds())
189+
s.logger.Printf("request complete request_id=%s method=%s path=%s status=%d latency_ms=%d", reqID, r.Method, r.URL.Path, responseWriter.status, latency.Milliseconds())
181190
}()
182191

183192
if !s.authorized(r) {
@@ -275,7 +284,7 @@ func canonicalizedRoute(pattern string, path string) string {
275284
return pattern
276285
}
277286

278-
func (s *Server) recordRequestMetrics(method string, route string, status int) {
287+
func (s *Server) recordRequestMetrics(method string, route string, status int, latency time.Duration) {
279288
s.statsMu.Lock()
280289
defer s.statsMu.Unlock()
281290
s.totalCount++
@@ -285,6 +294,11 @@ func (s *Server) recordRequestMetrics(method string, route string, status int) {
285294
if status >= 400 {
286295
s.errorCount++
287296
}
297+
298+
latencyNs := latency.Nanoseconds()
299+
s.totalLatencyNs += latencyNs
300+
s.pathLatencyNs[route] += latencyNs
301+
s.pathLatencyCounts[route]++
288302
}
289303

290304
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -740,12 +754,27 @@ func (s *Server) snapshotMetrics() metricsResponse {
740754
byMethod[method] = count
741755
}
742756

757+
byPathLatency := map[string]float64{}
758+
for path, count := range s.pathLatencyCounts {
759+
if count == 0 {
760+
continue
761+
}
762+
byPathLatency[path] = float64(s.pathLatencyNs[path]) / float64(count) / float64(time.Millisecond)
763+
}
764+
765+
avgLatencyMs := 0.0
766+
if s.totalCount > 0 {
767+
avgLatencyMs = float64(s.totalLatencyNs) / float64(s.totalCount) / float64(time.Millisecond)
768+
}
769+
743770
return metricsResponse{
744771
TotalRequests: s.totalCount,
745772
ErrorRequests: s.errorCount,
746773
ByStatus: byStatus,
747774
ByPath: byPath,
748775
ByMethod: byMethod,
776+
ByPathLatency: byPathLatency,
777+
AvgLatencyMs: avgLatencyMs,
749778
StartedAt: s.startedAt.Format(time.RFC3339),
750779
UptimeSeconds: time.Since(s.startedAt).Seconds(),
751780
}

internal/server/server_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,12 @@ func TestMetricsEndpoint(t *testing.T) {
887887
if got.ByPath["/_not_found"] != 1 {
888888
t.Fatalf("expected /_not_found to be tracked once, got %d", got.ByPath["/_not_found"])
889889
}
890+
if got.AvgLatencyMs <= 0 {
891+
t.Fatalf("expected positive average latency, got %f", got.AvgLatencyMs)
892+
}
893+
if got.ByPathLatency["/health"] <= 0 {
894+
t.Fatalf("expected health average latency to be tracked, got %f", got.ByPathLatency["/health"])
895+
}
890896
if _, err := time.Parse(time.RFC3339, got.StartedAt); err != nil {
891897
t.Fatalf("expected started_at to be RFC3339, got %q", got.StartedAt)
892898
}

0 commit comments

Comments
 (0)