From d2527a33e9717f93e439f8896032dd45cfca73df Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 4 Jun 2026 17:20:38 +0000 Subject: [PATCH 01/61] fix(graphrag): bound anomaly-store memory blowup + GOMEMLIMIT safety net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 15-min SQLite soak at 120 services drove RSS to ~1.8 GB and climbing. Heap profiling (gc=1) attributed 84% of the live heap to AnomalyStore PRECEDED_BY edges: the 10s detector minted a NEW anomaly node every tick per erroring service (UnixNano-suffixed ID), and correlateWithRecent then created O(N^2) edges among them — unbounded until the 24h TTL. - fix: stable per-(service,type) anomaly IDs so detection UPSERTS one evolving node instead of one-per-tick; this bounds both the node map and the edge mesh (AnomalyStore 272 MB -> 2.6 MB; peak RSS 1.8 GB -> 292 MB, now flat over the full 15 min). + regression test. - feat: applyMemoryLimit() sets a soft GOMEMLIMIT at startup — honors an explicit env value, else 75% of the detected cgroup/host budget — so the GC paces against a ceiling instead of letting next_gc run away. Defense in depth; cgroup v2/v1 + /proc/meminfo detection, stdlib-only. + tests. Validation: 3x 15-min soaks + heap profile; integrity ok, 0 drops/429s, 0 ERROR/panic, clean shutdown, goroutines/fds recover, 30k spans/120 svcs. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/graphrag/anomaly.go | 11 ++- internal/graphrag/anomaly_dedup_test.go | 42 +++++++++++ main.go | 5 ++ memlimit.go | 94 +++++++++++++++++++++++++ memlimit_test.go | 67 ++++++++++++++++++ 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 internal/graphrag/anomaly_dedup_test.go create mode 100644 memlimit.go create mode 100644 memlimit_test.go diff --git a/internal/graphrag/anomaly.go b/internal/graphrag/anomaly.go index 0d84cb7..ade6109 100644 --- a/internal/graphrag/anomaly.go +++ b/internal/graphrag/anomaly.go @@ -28,7 +28,12 @@ func (g *GraphRAG) detectAnomaliesForTenant(ctx context.Context, tenant string, baselineErrorRate := 0.02 // reasonable baseline if svc.ErrorRate > baselineErrorRate*2 && svc.ErrorRate > 0.05 { anomaly := AnomalyNode{ - ID: fmt.Sprintf("anom_%s_err_%d", svc.Name, now.UnixNano()), + // Stable ID per (service, type): each detection tick UPSERTS the + // same evolving anomaly node rather than minting a new one. With a + // UnixNano suffix an ongoing spike created a fresh node every 10s + // (and correlateWithRecent then minted O(N²) PRECEDED_BY edges), + // which grew the AnomalyStore to ~1.3 GB over a 15-min soak. + ID: fmt.Sprintf("anom_%s_err", svc.Name), Type: AnomalyErrorSpike, Severity: classifyErrorSeverity(svc.ErrorRate), Service: svc.Name, @@ -49,7 +54,7 @@ func (g *GraphRAG) detectAnomaliesForTenant(ctx context.Context, tenant string, // Latency degradation: p99-like check using avg * 3 as proxy if svc.AvgLatency > 500 && svc.CallCount > 10 { anomaly := AnomalyNode{ - ID: fmt.Sprintf("anom_%s_lat_%d", svc.Name, now.UnixNano()), + ID: fmt.Sprintf("anom_%s_lat", svc.Name), // stable per (service,type); see error-spike note Type: AnomalyLatencySpike, Severity: classifyLatencySeverity(svc.AvgLatency), Service: svc.Name, @@ -79,7 +84,7 @@ func (g *GraphRAG) detectAnomaliesForTenant(ctx context.Context, tenant string, deviation := (m.RollingAvg - (m.RollingMin + rangeSize/2)) / (rangeSize / 2) if deviation > 3.0 || deviation < -3.0 { anomaly := AnomalyNode{ - ID: fmt.Sprintf("anom_%s_metric_%d", m.Service, now.UnixNano()), + ID: fmt.Sprintf("anom_%s_metric_%s", m.Service, m.MetricName), // stable per (service,metric) Type: AnomalyMetricZScore, Severity: SeverityWarning, Service: m.Service, diff --git a/internal/graphrag/anomaly_dedup_test.go b/internal/graphrag/anomaly_dedup_test.go new file mode 100644 index 0000000..e007e45 --- /dev/null +++ b/internal/graphrag/anomaly_dedup_test.go @@ -0,0 +1,42 @@ +package graphrag + +import ( + "testing" + "time" +) + +// TestAnomalyDedupBoundsStore proves the root-cause fix for the soak finding: +// stable per-(service,type) anomaly IDs make repeated detection ticks UPSERT a +// single evolving node instead of minting a new one each tick. With the old +// UnixNano-suffixed IDs, 100 ticks over 2 services produced 200 nodes and an +// O(N²) PRECEDED_BY edge explosion (which grew AnomalyStore to ~1.3 GB in a +// 15-min soak). Under the fix, node and edge counts stay bounded regardless of +// how many ticks fire. +func TestAnomalyDedupBoundsStore(t *testing.T) { + stores := newTenantStores(time.Hour) + base := time.Unix(1_700_000_000, 0) + + const ticks = 100 + for i := range ticks { + ts := base.Add(time.Duration(i) * 10 * time.Second) + for _, svc := range []string{"checkout", "payments"} { + a := AnomalyNode{ + ID: "anom_" + svc + "_err", // stable ID, mirrors detectAnomaliesForTenant + Type: AnomalyErrorSpike, + Service: svc, + Timestamp: ts, + } + stores.anomalies.AddAnomaly(a) + correlateWithRecent(stores, a) + } + } + + if got := len(stores.anomalies.Anomalies); got != 2 { + t.Fatalf("expected 2 deduped anomaly nodes after %d ticks, got %d", ticks, got) + } + // TRIGGERED_BY: 2 (one per node). PRECEDED_BY: at most the 2-node mesh. + // The point is it does NOT scale with tick count. + if got := len(stores.anomalies.Edges); got > 8 { + t.Fatalf("edge count must stay bounded under dedup, got %d after %d ticks", got, ticks) + } +} diff --git a/main.go b/main.go index bd1747c..9a03b06 100644 --- a/main.go +++ b/main.go @@ -177,6 +177,11 @@ func main() { slog.Info("🚀 Starting OtelContext", "version", Version, "env", cfg.Env, "log_level", level) + // Pace the GC against a soft memory ceiling so RSS stays bounded under + // sustained ingest (honors an explicit GOMEMLIMIT; otherwise 75% of the + // detected cgroup/host budget). See applyMemoryLimit in memlimit.go. + applyMemoryLimit(75) + // 1. Initialize Internal Telemetry (first — everything registers metrics against this) metrics := telemetry.New() slog.Info("📊 Internal telemetry initialized") diff --git a/memlimit.go b/memlimit.go new file mode 100644 index 0000000..a6be394 --- /dev/null +++ b/memlimit.go @@ -0,0 +1,94 @@ +package main + +import ( + "log/slog" + "os" + "runtime/debug" + "strconv" + "strings" +) + +// applyMemoryLimit sets a soft heap ceiling (GOMEMLIMIT) so the garbage collector +// paces against a bound instead of letting the next-GC target run away on a large +// heap. Without it, a deployment under sustained ingest lets RSS climb to the +// (uncapped) GC target and never returns it to the OS — observed in the SQLite +// soak as ~1.8 GB RSS held flat after load stopped. A soft limit makes the GC run +// more frequently as the heap approaches the ceiling, keeping the working set and +// RSS bounded without the hard-OOM behavior of a rlimit. +// +// If the operator already set the GOMEMLIMIT env var, the Go runtime has applied +// it at startup and we leave it untouched. Otherwise the budget is detected from +// (in order) cgroup v2, cgroup v1, then /proc/meminfo, and the limit is set to +// pct percent of that budget. Any detection failure leaves the runtime default +// (no limit) in place — never worse than today. +func applyMemoryLimit(pct int) { + if _, ok := os.LookupEnv("GOMEMLIMIT"); ok { + slog.Info("🧠 GOMEMLIMIT set by operator; honoring it", + "soft_limit_bytes", debug.SetMemoryLimit(-1)) + return + } + budget, source := detectMemoryBudget() + if budget <= 0 { + slog.Warn("🧠 could not detect memory budget; GOMEMLIMIT left unset (GC target unbounded)") + return + } + limit := budget / 100 * int64(pct) + debug.SetMemoryLimit(limit) + slog.Info("🧠 soft memory limit applied (GOMEMLIMIT)", + "limit_mib", limit/(1<<20), "budget_mib", budget/(1<<20), "pct", pct, "source", source) +} + +// detectMemoryBudget returns the applicable memory budget in bytes and its source, +// or (0, "") if nothing usable is found. cgroup limits take precedence over host +// RAM so containerized deployments pace against their actual quota. +func detectMemoryBudget() (int64, string) { + if v, ok := readCgroupBytes("/sys/fs/cgroup/memory.max"); ok { // cgroup v2 + return v, "cgroup.v2" + } + if v, ok := readCgroupBytes("/sys/fs/cgroup/memory/memory.limit_in_bytes"); ok { // cgroup v1 + return v, "cgroup.v1" + } + if v, ok := readMemTotal("/proc/meminfo"); ok { + return v, "meminfo.MemTotal" + } + return 0, "" +} + +// readCgroupBytes reads a cgroup memory-limit file. Returns (bytes, true) only for +// a concrete limit; "max", empty, non-numeric, or a near-max "unlimited" sentinel +// (>= 1 PiB, used by cgroup v1) returns (0, false) so the caller falls through. +func readCgroupBytes(path string) (int64, bool) { + b, err := os.ReadFile(path) //nolint:gosec // G304: fixed cgroup sysfs path, not user input + if err != nil { + return 0, false + } + s := strings.TrimSpace(string(b)) + if s == "" || s == "max" { + return 0, false + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil || v <= 0 || v >= (1<<50) { + return 0, false + } + return v, true +} + +// readMemTotal parses MemTotal (in kB) from a /proc/meminfo-formatted file and +// returns it in bytes. +func readMemTotal(path string) (int64, bool) { + b, err := os.ReadFile(path) //nolint:gosec // G304: fixed /proc/meminfo path, not user input + if err != nil { + return 0, false + } + for line := range strings.SplitSeq(string(b), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + f := strings.Fields(line) + if len(f) >= 2 { + if kb, err := strconv.ParseInt(f[1], 10, 64); err == nil && kb > 0 { + return kb * 1024, true + } + } + } + } + return 0, false +} diff --git a/memlimit_test.go b/memlimit_test.go new file mode 100644 index 0000000..13592d3 --- /dev/null +++ b/memlimit_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadCgroupBytes(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + content string + want int64 + wantOK bool + }{ + {"concrete limit", "2147483648\n", 2147483648, true}, + {"unlimited max", "max\n", 0, false}, + {"empty", "", 0, false}, + {"non-numeric", "garbage", 0, false}, + {"zero", "0", 0, false}, + {"v1 near-max sentinel", "9223372036854771712", 0, false}, // ~8 EiB "unlimited" + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := filepath.Join(dir, tc.name) + if err := os.WriteFile(p, []byte(tc.content), 0o600); err != nil { + t.Fatal(err) + } + got, ok := readCgroupBytes(p) + if got != tc.want || ok != tc.wantOK { + t.Fatalf("readCgroupBytes(%q)=(%d,%v) want (%d,%v)", tc.content, got, ok, tc.want, tc.wantOK) + } + }) + } + if _, ok := readCgroupBytes(filepath.Join(dir, "does-not-exist")); ok { + t.Fatal("missing file should return ok=false") + } +} + +func TestReadMemTotal(t *testing.T) { + dir := t.TempDir() + meminfo := "MemFree: 1000 kB\nMemTotal: 16384000 kB\nBuffers: 500 kB\n" + p := filepath.Join(dir, "meminfo") + if err := os.WriteFile(p, []byte(meminfo), 0o600); err != nil { + t.Fatal(err) + } + got, ok := readMemTotal(p) + if !ok || got != 16384000*1024 { + t.Fatalf("readMemTotal=(%d,%v) want (%d,true)", got, ok, int64(16384000)*1024) + } + if _, ok := readMemTotal(filepath.Join(dir, "nope")); ok { + t.Fatal("missing meminfo should return ok=false") + } +} + +func TestDetectMemoryBudget(t *testing.T) { + // On the Linux test host at least one source (meminfo) must resolve to a + // positive budget; the function must never return a negative value. + b, src := detectMemoryBudget() + if b < 0 { + t.Fatalf("budget must not be negative, got %d", b) + } + if b > 0 && src == "" { + t.Fatal("positive budget must carry a source label") + } +} From 2d02b19a7dfcb071f9e68ad9df989d99a03daee0 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 4 Jun 2026 17:20:25 +0000 Subject: [PATCH 02/61] fix: security, CI-unblock, and reliability quick-wins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security(api): close cross-tenant read caused by middleware ordering — TenantMiddleware now passes through when auth already pinned a tenant (HasTenantContext), so a per-tenant key can't be escaped via X-Tenant-ID - fix(ingest): correct token-bucket sampler math; the old cost (1/rate) exceeded the cap for rate<1.0 so ~100% of healthy spans were dropped (SQLite default 0.05 persisted almost no baseline traces) - fix(api): clamp limit/offset on /api/logs and /api/traces (negative limit was passed to GORM as unlimited — heap/DB DoS) - fix(ingest): sanitize X-Tenant-ID on the HTTP OTLP path (gRPC parity) - fix(mcp): don't cache error tool results; enforce the response byte cap in resourceResult (trace_graph DB fallback was uncapped) - fix(ui): correct ServiceSidePanel test for split design-system markup, mount ErrorBoundary, derive connected badge from ws.status - chore: bump go directive to 1.25.11 to unblock the OSV-Scanner CI gate Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/log_handlers.go | 14 +- internal/api/log_handlers_cap_test.go | 39 ++++++ internal/api/middleware_test.go | 35 +++++ internal/api/server.go | 42 ++++++ internal/api/tenant_middleware.go | 9 +- internal/api/trace_handlers.go | 14 +- internal/ingest/otlp_http.go | 9 +- internal/ingest/sampler.go | 61 ++++++--- internal/ingest/sampler_test.go | 184 ++++++++++++++++++++++++++ internal/mcp/response_cap_test.go | 82 ++++++++++++ internal/mcp/server.go | 4 +- internal/mcp/tools.go | 6 + 12 files changed, 453 insertions(+), 46 deletions(-) create mode 100644 internal/ingest/sampler_test.go diff --git a/internal/api/log_handlers.go b/internal/api/log_handlers.go index 2cbc9d5..47fd997 100644 --- a/internal/api/log_handlers.go +++ b/internal/api/log_handlers.go @@ -15,19 +15,7 @@ import ( // handleGetLogs handles GET /api/logs with advanced filtering func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { - limit := 50 - offset := 0 - - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil { - limit = v - } - } - if o := r.URL.Query().Get("offset"); o != "" { - if v, err := strconv.Atoi(o); err == nil { - offset = v - } - } + limit, offset := parsePaging(r, pagingDefaultLimit) filter := storage.LogFilter{ ServiceName: r.URL.Query().Get("service_name"), diff --git a/internal/api/log_handlers_cap_test.go b/internal/api/log_handlers_cap_test.go index 7a14401..63689a6 100644 --- a/internal/api/log_handlers_cap_test.go +++ b/internal/api/log_handlers_cap_test.go @@ -55,6 +55,45 @@ func TestHandleGetLogs_NoSearchSkipsCap(t *testing.T) { } } +// TestParsePaging_Clamp verifies that parsePaging enforces bounds on limit and +// offset, preventing GORM from receiving a negative Limit (treated as unlimited) +// or a negative offset. +func TestParsePaging_Clamp(t *testing.T) { + cases := []struct { + query string + defaultLimit int + wantLimit int + wantOffset int + }{ + // Over-limit capped at 1000. + {"limit=9999&offset=0", 50, 1000, 0}, + // Negative limit floored at 1. + {"limit=-5&offset=0", 50, 1, 0}, + // Negative offset floored at 0. + {"limit=10&offset=-99", 50, 10, 0}, + // Both negative. + {"limit=-1&offset=-1", 50, 1, 0}, + // Default limit also clamped. + {"", 9999, 1000, 0}, + // Valid values pass through unchanged. + {"limit=100&offset=200", 50, 100, 200}, + // Exact cap boundary. + {"limit=1000&offset=0", 50, 1000, 0}, + } + for _, tc := range cases { + t.Run(tc.query, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/logs?"+tc.query, nil) + gotLimit, gotOffset := parsePaging(req, tc.defaultLimit) + if gotLimit != tc.wantLimit { + t.Errorf("limit: got %d, want %d", gotLimit, tc.wantLimit) + } + if gotOffset != tc.wantOffset { + t.Errorf("offset: got %d, want %d", gotOffset, tc.wantOffset) + } + }) + } +} + // newAPITestRepoWithoutFTS builds a fresh in-memory repo with FTS5 disabled. // Used by cap tests since they only care about handler behavior, not the // search backend. diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 511d263..4204970 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -133,6 +133,41 @@ func TestTenantMiddleware_MissingHeaderUsesDefault(t *testing.T) { } } +// TestTenantMiddleware_DoesNotOverwritePinnedTenant verifies that when +// TenantKeyAuth.Middleware has already pinned a tenant onto the context (the +// per-tenant API-key path), the subsequent TenantMiddleware pass-through does +// NOT overwrite it with the client-supplied X-Tenant-ID header. +// +// This is the regression test for the middleware-ordering bypass: +// +// TenantKeyAuth.Middleware(auth "alpha-key" → pins "alpha") +// → TenantMiddleware(reads X-Tenant-ID: "beta" → must NOT overwrite) +// → handler (must see "alpha") +func TestTenantMiddleware_DoesNotOverwritePinnedTenant(t *testing.T) { + // Build per-tenant key auth: key "alpha-key" → tenant "alpha". + auth := NewTenantKeyAuth(map[string]string{"alpha-key": "alpha"}) + + cfg := &config.Config{DefaultTenant: "default"} + tc := &tenantCapture{} + + // Compose: TenantKeyAuth wraps TenantMiddleware wraps handler. + h := auth.Middleware("/mcp", TenantMiddleware(cfg)(tc.handler())) + + req := httptest.NewRequest(http.MethodGet, "/api/logs", nil) + req.Header.Set("Authorization", "Bearer alpha-key") + req.Header.Set(TenantHeader, "beta") // attacker's cross-tenant attempt + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%q)", rec.Code, rec.Body.String()) + } + if tc.got != "alpha" { + t.Errorf("TenantMiddleware overwrote pinned tenant: got %q, want %q", tc.got, "alpha") + } +} + // Non-/api/* paths must pass through without tenant resolution — and so should // report the default (no ctx value). func TestTenantMiddleware_NonAPIPath_Passthrough(t *testing.T) { diff --git a/internal/api/server.go b/internal/api/server.go index 1f838d0..df4f793 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strconv" "time" "github.com/RandomCodeSpace/otelcontext/internal/cache" @@ -107,6 +108,47 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/ws/events", s.eventHub.HandleWebSocket) } +const ( + pagingDefaultLimit = 50 + pagingMaxLimit = 1000 +) + +// parsePaging reads "limit" and "offset" from the request query string and +// applies safety clamping so GORM never sees a negative or unbounded Limit. +// +// - limit: floored at 1, capped at pagingMaxLimit (1000). When absent the +// caller-supplied defaultLimit is used (also clamped). +// - offset: floored at 0. When absent defaults to 0. +func parsePaging(r *http.Request, defaultLimit int) (limit, offset int) { + limit = defaultLimit + if limit < 1 { + limit = 1 + } + if limit > pagingMaxLimit { + limit = pagingMaxLimit + } + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { + limit = v + } + } + if limit < 1 { + limit = 1 + } + if limit > pagingMaxLimit { + limit = pagingMaxLimit + } + if o := r.URL.Query().Get("offset"); o != "" { + if v, err := strconv.Atoi(o); err == nil { + offset = v + } + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + // parseTimeRange parses start and end times from request query parameters func parseTimeRange(r *http.Request) (time.Time, time.Time, error) { var start, end time.Time diff --git a/internal/api/tenant_middleware.go b/internal/api/tenant_middleware.go index dd25c39..cf73e90 100644 --- a/internal/api/tenant_middleware.go +++ b/internal/api/tenant_middleware.go @@ -8,7 +8,6 @@ import ( "github.com/RandomCodeSpace/otelcontext/internal/storage" ) - // TenantHeader is the canonical HTTP header carrying the tenant ID on // read-side (query) requests. Ingest paths resolve tenant separately via gRPC // metadata / OTLP resource attributes and do not go through this middleware. @@ -35,6 +34,14 @@ func TenantMiddleware(cfg *config.Config) func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } + // If an upstream layer (e.g. TenantKeyAuth.Middleware) has already + // pinned a tenant onto the context, do not overwrite it — that would + // allow a client to escape their key-bound tenant by supplying a + // different X-Tenant-ID header. + if storage.HasTenantContext(r.Context()) { + next.ServeHTTP(w, r) + return + } // SanitizeTenantID returns "" for empty / over-length / control-char // values so they fall through to the configured default — see // storage.SanitizeTenantID. Hostile or misconfigured clients cannot diff --git a/internal/api/trace_handlers.go b/internal/api/trace_handlers.go index 0e12ca7..c7fd1d5 100644 --- a/internal/api/trace_handlers.go +++ b/internal/api/trace_handlers.go @@ -5,25 +5,13 @@ import ( "fmt" "log/slog" "net/http" - "strconv" "github.com/RandomCodeSpace/otelcontext/internal/api/views" ) // handleGetTraces handles GET /api/traces func (s *Server) handleGetTraces(w http.ResponseWriter, r *http.Request) { - limit := 20 - offset := 0 - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil { - limit = v - } - } - if o := r.URL.Query().Get("offset"); o != "" { - if v, err := strconv.Atoi(o); err == nil { - offset = v - } - } + limit, offset := parsePaging(r, 20) start, end, err := parseTimeRange(r) if err != nil { diff --git a/internal/ingest/otlp_http.go b/internal/ingest/otlp_http.go index d9c6fce..32640c0 100644 --- a/internal/ingest/otlp_http.go +++ b/internal/ingest/otlp_http.go @@ -37,9 +37,16 @@ const headerContentType = "Content-Type" //nolint:goconst // single literal; Son // to the request context before delegating to the gRPC Export methods. // Uses the shared storage.WithTenantContext helper so ingest and read paths // agree on the context key. +// +// The raw header value is run through storage.SanitizeTenantID — the same +// sanitizer applied on the gRPC metadata path in tenantFromContext — so +// control characters, oversized strings, and empty values are rejected +// identically regardless of transport. func withTenantFromHTTP(r *http.Request) context.Context { if v := r.Header.Get("X-Tenant-ID"); v != "" { - return storage.WithTenantContext(r.Context(), v) + if sanitized := storage.SanitizeTenantID(v); sanitized != "" { + return storage.WithTenantContext(r.Context(), sanitized) + } } return r.Context() } diff --git a/internal/ingest/sampler.go b/internal/ingest/sampler.go index 38d63e8..6b9e0ed 100644 --- a/internal/ingest/sampler.go +++ b/internal/ingest/sampler.go @@ -17,6 +17,7 @@ type Sampler struct { buckets map[string]*tokenBucket totalSeen atomic.Int64 totalDropped atomic.Int64 + now func() time.Time // injectable for tests; defaults to time.Now } // NewSampler creates a Sampler with the given parameters. @@ -32,6 +33,7 @@ func NewSampler(rate float64, alwaysOnErrors bool, latencyThresholdMs float64) * alwaysOnErrors: alwaysOnErrors, latencyThresholdMs: latencyThresholdMs, buckets: make(map[string]*tokenBucket), + now: time.Now, } } @@ -67,13 +69,13 @@ func (s *Sampler) ShouldSample(serviceName string, isError bool, durationMs floa s.mu.Lock() b, ok := s.buckets[serviceName] if !ok { - b = newTokenBucket(s.rate) + b = newTokenBucket(s.rate, s.now) s.buckets[serviceName] = b // Always let first trace through (new service discovery). s.mu.Unlock() return true } - allow := b.allow() + allow := b.allow(s.now()) s.mu.Unlock() if !allow { @@ -87,35 +89,60 @@ func (s *Sampler) Stats() (int64, int64) { return s.totalSeen.Load(), s.totalDropped.Load() } -// tokenBucket is a simple token bucket for sampling decisions. -// Refills at `rate` tokens per second, max capacity 1.0. +// tokenBucket throttles healthy-span ingestion per service. It is a RATE +// LIMITER, not a percentage sampler: it admits at most ~`rate` healthy spans +// per second per service (refill `rate` tokens/s, cost 1 token/span) with a +// startup burst of up to capacity = max(1, 1/rate) spans. +// +// The long-run keep FRACTION is therefore min(1, rate / offered_rate): a +// service emitting ≤ `rate` healthy spans/s keeps all of them; above that it +// keeps a shrinking fraction while the absolute admitted rate stays near +// `rate`/s. So rate=0.05 means "~0.05 healthy spans/s/service" (≈ one per 20s), +// NOT "5% of healthy spans". Errors and slow spans bypass this entirely in +// ShouldSample, so there is no data loss for the signals that matter; the +// absolute cap is what bounds the SQLite write rate under load spikes. If a +// true proportional keep-fraction is ever wanted, switch to probabilistic +// (hash-mod / rand) sampling — that is a separate design decision. +// +// (The previous implementation capped tokens at 1.0 but charged 1.0/rate per +// request; for any rate < 1.0 the charge exceeded the cap, so no healthy span +// could ever be admitted — the SQLite default rate of 0.05 dropped ~100%.) type tokenBucket struct { - rate float64 // tokens per second - tokens float64 // current tokens (0.0–1.0) - lastTick time.Time + rate float64 // refill tokens/sec == max sustained admitted healthy spans/sec + capacity float64 // max accumulated tokens (burst ceiling) + tokens float64 // current token count + lastTick time.Time // wall-clock of last refill } -func newTokenBucket(rate float64) *tokenBucket { +func newTokenBucket(rate float64, now func() time.Time) *tokenBucket { + // capacity = 1/rate tokens: one "admission interval" of burst so a freshly + // seen service is not throttled immediately. Floored at 1.0 so a span can + // always eventually be admitted. + capacity := 1.0 / rate + if capacity < 1.0 { + capacity = 1.0 + } return &tokenBucket{ rate: rate, - tokens: rate, // start with one full refill worth - lastTick: time.Now(), + capacity: capacity, + tokens: capacity, // start full so new services are not immediately throttled + lastTick: now(), } } -// allow returns true and consumes a token if one is available. -func (b *tokenBucket) allow() bool { - now := time.Now() +// allow refills the bucket for elapsed time and admits the request if +// at least 1 token is available, consuming exactly 1 on admission. +func (b *tokenBucket) allow(now time.Time) bool { elapsed := now.Sub(b.lastTick).Seconds() b.lastTick = now b.tokens += elapsed * b.rate - if b.tokens > 1.0 { - b.tokens = 1.0 + if b.tokens > b.capacity { + b.tokens = b.capacity } - if b.tokens >= 1.0/b.rate { - b.tokens -= 1.0 / b.rate + if b.tokens >= 1.0 { + b.tokens -= 1.0 return true } return false diff --git a/internal/ingest/sampler_test.go b/internal/ingest/sampler_test.go new file mode 100644 index 0000000..cdb8970 --- /dev/null +++ b/internal/ingest/sampler_test.go @@ -0,0 +1,184 @@ +package ingest + +import ( + "fmt" + "testing" + "time" +) + +// advanceClock returns a now() func that starts at t0 and advances by step on +// each call. This lets tests drive token-bucket time without time.Sleep. +func advanceClock(t0 time.Time, step time.Duration) func() time.Time { + cur := t0 + return func() time.Time { + t := cur + cur = cur.Add(step) + return t + } +} + +// TestSamplerKeepFraction verifies that the healthy-span probabilistic path +// keeps approximately `rate` fraction of calls over a large sample. The test +// is fully deterministic — virtual time advances in fixed steps so there is no +// reliance on wall-clock timing or sleep. +// +// Clock design: each tick advances virtual time by tickNs nanoseconds (a fine +// granularity). After the first call (new-service free-admit), the token bucket +// receives `rate * tickNs/1e9` tokens per call. Over N calls the total tokens +// available ≈ N * rate * tickNs/1e9, and each admission costs 1 token, giving +// a long-run keep fraction ≈ rate * tickNs/1e9. We therefore need: +// +// tickNs = 1e9 (1 second per virtual call) +// +// so keep fraction → rate. However, with 1s ticks and rates near 1.0 the +// capacity cap wastes tokens (1.8→1.111 for rate=0.9). Using a very large +// virtual time window (100 000 calls at 1s each = ~28 virtual hours) and a +// wider tolerance lets the law-of-large-numbers smooth the discrete rounding, +// while still clearly distinguishing "correct implementation" (~rate) from +// "broken implementation" (~0% or ~100%). +func TestSamplerKeepFraction(t *testing.T) { + t.Parallel() + + tests := []struct { + rate float64 + wantFrac float64 + lo float64 // inclusive lower bound + hi float64 // inclusive upper bound + }{ + // Wide but meaningful bounds: broken code gives ~0% (capped bucket) or + // ~100% (bypass bug). Correct code must land in these windows. + {rate: 0.05, wantFrac: 0.05, lo: 0.03, hi: 0.09}, + {rate: 0.50, wantFrac: 0.50, lo: 0.40, hi: 0.60}, + {rate: 0.90, wantFrac: 0.90, lo: 0.60, hi: 1.00}, + } + + // 1 virtual second per call so refill per tick == rate tokens exactly. + const tickNs = int64(time.Second) + const calls = 100_000 + + for _, tc := range tests { + t.Run(fmt.Sprintf("rate=%.2f", tc.rate), func(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + nowFn := advanceClock(t0, time.Duration(tickNs)) + + s := &Sampler{ + rate: tc.rate, + alwaysOnErrors: false, + latencyThresholdMs: 1e9, // far above test spans so the slow-span bypass never fires + buckets: make(map[string]*tokenBucket), + now: nowFn, + } + + kept := 0 + for i := 0; i < calls; i++ { + if s.ShouldSample("svc", false, 0) { + kept++ + } + } + + got := float64(kept) / float64(calls) + if got < tc.lo || got > tc.hi { + t.Errorf("rate=%.2f: keep fraction %.4f outside [%.4f, %.4f] (%d/%d kept)", + tc.rate, got, tc.lo, tc.hi, kept, calls) + } + }) + } +} + +// TestSamplerRateOne verifies rate==1.0 keeps all healthy spans. +func TestSamplerRateOne(t *testing.T) { + t.Parallel() + // latencyThresholdMs=999999 keeps slow-span bypass out of the picture. + s := NewSampler(1.0, false, 999999) + for i := 0; i < 1000; i++ { + if !s.ShouldSample("svc", false, 0) { + t.Fatal("rate=1.0 must keep every span") + } + } +} + +// TestSamplerRateZero verifies rate==0.0 drops all healthy spans. +func TestSamplerRateZero(t *testing.T) { + t.Parallel() + // latencyThresholdMs=999999 so 0ms spans are not treated as "slow"; we want + // the pure zero-rate drop path. + s := NewSampler(0.0, false, 999999) + for i := 0; i < 1000; i++ { + if s.ShouldSample("svc", false, 0) { + t.Fatal("rate=0.0 must drop every healthy span") + } + } +} + +// TestSamplerAlwaysOnErrors verifies that error spans bypass the bucket +// regardless of rate. +func TestSamplerAlwaysOnErrors(t *testing.T) { + t.Parallel() + // rate=0 would drop everything healthy; errors must still pass. + s := NewSampler(0.0, true /* alwaysOnErrors */, 0) + for i := 0; i < 1000; i++ { + if !s.ShouldSample("svc", true /* isError */, 0) { + t.Fatal("error span must always be kept when alwaysOnErrors=true") + } + } +} + +// TestSamplerSlowSpanBypass verifies that slow spans bypass the bucket. +func TestSamplerSlowSpanBypass(t *testing.T) { + t.Parallel() + // rate=0, latencyThreshold=500ms — a 600ms span must be kept. + s := NewSampler(0.0, false, 500) + for i := 0; i < 100; i++ { + if !s.ShouldSample("svc", false, 600) { + t.Fatal("slow span (600ms > 500ms threshold) must always be kept") + } + } +} + +// TestSamplerNewServiceDiscovery verifies the first call for a new service is +// always admitted (service discovery guarantee). +func TestSamplerNewServiceDiscovery(t *testing.T) { + t.Parallel() + // rate=0.001 — almost nothing would pass, but a brand-new service must. + // latencyThresholdMs=999999 keeps the slow-span bypass out of the picture. + s := NewSampler(0.001, false, 999999) + if !s.ShouldSample("brand-new-svc", false, 0) { + t.Fatal("first call for a new service must always be admitted") + } +} + +// TestSamplerStats verifies that the seen/dropped counters are consistent. +func TestSamplerStats(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + // Use a slow clock (1s per tick) so rate=0.5 alternates roughly keep/drop. + nowFn := advanceClock(t0, time.Second) + + s := &Sampler{ + rate: 0.5, + alwaysOnErrors: false, + latencyThresholdMs: -1, // disabled + buckets: make(map[string]*tokenBucket), + now: nowFn, + } + + const calls = 100 + for i := 0; i < calls; i++ { + s.ShouldSample("svc", false, 0) + } + + seen, dropped := s.Stats() + // First call is free (new-service discovery), so seen == calls, dropped < calls. + if seen != calls { + t.Errorf("seen=%d want %d", seen, calls) + } + if dropped >= calls { + t.Errorf("dropped=%d must be < seen=%d", dropped, calls) + } + if dropped < 0 { + t.Errorf("dropped=%d must not be negative", dropped) + } +} diff --git a/internal/mcp/response_cap_test.go b/internal/mcp/response_cap_test.go index 57a4162..2c89a27 100644 --- a/internal/mcp/response_cap_test.go +++ b/internal/mcp/response_cap_test.go @@ -3,6 +3,7 @@ package mcp import ( "strings" "testing" + "time" ) // TestTextResult_ResponseCap verifies the byte-cap defense for tool @@ -52,3 +53,84 @@ func TestTextResult_ResponseCap(t *testing.T) { } }) } + +// TestResourceResult_ResponseCap verifies that resourceResult applies the same +// MaxToolResponseBytes cap as textResult — the trace_graph DB fallback can +// return an unbounded GetTrace payload without this guard. +func TestResourceResult_ResponseCap(t *testing.T) { + t.Parallel() + + t.Run("UnderCapPassesThrough", func(t *testing.T) { + text := strings.Repeat("x", 1024) + got := resourceResult("OtelContext://test", "application/json", text) + if got.IsError { + t.Fatalf("expected success, got IsError=true: %+v", got) + } + if len(got.Content) != 1 || got.Content[0].Resource == nil { + t.Fatalf("expected resource content item: %+v", got) + } + if got.Content[0].Resource.Text != text { + t.Fatalf("payload mangled") + } + }) + + t.Run("AtCapPassesThrough", func(t *testing.T) { + text := strings.Repeat("x", MaxToolResponseBytes) + got := resourceResult("OtelContext://test", "application/json", text) + if got.IsError { + t.Fatalf("expected at-cap to pass, got IsError=true") + } + }) + + t.Run("OverCapErrors", func(t *testing.T) { + text := strings.Repeat("x", MaxToolResponseBytes+1) + got := resourceResult("OtelContext://test", "application/json", text) + if !got.IsError { + t.Fatalf("expected over-cap to error, got success") + } + if len(got.Content) == 0 || !strings.Contains(got.Content[0].Text, "response too large") { + t.Fatalf("expected 'response too large' in error: %+v", got) + } + if !strings.Contains(got.Content[0].Text, "narrow time range") { + t.Fatalf("expected actionable hint in error: %+v", got) + } + }) +} + +// TestCacheErrorSkip_GuardLogic verifies the guard logic that server.go uses +// to decide whether to call cache.Set. The guard is: +// +// if !toolResult.IsError { s.cache.Set(...) } +// +// This test exercises that decision directly: an error result (IsError=true) +// must NOT be passed to Set; a successful result must be passed and then +// retrievable. +func TestCacheErrorSkip_GuardLogic(t *testing.T) { + t.Parallel() + + c := newResultCache(5*time.Second, 64) + + // Simulate the server-side guard: only Set when !IsError. + maybeCache := func(res ToolCallResult) { + if !res.IsError { + c.Set("default", "get_service_map", nil, res) + } + } + + // Error result: guard must prevent the Set. + maybeCache(errorResult("GraphRAG not initialized")) + _, hit := c.Get("default", "get_service_map", nil) + if hit { + t.Fatal("error result must not be stored in the cache") + } + + // Successful result: guard must allow the Set. + maybeCache(textResult(`{"ok":true}`)) + got, hit := c.Get("default", "get_service_map", nil) + if !hit { + t.Fatal("successful result must be stored in the cache") + } + if got.IsError { + t.Fatalf("cached result should not be an error: %+v", got) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 5da7532..27f005b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -332,7 +332,9 @@ func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) { break } s.callsServiced.Add(1) - s.cache.Set(tenant, params.Name, params.Arguments, toolResult) + if !toolResult.IsError { + s.cache.Set(tenant, params.Name, params.Arguments, toolResult) + } result = toolResult case "ping": diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 6896aeb..6e399b5 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -387,6 +387,12 @@ func textResult(text string) ToolCallResult { } func resourceResult(uri, mimeType, text string) ToolCallResult { + if len(text) > MaxToolResponseBytes { + return errorResult(fmt.Sprintf( + "response too large: %d bytes exceeds %d-byte cap; narrow time range or use pagination", + len(text), MaxToolResponseBytes, + )) + } return ToolCallResult{ Content: []ContentItem{ {Type: "resource", Resource: &Resource{URI: uri, MimeType: mimeType, Text: text}}, From d35986dc0490f69a2d49434bea08d965595d56c9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 15:44:28 +0000 Subject: [PATCH 03/61] feat(observability): localhost pprof endpoint on dedicated listener PPROF_ADDR (default 127.0.0.1:6060, empty disables) serves net/http/pprof from its own listener so profiling never reaches the public :8080 mux. Heap attribution is the prerequisite for proving every memory fix in the OOM-survival series (b1983f8 was only diagnosable via heap profiles). Co-Authored-By: Claude Fable 5 --- .env.example | 1 + internal/config/config.go | 5 +++++ main.go | 9 ++++++++ pprof.go | 35 +++++++++++++++++++++++++++++ pprof_test.go | 47 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 pprof.go create mode 100644 pprof_test.go diff --git a/.env.example b/.env.example index c893ff0..f6bb338 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ # LOG_LEVEL=INFO # DEBUG|INFO|WARN|ERROR # HTTP_PORT=8080 # HTTP API + OTLP HTTP + WebSocket + UI # GRPC_PORT=4317 # OTLP gRPC +# PPROF_ADDR=127.0.0.1:6060 # net/http/pprof on a dedicated loopback listener; empty disables # ---- Database --------------------------------------------------------------- # DB_DRIVER=sqlite # sqlite|postgres|mysql|sqlserver diff --git a/internal/config/config.go b/internal/config/config.go index 8145ee6..0d85c9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,10 @@ type Config struct { DLQPath string DLQReplayInterval string + // PprofAddr serves net/http/pprof on a dedicated listener (never the + // public mux). Loopback-only by default; empty disables profiling. + PprofAddr string + // Ingestion Filtering IngestMinSeverity string IngestAllowedServices string @@ -235,6 +239,7 @@ func Load(customPath string) (*Config, error) { DBDSN: getEnv("DB_DSN", ""), DLQPath: getEnv("DLQ_PATH", "./data/dlq"), DLQReplayInterval: getEnv("DLQ_REPLAY_INTERVAL", "5m"), + PprofAddr: getEnv("PPROF_ADDR", "127.0.0.1:6060"), IngestMinSeverity: getEnv("INGEST_MIN_SEVERITY", "INFO"), StoreMinSeverity: getEnv("STORE_MIN_SEVERITY", ""), diff --git a/main.go b/main.go index 9a03b06..7da576c 100644 --- a/main.go +++ b/main.go @@ -182,6 +182,12 @@ func main() { // detected cgroup/host budget). See applyMemoryLimit in memlimit.go. applyMemoryLimit(75) + pprofSrv, _, err := startPprofServer(cfg.PprofAddr, logger) + if err != nil { + slog.Error("Failed to start pprof server", "error", err, "addr", cfg.PprofAddr) + os.Exit(1) + } + // 1. Initialize Internal Telemetry (first — everything registers metrics against this) metrics := telemetry.New() slog.Info("📊 Internal telemetry initialized") @@ -871,6 +877,9 @@ func main() { if err := srv.Shutdown(ctx); err != nil { slog.Error("HTTP server forced shutdown", "error", err) } + if pprofSrv != nil { + _ = pprofSrv.Close() + } // 2. Stop real-time hubs and event processing hub.Stop() diff --git a/pprof.go b/pprof.go new file mode 100644 index 0000000..a8bc394 --- /dev/null +++ b/pprof.go @@ -0,0 +1,35 @@ +package main + +import ( + "errors" + "log/slog" + "net" + "net/http" + _ "net/http/pprof" // registers profiling handlers on http.DefaultServeMux + "time" +) + +// startPprofServer serves net/http/pprof on its own listener so profiling +// never leaks onto the public :8080 mux (which is a fresh NewServeMux). +// Empty addr disables it. Bind to loopback in production; heap profiles +// expose internals. +func startPprofServer(addr string, log *slog.Logger) (*http.Server, net.Addr, error) { + if addr == "" { + return nil, nil, nil + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, nil, err + } + srv := &http.Server{ + Handler: http.DefaultServeMux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Warn("pprof server exited", "error", err) + } + }() + log.Info("pprof profiling enabled", "addr", ln.Addr().String()) + return srv, ln.Addr(), nil +} diff --git a/pprof_test.go b/pprof_test.go new file mode 100644 index 0000000..9e9ab2f --- /dev/null +++ b/pprof_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "testing" + "time" +) + +func TestStartPprofServerDisabledOnEmptyAddr(t *testing.T) { + srv, addr, err := startPprofServer("", slog.Default()) + if err != nil || srv != nil || addr != nil { + t.Fatalf("empty addr must disable pprof: srv=%v addr=%v err=%v", srv, addr, err) + } +} + +func TestStartPprofServerServesHeapProfile(t *testing.T) { + srv, addr, err := startPprofServer("127.0.0.1:0", slog.Default()) + if err != nil { + t.Fatalf("start: %v", err) + } + defer srv.Close() + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://%s/debug/pprof/heap?debug=1", addr)) + if err != nil { + t.Fatalf("get heap profile: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d body=%.200s", resp.StatusCode, body) + } + if len(body) == 0 { + t.Fatal("empty heap profile body") + } +} + +func TestStartPprofServerInvalidAddr(t *testing.T) { + srv, _, err := startPprofServer("definitely-not-an-addr", slog.Default()) + if err == nil { + srv.Close() + t.Fatal("expected listen error for invalid addr") + } +} From 36375b4790d4bd355f976195e4726752baeec37f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 15:49:10 +0000 Subject: [PATCH 04/61] feat(observability): in-memory store census gauges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otelcontext_graphrag_store_entities{entity}/_edges{store}, otelcontext_tsdb_ring_series_active, otelcontext_drain_templates_active — sampled every 15s via len()-under-RLock census so operators can attribute RSS growth to a specific store without a heap profile. Co-Authored-By: Claude Fable 5 --- internal/graphrag/drain.go | 7 +++ internal/graphrag/store_counts.go | 62 +++++++++++++++++++++ internal/graphrag/store_counts_test.go | 74 ++++++++++++++++++++++++++ internal/telemetry/metrics.go | 27 ++++++++++ main.go | 22 ++++++++ 5 files changed, 192 insertions(+) create mode 100644 internal/graphrag/store_counts.go create mode 100644 internal/graphrag/store_counts_test.go diff --git a/internal/graphrag/drain.go b/internal/graphrag/drain.go index 90ad1ac..d4f3f2f 100644 --- a/internal/graphrag/drain.go +++ b/internal/graphrag/drain.go @@ -213,6 +213,13 @@ func NewDrain(opts ...DrainOption) *Drain { return d } +// TemplateCount reports the number of live templates under the read lock. +func (d *Drain) TemplateCount() int { + d.mu.RLock() + defer d.mu.RUnlock() + return len(d.templates) +} + // Match preprocesses the log line, descends the prefix tree, and either // merges into an existing template or creates a new one. Returns the // resulting template. Returns nil only for empty input. diff --git a/internal/graphrag/store_counts.go b/internal/graphrag/store_counts.go new file mode 100644 index 0000000..30067c8 --- /dev/null +++ b/internal/graphrag/store_counts.go @@ -0,0 +1,62 @@ +package graphrag + +// StoreCounts is a point-in-time census of every long-lived in-memory +// structure the coordinator owns, aggregated across tenants. It feeds the +// otelcontext_graphrag_* gauges so operators can attribute RSS growth to a +// specific store before reaching for a heap profile. +type StoreCounts struct { + Tenants int + Services int + Operations int + Traces int + Spans int + LogClusters int + Metrics int + Anomalies int + ServiceEdges int + TraceEdges int + SignalEdges int + AnomalyEdges int +} + +// StoreCounts walks a tenant snapshot and takes len() under each store's +// read lock — no slice building, cheap enough for a periodic sampler. +func (g *GraphRAG) StoreCounts() StoreCounts { + var c StoreCounts + for _, st := range g.snapshotTenants() { + c.Tenants++ + + st.service.mu.RLock() + c.Services += len(st.service.Services) + c.Operations += len(st.service.Operations) + c.ServiceEdges += len(st.service.Edges) + st.service.mu.RUnlock() + + st.traces.mu.RLock() + c.Traces += len(st.traces.Traces) + c.Spans += len(st.traces.Spans) + c.TraceEdges += len(st.traces.Edges) + st.traces.mu.RUnlock() + + st.signals.mu.RLock() + c.LogClusters += len(st.signals.LogClusters) + c.Metrics += len(st.signals.Metrics) + c.SignalEdges += len(st.signals.Edges) + st.signals.mu.RUnlock() + + st.anomalies.mu.RLock() + c.Anomalies += len(st.anomalies.Anomalies) + c.AnomalyEdges += len(st.anomalies.Edges) + st.anomalies.mu.RUnlock() + } + return c +} + +// DrainTemplateCount reports the number of live Drain templates (bounded by +// maxTemplates, but the gauge proves it in production). +func (g *GraphRAG) DrainTemplateCount() int { + if g.drain == nil { + return 0 + } + return g.drain.TemplateCount() +} diff --git a/internal/graphrag/store_counts_test.go b/internal/graphrag/store_counts_test.go new file mode 100644 index 0000000..b65e2a9 --- /dev/null +++ b/internal/graphrag/store_counts_test.go @@ -0,0 +1,74 @@ +package graphrag + +import ( + "testing" + "time" +) + +func TestStoreCountsAggregatesAcrossTenants(t *testing.T) { + g := newTestGraphRAG(t) + now := time.Now() + + a := g.storesForTenant("tenant-a") + a.service.UpsertService("svc-1", 10, false, now) + a.service.UpsertService("svc-2", 20, true, now) + a.service.UpsertOperation("svc-1", "GET /x", 10, false, now) + a.service.UpsertCallEdge("svc-1", "svc-2", 10, false, now) + a.traces.UpsertTrace("trace-1", "svc-1", "OK", 12, now) + a.traces.UpsertSpan(SpanNode{ID: "span-1", TraceID: "trace-1", Service: "svc-1", Timestamp: now}) + + b := g.storesForTenant("tenant-b") + b.signals.UpsertLogCluster("cl-1", "tmpl", "ERROR", "svc-3", now) + b.signals.UpsertMetric("cpu", "svc-3", 0.5, now) + b.anomalies.AddAnomaly(AnomalyNode{ID: "anom-1", Service: "svc-3", Timestamp: now}) + b.anomalies.AddAnomaly(AnomalyNode{ID: "anom-2", Service: "svc-3", Timestamp: now}) + b.anomalies.AddPrecededByEdge("anom-2", "anom-1", now) + + c := g.StoreCounts() + + if c.Tenants < 2 { + t.Fatalf("Tenants = %d, want >= 2", c.Tenants) + } + if c.Services != 2 { + t.Fatalf("Services = %d, want 2", c.Services) + } + if c.Operations != 1 { + t.Fatalf("Operations = %d, want 1", c.Operations) + } + if c.Spans != 1 { + t.Fatalf("Spans = %d, want 1", c.Spans) + } + if c.LogClusters != 1 { + t.Fatalf("LogClusters = %d, want 1", c.LogClusters) + } + if c.Metrics != 1 { + t.Fatalf("Metrics = %d, want 1", c.Metrics) + } + if c.Anomalies != 2 { + t.Fatalf("Anomalies = %d, want 2", c.Anomalies) + } + // UpsertCallEdge creates a CALLS edge; UpsertOperation an EXPOSES edge. + if c.ServiceEdges != 2 { + t.Fatalf("ServiceEdges = %d, want 2", c.ServiceEdges) + } + // Each AddAnomaly creates a TRIGGERED_BY edge, plus one PRECEDED_BY edge. + if c.AnomalyEdges != 3 { + t.Fatalf("AnomalyEdges = %d, want 3", c.AnomalyEdges) + } + // Metric upsert creates a MEASURED_BY edge; log cluster upsert an EMITTED_BY edge. + if c.SignalEdges != 2 { + t.Fatalf("SignalEdges = %d, want 2", c.SignalEdges) + } +} + +func TestDrainTemplateCount(t *testing.T) { + d := NewDrain() + if got := d.TemplateCount(); got != 0 { + t.Fatalf("empty miner TemplateCount = %d, want 0", got) + } + d.Match("user 42 logged in", time.Now()) + d.Match("user 43 logged in", time.Now()) + if got := d.TemplateCount(); got != 1 { + t.Fatalf("TemplateCount = %d, want 1 (same template)", got) + } +} diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go index 44f0afc..872ed05 100644 --- a/internal/telemetry/metrics.go +++ b/internal/telemetry/metrics.go @@ -98,6 +98,17 @@ type Metrics struct { // --- GraphRAG overflow --- GraphRAGEventsDroppedTotal *prometheus.CounterVec + // --- In-memory store census (OOM-survival work) --- + // GraphRAGStoreEntities — live node counts per entity kind across tenants + // (tenants|services|operations|traces|spans|log_clusters|metrics|anomalies). + // GraphRAGStoreEdges — live edge counts per store (service|trace|signal|anomaly). + // Together with the ring/drain gauges these attribute RSS growth to a + // specific structure before a heap profile is needed. + GraphRAGStoreEntities *prometheus.GaugeVec + GraphRAGStoreEdges *prometheus.GaugeVec + TSDBRingSeriesActive prometheus.Gauge + DrainTemplatesActive prometheus.Gauge + // --- Async ingest pipeline (Phase 1 robustness work) --- // IngestPipelineQueueDepth — current queue depth, sampled on every Submit. // Labeled by signal so spikes can be attributed to traces vs logs. @@ -323,6 +334,22 @@ func New() *Metrics { Name: "otelcontext_graphrag_events_dropped_total", Help: "Events dropped because the GraphRAG event channel was full.", }, []string{"signal"}), + GraphRAGStoreEntities: promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "otelcontext_graphrag_store_entities", + Help: "Live GraphRAG node counts across tenants, by entity kind (tenants|services|operations|traces|spans|log_clusters|metrics|anomalies).", + }, []string{"entity"}), + GraphRAGStoreEdges: promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "otelcontext_graphrag_store_edges", + Help: "Live GraphRAG edge counts across tenants, by store (service|trace|signal|anomaly).", + }, []string{"store"}), + TSDBRingSeriesActive: promauto.NewGauge(prometheus.GaugeOpts{ + Name: "otelcontext_tsdb_ring_series_active", + Help: "Distinct metric series currently held in TSDB ring buffers.", + }), + DrainTemplatesActive: promauto.NewGauge(prometheus.GaugeOpts{ + Name: "otelcontext_drain_templates_active", + Help: "Live Drain log templates (bounded by the 50k LRU cap).", + }), IngestPipelineQueueDepth: promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "otelcontext_ingest_pipeline_queue_depth", diff --git a/main.go b/main.go index 7da576c..ee58982 100644 --- a/main.go +++ b/main.go @@ -797,12 +797,34 @@ func main() { defer bootWG.Done() tick := time.NewTicker(1 * time.Second) defer tick.Stop() + // Store census is len()-under-RLock per tenant — cheap, but 15s is + // plenty for trend attribution of RSS growth. + census := time.NewTicker(15 * time.Second) + defer census.Stop() for { select { case <-appCtx.Done(): return case <-tick.C: metrics.GraphRAGEventBufferDepth.Set(float64(graphRAG.EventBufferDepth())) + case <-census.C: + c := graphRAG.StoreCounts() + ent := metrics.GraphRAGStoreEntities + ent.WithLabelValues("tenants").Set(float64(c.Tenants)) + ent.WithLabelValues("services").Set(float64(c.Services)) + ent.WithLabelValues("operations").Set(float64(c.Operations)) + ent.WithLabelValues("traces").Set(float64(c.Traces)) + ent.WithLabelValues("spans").Set(float64(c.Spans)) + ent.WithLabelValues("log_clusters").Set(float64(c.LogClusters)) + ent.WithLabelValues("metrics").Set(float64(c.Metrics)) + ent.WithLabelValues("anomalies").Set(float64(c.Anomalies)) + edg := metrics.GraphRAGStoreEdges + edg.WithLabelValues("service").Set(float64(c.ServiceEdges)) + edg.WithLabelValues("trace").Set(float64(c.TraceEdges)) + edg.WithLabelValues("signal").Set(float64(c.SignalEdges)) + edg.WithLabelValues("anomaly").Set(float64(c.AnomalyEdges)) + metrics.TSDBRingSeriesActive.Set(float64(ringBuf.MetricCount())) + metrics.DrainTemplatesActive.Set(float64(graphRAG.DrainTemplateCount())) } } }() From fc58b625ab99e880323b76811ed8ea04a9f28874 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 15:50:17 +0000 Subject: [PATCH 05/61] perf(config): drop GraphRAG event queue SQLite default 100k -> 10k ~100 MB of standing buffer at the Postgres default; on SQLite the single writer starves the workers anyway, so buffer less and let the metered drop path engage sooner. Co-Authored-By: Claude Fable 5 --- internal/config/config.go | 5 +++++ internal/config/driver_defaults_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 0d85c9b..a36aa25 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -368,6 +368,11 @@ var sqliteOverrides = []struct { {"SAMPLING_RATE", func(c *Config) { c.SamplingRate = 0.05 }}, {"GRPC_MAX_CONCURRENT_STREAMS", func(c *Config) { c.GRPCMaxConcurrentStreams = 240 }}, {"LOG_FTS_ENABLED", func(c *Config) { c.LogFTSEnabled = true }}, + // Each queued event embeds a storage.Span/Log by value (~0.5–2 KB); the + // 100k Postgres default is ~100 MB+ of standing buffer. On SQLite the + // single writer starves the workers anyway — drop sooner (metered via + // otelcontext_graphrag_events_dropped_total) instead of buffering RAM. + {"GRAPHRAG_EVENT_QUEUE_SIZE", func(c *Config) { c.GraphRAGEventQueueSize = 10000 }}, } func applyDriverDefaults(cfg *Config) { diff --git a/internal/config/driver_defaults_test.go b/internal/config/driver_defaults_test.go index 896267f..fddaa61 100644 --- a/internal/config/driver_defaults_test.go +++ b/internal/config/driver_defaults_test.go @@ -18,6 +18,7 @@ var sqliteEnvKeys = []string{ "SAMPLING_RATE", "GRPC_MAX_CONCURRENT_STREAMS", "LOG_FTS_ENABLED", + "GRAPHRAG_EVENT_QUEUE_SIZE", } // clearSQLiteEnv unsets every env var consulted by applyDriverDefaults so @@ -54,6 +55,7 @@ func postgresDefaultsConfig(driver string) *Config { StoreMinSeverity: "", // same-as-ingest default SamplingRate: 1.0, // keep-all default GRPCMaxConcurrentStreams: 1000, // Postgres default + GraphRAGEventQueueSize: 100000, // Postgres default LogFTSEnabled: false, // FTS5 opt-in default } } @@ -80,6 +82,7 @@ func TestApplyDriverDefaults_SQLite_FlipsAllWhenNoEnv(t *testing.T) { {"SamplingRate", cfg.SamplingRate, 0.05}, {"GRPCMaxConcurrentStreams", cfg.GRPCMaxConcurrentStreams, 240}, {"LogFTSEnabled", cfg.LogFTSEnabled, true}, + {"GraphRAGEventQueueSize", cfg.GraphRAGEventQueueSize, 10000}, } for _, c := range cases { if c.got != c.want { From 8907413602d63ba43bc4ec621e9cf0a6f20038bb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:02:18 +0000 Subject: [PATCH 06/61] feat(httpconst): shared Accept-Encoding and ETag negotiation helpers Both the embedded-UI server (precompressed asset negotiation, index.html 304s) and the API layer (gzip middleware, cached-payload ETags) need the same two pure helpers; centralising them in httpconst avoids duplicating header-parsing logic across packages. Co-Authored-By: Claude Fable 5 --- internal/httpconst/negotiate.go | 48 +++++++++++++++++++++ internal/httpconst/negotiate_test.go | 64 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/httpconst/negotiate.go create mode 100644 internal/httpconst/negotiate_test.go diff --git a/internal/httpconst/negotiate.go b/internal/httpconst/negotiate.go new file mode 100644 index 0000000..e42f078 --- /dev/null +++ b/internal/httpconst/negotiate.go @@ -0,0 +1,48 @@ +package httpconst + +import ( + "net/http" + "strconv" + "strings" +) + +// AcceptsEncoding reports whether the request's Accept-Encoding header lists +// the given encoding with a non-zero q-value. Minimal token parse — the "*" +// wildcard is deliberately not honoured (browsers and collectors always name +// the encodings they support), so callers never have to guess a preference +// order for it. +func AcceptsEncoding(r *http.Request, enc string) bool { + for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { + token, attr, hasAttr := strings.Cut(strings.TrimSpace(part), ";") + if !strings.EqualFold(strings.TrimSpace(token), enc) { + continue + } + if hasAttr { + attr = strings.TrimSpace(attr) + if qv, ok := strings.CutPrefix(attr, "q="); ok { + if q, err := strconv.ParseFloat(strings.TrimSpace(qv), 64); err == nil && q == 0 { + return false + } + } + } + return true + } + return false +} + +// ETagMatch reports whether an If-None-Match header value matches the given +// entity tag. Handles the "*" wildcard, comma-separated candidate lists, and +// weak validators (W/ prefix is ignored — weak comparison is correct for +// If-None-Match per RFC 9110 §13.1.2). +func ETagMatch(header, etag string) bool { + if header == "" { + return false + } + for _, candidate := range strings.Split(header, ",") { + candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "W/") + if candidate == "*" || candidate == etag { + return true + } + } + return false +} diff --git a/internal/httpconst/negotiate_test.go b/internal/httpconst/negotiate_test.go new file mode 100644 index 0000000..3e7c1c2 --- /dev/null +++ b/internal/httpconst/negotiate_test.go @@ -0,0 +1,64 @@ +package httpconst + +import ( + "net/http/httptest" + "testing" +) + +func TestAcceptsEncoding(t *testing.T) { + tests := []struct { + name string + header string + enc string + want bool + }{ + {"empty header", "", "gzip", false}, + {"plain match", "gzip", "gzip", true}, + {"case insensitive", "GZip", "gzip", true}, + {"list match", "deflate, gzip, br", "gzip", true}, + {"list match br", "gzip, br", "br", true}, + {"no match", "deflate", "gzip", false}, + {"substring is not a token", "xgzipx", "gzip", false}, + {"q value accepted", "gzip;q=0.5", "gzip", true}, + {"q zero rejected", "gzip;q=0", "gzip", false}, + {"q zero decimal rejected", "gzip;q=0.000", "gzip", false}, + {"q zero in list", "br;q=0, gzip", "br", false}, + {"whitespace tolerated", " gzip ; q=1.0 , br", "gzip", true}, + {"wildcard not honoured", "*", "gzip", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + if tt.header != "" { + r.Header.Set("Accept-Encoding", tt.header) + } + if got := AcceptsEncoding(r, tt.enc); got != tt.want { + t.Errorf("AcceptsEncoding(%q, %q) = %v, want %v", tt.header, tt.enc, got, tt.want) + } + }) + } +} + +func TestETagMatch(t *testing.T) { + const etag = `"abc123"` + tests := []struct { + name string + header string + want bool + }{ + {"empty header", "", false}, + {"exact match", `"abc123"`, true}, + {"no match", `"zzz"`, false}, + {"wildcard", "*", true}, + {"list match", `"zzz", "abc123"`, true}, + {"weak validator match", `W/"abc123"`, true}, + {"unquoted does not match", "abc123", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ETagMatch(tt.header, etag); got != tt.want { + t.Errorf("ETagMatch(%q, %q) = %v, want %v", tt.header, etag, got, tt.want) + } + }) + } +} From 3a079ebaa78daafe880de60c2a9742d19ec0ef0d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:02:53 +0000 Subject: [PATCH 07/61] refactor(membudget): extract memory-budget detection into internal/membudget Move detectMemoryBudget/readCgroupBytes/readMemTotal (cgroup v2 -> v1 -> /proc/meminfo) out of the root-package memlimit.go into a reusable internal/membudget package so internal/storage can budget-scale the SQLite PRAGMA stanza without importing package main. applyMemoryLimit stays as a thin wrapper with unchanged behavior; detection tests moved across verbatim and the wrapper gained its own tests (operator GOMEMLIMIT honored, budget fraction applied). Co-Authored-By: Claude Fable 5 --- internal/membudget/membudget.go | 67 ++++++++++++++++++++++ internal/membudget/membudget_test.go | 67 ++++++++++++++++++++++ memlimit.go | 69 +++-------------------- memlimit_test.go | 84 ++++++++++------------------ 4 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 internal/membudget/membudget.go create mode 100644 internal/membudget/membudget_test.go diff --git a/internal/membudget/membudget.go b/internal/membudget/membudget.go new file mode 100644 index 0000000..e0ee356 --- /dev/null +++ b/internal/membudget/membudget.go @@ -0,0 +1,67 @@ +// Package membudget detects the memory budget available to the process — +// (in order) cgroup v2, cgroup v1, then host RAM from /proc/meminfo — so +// consumers can scale their allocations against the actual quota instead of +// hardcoding sizes. Used by the GOMEMLIMIT bootstrap in package main and the +// SQLite cache/mmap sizing in internal/storage. +package membudget + +import ( + "os" + "strconv" + "strings" +) + +// Detect returns the applicable memory budget in bytes and its source, +// or (0, "") if nothing usable is found. cgroup limits take precedence over +// host RAM so containerized deployments pace against their actual quota. +func Detect() (int64, string) { + if v, ok := readCgroupBytes("/sys/fs/cgroup/memory.max"); ok { // cgroup v2 + return v, "cgroup.v2" + } + if v, ok := readCgroupBytes("/sys/fs/cgroup/memory/memory.limit_in_bytes"); ok { // cgroup v1 + return v, "cgroup.v1" + } + if v, ok := readMemTotal("/proc/meminfo"); ok { + return v, "meminfo.MemTotal" + } + return 0, "" +} + +// readCgroupBytes reads a cgroup memory-limit file. Returns (bytes, true) only for +// a concrete limit; "max", empty, non-numeric, or a near-max "unlimited" sentinel +// (>= 1 PiB, used by cgroup v1) returns (0, false) so the caller falls through. +func readCgroupBytes(path string) (int64, bool) { + b, err := os.ReadFile(path) //nolint:gosec // G304: fixed cgroup sysfs path, not user input + if err != nil { + return 0, false + } + s := strings.TrimSpace(string(b)) + if s == "" || s == "max" { + return 0, false + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil || v <= 0 || v >= (1<<50) { + return 0, false + } + return v, true +} + +// readMemTotal parses MemTotal (in kB) from a /proc/meminfo-formatted file and +// returns it in bytes. +func readMemTotal(path string) (int64, bool) { + b, err := os.ReadFile(path) //nolint:gosec // G304: fixed /proc/meminfo path, not user input + if err != nil { + return 0, false + } + for line := range strings.SplitSeq(string(b), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + f := strings.Fields(line) + if len(f) >= 2 { + if kb, err := strconv.ParseInt(f[1], 10, 64); err == nil && kb > 0 { + return kb * 1024, true + } + } + } + } + return 0, false +} diff --git a/internal/membudget/membudget_test.go b/internal/membudget/membudget_test.go new file mode 100644 index 0000000..b46fad1 --- /dev/null +++ b/internal/membudget/membudget_test.go @@ -0,0 +1,67 @@ +package membudget + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadCgroupBytes(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + content string + want int64 + wantOK bool + }{ + {"concrete limit", "2147483648\n", 2147483648, true}, + {"unlimited max", "max\n", 0, false}, + {"empty", "", 0, false}, + {"non-numeric", "garbage", 0, false}, + {"zero", "0", 0, false}, + {"v1 near-max sentinel", "9223372036854771712", 0, false}, // ~8 EiB "unlimited" + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := filepath.Join(dir, tc.name) + if err := os.WriteFile(p, []byte(tc.content), 0o600); err != nil { + t.Fatal(err) + } + got, ok := readCgroupBytes(p) + if got != tc.want || ok != tc.wantOK { + t.Fatalf("readCgroupBytes(%q)=(%d,%v) want (%d,%v)", tc.content, got, ok, tc.want, tc.wantOK) + } + }) + } + if _, ok := readCgroupBytes(filepath.Join(dir, "does-not-exist")); ok { + t.Fatal("missing file should return ok=false") + } +} + +func TestReadMemTotal(t *testing.T) { + dir := t.TempDir() + meminfo := "MemFree: 1000 kB\nMemTotal: 16384000 kB\nBuffers: 500 kB\n" + p := filepath.Join(dir, "meminfo") + if err := os.WriteFile(p, []byte(meminfo), 0o600); err != nil { + t.Fatal(err) + } + got, ok := readMemTotal(p) + if !ok || got != 16384000*1024 { + t.Fatalf("readMemTotal=(%d,%v) want (%d,true)", got, ok, int64(16384000)*1024) + } + if _, ok := readMemTotal(filepath.Join(dir, "nope")); ok { + t.Fatal("missing meminfo should return ok=false") + } +} + +func TestDetect(t *testing.T) { + // On the Linux test host at least one source (meminfo) must resolve to a + // positive budget; the function must never return a negative value. + b, src := Detect() + if b < 0 { + t.Fatalf("budget must not be negative, got %d", b) + } + if b > 0 && src == "" { + t.Fatal("positive budget must carry a source label") + } +} diff --git a/memlimit.go b/memlimit.go index a6be394..e8bf87c 100644 --- a/memlimit.go +++ b/memlimit.go @@ -4,8 +4,8 @@ import ( "log/slog" "os" "runtime/debug" - "strconv" - "strings" + + "github.com/RandomCodeSpace/otelcontext/internal/membudget" ) // applyMemoryLimit sets a soft heap ceiling (GOMEMLIMIT) so the garbage collector @@ -17,17 +17,17 @@ import ( // RSS bounded without the hard-OOM behavior of a rlimit. // // If the operator already set the GOMEMLIMIT env var, the Go runtime has applied -// it at startup and we leave it untouched. Otherwise the budget is detected from -// (in order) cgroup v2, cgroup v1, then /proc/meminfo, and the limit is set to -// pct percent of that budget. Any detection failure leaves the runtime default -// (no limit) in place — never worse than today. +// it at startup and we leave it untouched. Otherwise the budget is detected via +// internal/membudget (cgroup v2 → cgroup v1 → /proc/meminfo) and the limit is +// set to pct percent of that budget. Any detection failure leaves the runtime +// default (no limit) in place — never worse than today. func applyMemoryLimit(pct int) { if _, ok := os.LookupEnv("GOMEMLIMIT"); ok { slog.Info("🧠 GOMEMLIMIT set by operator; honoring it", "soft_limit_bytes", debug.SetMemoryLimit(-1)) return } - budget, source := detectMemoryBudget() + budget, source := membudget.Detect() if budget <= 0 { slog.Warn("🧠 could not detect memory budget; GOMEMLIMIT left unset (GC target unbounded)") return @@ -37,58 +37,3 @@ func applyMemoryLimit(pct int) { slog.Info("🧠 soft memory limit applied (GOMEMLIMIT)", "limit_mib", limit/(1<<20), "budget_mib", budget/(1<<20), "pct", pct, "source", source) } - -// detectMemoryBudget returns the applicable memory budget in bytes and its source, -// or (0, "") if nothing usable is found. cgroup limits take precedence over host -// RAM so containerized deployments pace against their actual quota. -func detectMemoryBudget() (int64, string) { - if v, ok := readCgroupBytes("/sys/fs/cgroup/memory.max"); ok { // cgroup v2 - return v, "cgroup.v2" - } - if v, ok := readCgroupBytes("/sys/fs/cgroup/memory/memory.limit_in_bytes"); ok { // cgroup v1 - return v, "cgroup.v1" - } - if v, ok := readMemTotal("/proc/meminfo"); ok { - return v, "meminfo.MemTotal" - } - return 0, "" -} - -// readCgroupBytes reads a cgroup memory-limit file. Returns (bytes, true) only for -// a concrete limit; "max", empty, non-numeric, or a near-max "unlimited" sentinel -// (>= 1 PiB, used by cgroup v1) returns (0, false) so the caller falls through. -func readCgroupBytes(path string) (int64, bool) { - b, err := os.ReadFile(path) //nolint:gosec // G304: fixed cgroup sysfs path, not user input - if err != nil { - return 0, false - } - s := strings.TrimSpace(string(b)) - if s == "" || s == "max" { - return 0, false - } - v, err := strconv.ParseInt(s, 10, 64) - if err != nil || v <= 0 || v >= (1<<50) { - return 0, false - } - return v, true -} - -// readMemTotal parses MemTotal (in kB) from a /proc/meminfo-formatted file and -// returns it in bytes. -func readMemTotal(path string) (int64, bool) { - b, err := os.ReadFile(path) //nolint:gosec // G304: fixed /proc/meminfo path, not user input - if err != nil { - return 0, false - } - for line := range strings.SplitSeq(string(b), "\n") { - if strings.HasPrefix(line, "MemTotal:") { - f := strings.Fields(line) - if len(f) >= 2 { - if kb, err := strconv.ParseInt(f[1], 10, 64); err == nil && kb > 0 { - return kb * 1024, true - } - } - } - } - return 0, false -} diff --git a/memlimit_test.go b/memlimit_test.go index 13592d3..d0e94b4 100644 --- a/memlimit_test.go +++ b/memlimit_test.go @@ -2,66 +2,44 @@ package main import ( "os" - "path/filepath" + "runtime/debug" "testing" + + "github.com/RandomCodeSpace/otelcontext/internal/membudget" ) -func TestReadCgroupBytes(t *testing.T) { - dir := t.TempDir() - cases := []struct { - name string - content string - want int64 - wantOK bool - }{ - {"concrete limit", "2147483648\n", 2147483648, true}, - {"unlimited max", "max\n", 0, false}, - {"empty", "", 0, false}, - {"non-numeric", "garbage", 0, false}, - {"zero", "0", 0, false}, - {"v1 near-max sentinel", "9223372036854771712", 0, false}, // ~8 EiB "unlimited" - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - p := filepath.Join(dir, tc.name) - if err := os.WriteFile(p, []byte(tc.content), 0o600); err != nil { - t.Fatal(err) - } - got, ok := readCgroupBytes(p) - if got != tc.want || ok != tc.wantOK { - t.Fatalf("readCgroupBytes(%q)=(%d,%v) want (%d,%v)", tc.content, got, ok, tc.want, tc.wantOK) - } - }) - } - if _, ok := readCgroupBytes(filepath.Join(dir, "does-not-exist")); ok { - t.Fatal("missing file should return ok=false") - } -} +// Detection internals (cgroup v2/v1, /proc/meminfo parsing) are tested in +// internal/membudget — these tests cover only the thin wrapper behavior. -func TestReadMemTotal(t *testing.T) { - dir := t.TempDir() - meminfo := "MemFree: 1000 kB\nMemTotal: 16384000 kB\nBuffers: 500 kB\n" - p := filepath.Join(dir, "meminfo") - if err := os.WriteFile(p, []byte(meminfo), 0o600); err != nil { - t.Fatal(err) - } - got, ok := readMemTotal(p) - if !ok || got != 16384000*1024 { - t.Fatalf("readMemTotal=(%d,%v) want (%d,true)", got, ok, int64(16384000)*1024) - } - if _, ok := readMemTotal(filepath.Join(dir, "nope")); ok { - t.Fatal("missing meminfo should return ok=false") +func TestApplyMemoryLimit_HonorsOperatorGOMEMLIMIT(t *testing.T) { + prev := debug.SetMemoryLimit(-1) + defer debug.SetMemoryLimit(prev) + + t.Setenv("GOMEMLIMIT", "1GiB") + applyMemoryLimit(75) + if got := debug.SetMemoryLimit(-1); got != prev { + t.Fatalf("applyMemoryLimit must not touch the limit when GOMEMLIMIT is set; got %d want %d", got, prev) } } -func TestDetectMemoryBudget(t *testing.T) { - // On the Linux test host at least one source (meminfo) must resolve to a - // positive budget; the function must never return a negative value. - b, src := detectMemoryBudget() - if b < 0 { - t.Fatalf("budget must not be negative, got %d", b) +func TestApplyMemoryLimit_SetsBudgetFraction(t *testing.T) { + prev := debug.SetMemoryLimit(-1) + defer debug.SetMemoryLimit(prev) + + // Force the detection path: clear GOMEMLIMIT for this test only. + // t.Setenv registers the restore; Unsetenv makes LookupEnv miss. + t.Setenv("GOMEMLIMIT", "") + if err := os.Unsetenv("GOMEMLIMIT"); err != nil { + t.Fatalf("unset GOMEMLIMIT: %v", err) } - if b > 0 && src == "" { - t.Fatal("positive budget must carry a source label") + + budget, _ := membudget.Detect() + if budget <= 0 { + t.Skip("no detectable memory budget on this host") + } + applyMemoryLimit(75) + want := budget / 100 * 75 + if got := debug.SetMemoryLimit(-1); got != want { + t.Fatalf("applyMemoryLimit(75) set %d, want %d (budget %d)", got, want, budget) } } From 62b63f98573508ebaa9a925922aa2b3044f4e23e Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:03:48 +0000 Subject: [PATCH 08/61] feat(config): GraphRAG memory-bound env knobs with SQLite 30m trace TTL Adds GRAPHRAG_TRACE_TTL (default 1h, SQLite default 30m via sqliteOverrides), GRAPHRAG_MAX_SPANS_PER_TENANT (default 500000) and GRAPHRAG_TENANT_IDLE_TTL (default 24h). The TraceStore span window is the largest GraphRAG heap consumer at 120 services; anomaly and investigation lookbacks are <=5min so the 30min SQLite window is safe. Co-Authored-By: Claude Fable 5 --- internal/config/config.go | 34 +++++++++++- internal/config/driver_defaults_test.go | 3 ++ internal/config/graphrag_bounds_test.go | 72 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 internal/config/graphrag_bounds_test.go diff --git a/internal/config/config.go b/internal/config/config.go index a36aa25..7f37004 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -133,6 +133,28 @@ type Config struct { // GraphRAG event channel buffer size. Defaults to 10000 if unset or <=0. GraphRAGEventQueueSize int + // GraphRAGTraceTTL bounds how long spans/traces stay in the in-memory + // TraceStore before the refresh tick prunes them. Duration string, e.g. + // "1h". Defaults to "1h"; flipped to "30m" on SQLite (the in-memory span + // window is the largest GraphRAG heap consumer at 120 services). Anomaly + // and investigation paths look back <=5min, so a 30min window is safe. + GraphRAGTraceTTL string + + // GraphRAGMaxSpansPerTenant hard-caps the in-memory TraceStore span map + // per tenant. At the cap, NEW spans are skipped (counted via + // otelcontext_graphrag_events_dropped_total{signal="span_capacity"}); + // updates to resident spans still apply. The graph is best-effort — the + // DB remains the source of truth. 0 = default (500000); negative + // disables the cap. + GraphRAGMaxSpansPerTenant int + + // GraphRAGTenantIdleTTL evicts a tenant's entire in-memory store slice + // after this much time without any ingest event or query. Duration + // string, default "24h". The default tenant is never evicted, and an + // active tenant is re-created within one refresh tick (60s) from recent + // DB spans — eviction is self-healing. + GraphRAGTenantIdleTTL string + // Async ingest pipeline (Phase 1 robustness work). Decouples OTLP Export // from synchronous DB writes. When enabled, Export() returns as soon as // the parsed batch is enqueued; persistence runs on a worker pool. @@ -296,8 +318,11 @@ func Load(customPath string) (*Config, error) { LogFTSEnabled: parseTruthy(getEnv("LOG_FTS_ENABLED", "")), // GraphRAG - GraphRAGWorkerCount: getEnvInt("GRAPHRAG_WORKER_COUNT", 16), - GraphRAGEventQueueSize: getEnvInt("GRAPHRAG_EVENT_QUEUE_SIZE", 100000), + GraphRAGWorkerCount: getEnvInt("GRAPHRAG_WORKER_COUNT", 16), + GraphRAGEventQueueSize: getEnvInt("GRAPHRAG_EVENT_QUEUE_SIZE", 100000), + GraphRAGTraceTTL: getEnv("GRAPHRAG_TRACE_TTL", "1h"), + GraphRAGMaxSpansPerTenant: getEnvInt("GRAPHRAG_MAX_SPANS_PER_TENANT", 500000), + GraphRAGTenantIdleTTL: getEnv("GRAPHRAG_TENANT_IDLE_TTL", "24h"), // Async ingest pipeline IngestAsyncEnabled: getEnvBool("INGEST_ASYNC_ENABLED", true), @@ -373,6 +398,11 @@ var sqliteOverrides = []struct { // single writer starves the workers anyway — drop sooner (metered via // otelcontext_graphrag_events_dropped_total) instead of buffering RAM. {"GRAPHRAG_EVENT_QUEUE_SIZE", func(c *Config) { c.GraphRAGEventQueueSize = 10000 }}, + // The TraceStore span window dominates GraphRAG heap at 120 services + // (~1.5 GB potential at 1h). Anomaly/investigation lookbacks are <=5min, + // so halving the window costs nothing they rely on; MCP trace tools fall + // through to the DB for older traces. + {"GRAPHRAG_TRACE_TTL", func(c *Config) { c.GraphRAGTraceTTL = "30m" }}, } func applyDriverDefaults(cfg *Config) { diff --git a/internal/config/driver_defaults_test.go b/internal/config/driver_defaults_test.go index fddaa61..2b60b58 100644 --- a/internal/config/driver_defaults_test.go +++ b/internal/config/driver_defaults_test.go @@ -19,6 +19,7 @@ var sqliteEnvKeys = []string{ "GRPC_MAX_CONCURRENT_STREAMS", "LOG_FTS_ENABLED", "GRAPHRAG_EVENT_QUEUE_SIZE", + "GRAPHRAG_TRACE_TTL", } // clearSQLiteEnv unsets every env var consulted by applyDriverDefaults so @@ -57,6 +58,7 @@ func postgresDefaultsConfig(driver string) *Config { GRPCMaxConcurrentStreams: 1000, // Postgres default GraphRAGEventQueueSize: 100000, // Postgres default LogFTSEnabled: false, // FTS5 opt-in default + GraphRAGTraceTTL: "1h", // Postgres default } } @@ -83,6 +85,7 @@ func TestApplyDriverDefaults_SQLite_FlipsAllWhenNoEnv(t *testing.T) { {"GRPCMaxConcurrentStreams", cfg.GRPCMaxConcurrentStreams, 240}, {"LogFTSEnabled", cfg.LogFTSEnabled, true}, {"GraphRAGEventQueueSize", cfg.GraphRAGEventQueueSize, 10000}, + {"GraphRAGTraceTTL", cfg.GraphRAGTraceTTL, "30m"}, } for _, c := range cases { if c.got != c.want { diff --git a/internal/config/graphrag_bounds_test.go b/internal/config/graphrag_bounds_test.go new file mode 100644 index 0000000..8a58430 --- /dev/null +++ b/internal/config/graphrag_bounds_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "os" + "testing" +) + +// clearGraphRAGBoundsEnv unsets the GraphRAG memory-bound env vars so Load() +// starts from a deterministic "operator set nothing" baseline. Mirrors +// clearSQLiteEnv (driver_defaults_test.go). +func clearGraphRAGBoundsEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "GRAPHRAG_TRACE_TTL", + "GRAPHRAG_MAX_SPANS_PER_TENANT", + "GRAPHRAG_TENANT_IDLE_TTL", + } { + if _, ok := os.LookupEnv(k); ok { + old := os.Getenv(k) + t.Setenv(k, old) // record original for revert + if err := os.Unsetenv(k); err != nil { + t.Fatalf("unset %s: %v", k, err) + } + } + } +} + +// TestLoad_GraphRAGMemoryBounds_Defaults proves the new GraphRAG memory-bound +// knobs land with their documented Postgres-path defaults: 1h trace TTL, +// 500k spans per tenant, 24h tenant idle TTL. +func TestLoad_GraphRAGMemoryBounds_Defaults(t *testing.T) { + clearGraphRAGBoundsEnv(t) + t.Setenv("DB_DRIVER", "postgres") // avoid the SQLite 30m TTL override + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.GraphRAGTraceTTL != "1h" { + t.Errorf("GraphRAGTraceTTL = %q, want \"1h\"", cfg.GraphRAGTraceTTL) + } + if cfg.GraphRAGMaxSpansPerTenant != 500000 { + t.Errorf("GraphRAGMaxSpansPerTenant = %d, want 500000", cfg.GraphRAGMaxSpansPerTenant) + } + if cfg.GraphRAGTenantIdleTTL != "24h" { + t.Errorf("GraphRAGTenantIdleTTL = %q, want \"24h\"", cfg.GraphRAGTenantIdleTTL) + } +} + +// TestLoad_GraphRAGMemoryBounds_EnvOverride proves operator-set values are +// honoured verbatim. +func TestLoad_GraphRAGMemoryBounds_EnvOverride(t *testing.T) { + clearGraphRAGBoundsEnv(t) + t.Setenv("DB_DRIVER", "sqlite") + t.Setenv("GRAPHRAG_TRACE_TTL", "15m") + t.Setenv("GRAPHRAG_MAX_SPANS_PER_TENANT", "1000") + t.Setenv("GRAPHRAG_TENANT_IDLE_TTL", "1h") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.GraphRAGTraceTTL != "15m" { + t.Errorf("GraphRAGTraceTTL = %q, want \"15m\" (SQLite override must not clobber explicit env)", cfg.GraphRAGTraceTTL) + } + if cfg.GraphRAGMaxSpansPerTenant != 1000 { + t.Errorf("GraphRAGMaxSpansPerTenant = %d, want 1000", cfg.GraphRAGMaxSpansPerTenant) + } + if cfg.GraphRAGTenantIdleTTL != "1h" { + t.Errorf("GraphRAGTenantIdleTTL = %q, want \"1h\"", cfg.GraphRAGTenantIdleTTL) + } +} From 8cf04db41a1f0c3a0934965349b463a0032051aa Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:04:49 +0000 Subject: [PATCH 09/61] feat(ui): precompressed asset serving, index ETag/304, SPA fallback Replace the bare http.FileServer over the embedded dist with a purpose- built spaHandler: - /assets/* (Vite hashed filenames): Accept-Encoding negotiation against build-time .br/.gz siblings, Content-Type from the original extension, Cache-Control: public, max-age=31536000, immutable + Vary. - index.html: Cache-Control: no-cache with a startup-computed sha256 ETag; If-None-Match returns 304, precompressed variants served when accepted. - SPA fallback: unknown extensionless paths serve index.html for client- side routing, preserving the old spaFS dot-in-path 404 contract and explicitly refusing machine namespaces (/api, /v1, /ws, /mcp, /metrics). - Source-only checkouts (stub dist) degrade to a descriptive 404. Embed directives unchanged; siblings are emitted by the build-time precompress script (wired separately) and picked up by all:dist. Co-Authored-By: Claude Fable 5 --- internal/ui/ui.go | 218 ++++++++++++++++++++++++++++----- internal/ui/ui_test.go | 272 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 internal/ui/ui_test.go diff --git a/internal/ui/ui.go b/internal/ui/ui.go index fc5850a..3b93b99 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,45 +1,23 @@ package ui import ( + "crypto/sha256" "embed" - "errors" + "encoding/hex" "fmt" "io/fs" + "mime" "net/http" + "path" + "strconv" "strings" "github.com/RandomCodeSpace/otelcontext/internal/graph" + "github.com/RandomCodeSpace/otelcontext/internal/httpconst" "github.com/RandomCodeSpace/otelcontext/internal/storage" "github.com/RandomCodeSpace/otelcontext/internal/telemetry" ) -// spaFS wraps an fs.FS so http.FileServer transparently serves index.html -// for any extensionless path that doesn't resolve to a real file — the -// usual single-page-app routing where the React router owns client-side -// URLs. Asset-shaped paths (anything with a ".") still 404 normally so a -// missing /favicon.ico doesn't surprise the browser with an HTML body. -// -// Wrapping the FS — rather than calling Open() against r.URL.Path in our -// own handler — keeps the user-controlled name behind the stdlib -// http.FileServer boundary, where path.Clean has already happened. -type spaFS struct{ fs.FS } - -func (s spaFS) Open(name string) (fs.File, error) { - f, err := s.FS.Open(name) - if err == nil { - return f, nil - } - if !errors.Is(err, fs.ErrNotExist) { - return nil, err - } - // SPA fallback only for extensionless paths (treated as client-side - // routes); legitimate asset 404s still propagate. - if strings.Contains(name, ".") { - return nil, err - } - return s.FS.Open("index.html") -} - // The built UI is generated at release time (scripts/release.sh) and is not // committed; only internal/ui/dist/.gitkeep is tracked. The `all:` prefix // embeds that dotfile so a source-only checkout still compiles. A plain @@ -82,14 +60,190 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) error { mux.Handle("/static/", http.FileServer(http.FS(content))) // Serve React SPA from dist/ for all non-API paths. API routes are - // registered before this is called, so they take priority. spaFS - // converts extensionless 404s into index.html so the React router - // can claim them. + // registered before this is called, so they take priority; anything + // that falls through lands on the spaHandler below. distFS, err := fs.Sub(content, "dist") if err != nil { return fmt.Errorf("ui: failed to create dist sub-fs: %w", err) } - mux.Handle("/", http.FileServer(http.FS(spaFS{distFS}))) + mux.Handle("/", newSPAHandler(distFS)) return nil } + +// reservedPrefixes are machine namespaces that must 404 — never receive the +// SPA shell — when no explicit mux route matched. Serving HTML to an OTLP +// exporter, a WebSocket client, or an MCP agent that hit an unknown path +// would only confuse it. +var reservedPrefixes = []string{"/api/", "/v1/", "/ws", "/mcp", "/metrics"} + +// spaHandler serves the embedded Vite build: +// +// - "/" and SPA fallback routes → index.html with Cache-Control: no-cache +// plus a startup-computed ETag so steady-state reloads are 304s. +// - "/assets/*" (Vite hashed filenames) → immutable one-year caching with +// Accept-Encoding negotiation against build-time .br/.gz siblings +// (emitted by ui/scripts/precompress.mjs and picked up by `all:dist`). +// - any other real file → plain http.FileServer. +// - unknown extensionless non-reserved paths → index.html (client-side +// routing). Asset-shaped paths (anything with a ".") still 404 normally +// so a missing /favicon.ico doesn't surprise the browser with HTML — +// same contract as the previous spaFS wrapper. +type spaHandler struct { + dist fs.FS + fileServer http.Handler + + // index.html variants, loaded once at startup. indexPlain == nil means + // dist holds only the source-only stub (.gitkeep) — the dev case where + // the UI was never built. + indexPlain []byte + indexGz []byte + indexBr []byte + indexETag string +} + +func newSPAHandler(dist fs.FS) *spaHandler { + h := &spaHandler{ + dist: dist, + fileServer: http.FileServer(http.FS(dist)), + } + if b, err := fs.ReadFile(dist, "index.html"); err == nil { + h.indexPlain = b + sum := sha256.Sum256(b) + h.indexETag = `"` + hex.EncodeToString(sum[:8]) + `"` + if gz, err := fs.ReadFile(dist, "index.html.gz"); err == nil { + h.indexGz = gz + } + if br, err := fs.ReadFile(dist, "index.html.br"); err == nil { + h.indexBr = br + } + } + return h +} + +func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p := path.Clean(r.URL.Path) + switch { + case p == "/" || p == "/index.html": + h.serveIndex(w, r) + case strings.HasPrefix(p, "/assets/"): + h.serveAsset(w, r, strings.TrimPrefix(p, "/")) + default: + h.serveOther(w, r, p) + } +} + +// serveOther handles everything that isn't index.html or a hashed asset: +// real embedded files delegate to the stdlib file server (which handles +// Content-Type, ranges, and path traversal), everything else either 404s or +// falls back to the SPA shell. +func (h *spaHandler) serveOther(w http.ResponseWriter, r *http.Request, p string) { + name := strings.TrimPrefix(p, "/") + if name != "" { + if f, err := h.dist.Open(name); err == nil { + info, statErr := f.Stat() + _ = f.Close() + if statErr == nil && !info.IsDir() { + h.fileServer.ServeHTTP(w, r) + return + } + } + } + if strings.Contains(name, ".") || isReserved(p) { + http.NotFound(w, r) + return + } + h.serveIndex(w, r) +} + +func isReserved(p string) bool { + for _, prefix := range reservedPrefixes { + if strings.HasPrefix(p, prefix) { + return true + } + } + return false +} + +// serveIndex serves the SPA shell. no-cache (not no-store) keeps the bytes +// in the browser cache but forces revalidation, so a redeploy is picked up +// on the next navigation while steady-state loads are If-None-Match → 304. +func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) { + if h.indexPlain == nil { + // Source-only checkout: dist holds just the .gitkeep stub. A plain + // 404 with a pointer beats an empty page or a directory listing. + http.Error(w, "UI not built — release binaries embed the SPA (see scripts/release.sh)", http.StatusNotFound) + return + } + hdr := w.Header() + hdr.Set("Cache-Control", "no-cache") + hdr.Set("ETag", h.indexETag) + hdr.Set("Vary", "Accept-Encoding") + if httpconst.ETagMatch(r.Header.Get("If-None-Match"), h.indexETag) { + w.WriteHeader(http.StatusNotModified) + return + } + body := h.indexPlain + switch { + case h.indexBr != nil && httpconst.AcceptsEncoding(r, "br"): + hdr.Set("Content-Encoding", "br") + body = h.indexBr + case h.indexGz != nil && httpconst.AcceptsEncoding(r, "gzip"): + hdr.Set("Content-Encoding", "gzip") + body = h.indexGz + } + writeBody(w, r, body, "text/html; charset=utf-8") +} + +// serveAsset serves a Vite-emitted hashed asset (/assets/-.). +// The hash makes the content immutable, so the response is cacheable for a +// year; build-time precompressed .br/.gz siblings are negotiated via +// Accept-Encoding with Content-Type taken from the ORIGINAL extension. +func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, name string) { + body, encoding, ok := h.assetVariant(r, name) + if !ok { + http.NotFound(w, r) + return + } + hdr := w.Header() + hdr.Set("Cache-Control", "public, max-age=31536000, immutable") + hdr.Set("Vary", "Accept-Encoding") + if encoding != "" { + hdr.Set("Content-Encoding", encoding) + } + ctype := mime.TypeByExtension(path.Ext(name)) + if ctype == "" { + ctype = "application/octet-stream" + } + writeBody(w, r, body, ctype) +} + +// assetVariant picks the best representation the client accepts: brotli +// sibling first (smallest), then gzip, then the original bytes. +func (h *spaHandler) assetVariant(r *http.Request, name string) (body []byte, encoding string, ok bool) { + if httpconst.AcceptsEncoding(r, "br") { + if b, err := fs.ReadFile(h.dist, name+".br"); err == nil { + return b, "br", true + } + } + if httpconst.AcceptsEncoding(r, "gzip") { + if b, err := fs.ReadFile(h.dist, name+".gz"); err == nil { + return b, "gzip", true + } + } + b, err := fs.ReadFile(h.dist, name) + if err != nil { + return nil, "", false + } + return b, "", true +} + +func writeBody(w http.ResponseWriter, r *http.Request, body []byte, ctype string) { + hdr := w.Header() + hdr.Set(httpconst.HeaderContentType, ctype) + hdr.Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(body) //nolint:gosec // G705 false positive: body is read-only embedded build output (fs.ValidPath rejects traversal), never request data + } +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..516b49f --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,272 @@ +package ui + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +// builtDistFS returns a fake Vite build output: index.html with both +// precompressed siblings, a hashed JS asset with both siblings, a hashed +// CSS asset with no siblings, and a root-level static file. +func builtDistFS() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte("index")}, + "index.html.br": {Data: []byte("br-index")}, + "index.html.gz": {Data: []byte("gz-index")}, + "assets/app-abc123.js": {Data: []byte("console.log('app')")}, + "assets/app-abc123.js.br": {Data: []byte("br-js")}, + "assets/app-abc123.js.gz": {Data: []byte("gz-js")}, + "assets/app-abc123.css": {Data: []byte("body{}")}, + "vite.svg": {Data: []byte("")}, + } +} + +func get(t *testing.T, h http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestServeAsset_BrotliNegotiation(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/app-abc123.js", map[string]string{"Accept-Encoding": "gzip, br"}) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Encoding"); got != "br" { + t.Errorf("Content-Encoding = %q, want br", got) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { + t.Errorf("Cache-Control = %q", got) + } + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q", got) + } + // Content-Type must come from the ORIGINAL .js extension, not .br. + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { + t.Errorf("Content-Type = %q, want javascript", ct) + } + if rec.Body.String() != "br-js" { + t.Errorf("body = %q, want br sibling bytes", rec.Body.String()) + } +} + +func TestServeAsset_GzipFallback(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/app-abc123.js", map[string]string{"Accept-Encoding": "gzip"}) + + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want gzip", got) + } + if rec.Body.String() != "gz-js" { + t.Errorf("body = %q, want gz sibling bytes", rec.Body.String()) + } +} + +func TestServeAsset_BrotliQZeroFallsBackToGzip(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/app-abc123.js", map[string]string{"Accept-Encoding": "br;q=0, gzip"}) + + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want gzip", got) + } +} + +func TestServeAsset_IdentityWhenNoAcceptEncoding(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/app-abc123.js", nil) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want empty", got) + } + if rec.Body.String() != "console.log('app')" { + t.Errorf("body = %q, want original bytes", rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { + t.Errorf("Cache-Control = %q, want immutable", got) + } +} + +func TestServeAsset_NoSiblingServesIdentity(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/app-abc123.css", map[string]string{"Accept-Encoding": "gzip, br"}) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want empty (no siblings exist)", got) + } + if rec.Body.String() != "body{}" { + t.Errorf("body = %q", rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") { + t.Errorf("Content-Type = %q, want text/css", ct) + } +} + +func TestServeAsset_Missing404(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/assets/nope-zzz.js", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } +} + +func TestIndex_ETagAnd304(t *testing.T) { + h := newSPAHandler(builtDistFS()) + + rec := get(t, h, "/", nil) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("Cache-Control = %q, want no-cache", got) + } + etag := rec.Header().Get("ETag") + if etag == "" || !strings.HasPrefix(etag, `"`) { + t.Fatalf("ETag = %q, want quoted non-empty", etag) + } + if rec.Body.String() != "index" { + t.Errorf("body = %q", rec.Body.String()) + } + + rec2 := get(t, h, "/", map[string]string{"If-None-Match": etag}) + if rec2.Code != http.StatusNotModified { + t.Fatalf("want 304, got %d", rec2.Code) + } + if rec2.Body.Len() != 0 { + t.Errorf("304 body should be empty, got %q", rec2.Body.String()) + } + if got := rec2.Header().Get("ETag"); got != etag { + t.Errorf("304 ETag = %q, want %q", got, etag) + } +} + +func TestIndex_PrecompressedVariant(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/", map[string]string{"Accept-Encoding": "br"}) + + if got := rec.Header().Get("Content-Encoding"); got != "br" { + t.Errorf("Content-Encoding = %q, want br", got) + } + if rec.Body.String() != "br-index" { + t.Errorf("body = %q, want br variant", rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } + // ETag must be identical across encodings — it identifies the document, + // and Vary: Accept-Encoding disambiguates the representation. + identity := get(t, h, "/", nil) + if rec.Header().Get("ETag") != identity.Header().Get("ETag") { + t.Errorf("ETag differs between encodings") + } + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q", got) + } +} + +func TestSPAFallback_ServesIndexForClientRoutes(t *testing.T) { + h := newSPAHandler(builtDistFS()) + for _, path := range []string{"/traces", "/logs", "/traces/deep/route"} { + rec := get(t, h, path, nil) + if rec.Code != http.StatusOK { + t.Errorf("%s: want 200, got %d", path, rec.Code) + continue + } + if rec.Body.String() != "index" { + t.Errorf("%s: body = %q, want index", path, rec.Body.String()) + } + // Fallback responses are the mutable SPA shell — same caching rules + // as "/" so a deploy is picked up on the next poll. + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("%s: Cache-Control = %q, want no-cache", path, got) + } + } +} + +func TestSPAFallback_DoesNotClaimReservedOrAssetPaths(t *testing.T) { + h := newSPAHandler(builtDistFS()) + for _, path := range []string{ + "/api/x", // unknown API route must 404, not get HTML + "/v1/unknown", // OTLP namespace + "/ws/nope", // WebSocket namespace + "/mcp/extra", // MCP namespace + "/metrics/unknown", // Prometheus namespace + "/missing.png", // asset-shaped: extension means real 404 + "/release/v1.2/doc", // dot anywhere in the path: preserved legacy spaFS behavior + } { + rec := get(t, h, path, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("%s: want 404, got %d (body %q)", path, rec.Code, rec.Body.String()) + } + } +} + +func TestRealFile_DelegatesToFileServer(t *testing.T) { + h := newSPAHandler(builtDistFS()) + rec := get(t, h, "/vite.svg", nil) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if rec.Body.String() != "" { + t.Errorf("body = %q", rec.Body.String()) + } +} + +func TestHeadRequest_NoBody(t *testing.T) { + h := newSPAHandler(builtDistFS()) + req := httptest.NewRequest(http.MethodHead, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("HEAD body should be empty, got %q", rec.Body.String()) + } + if rec.Header().Get("ETag") == "" { + t.Errorf("HEAD response missing ETag") + } +} + +// TestStubDist covers the source-only checkout where dist holds just the +// .gitkeep placeholder: the handler must degrade to a plain 404 instead of +// panicking or serving a directory listing. +func TestStubDist_Graceful404(t *testing.T) { + h := newSPAHandler(fstest.MapFS{".gitkeep": {Data: nil}}) + for _, path := range []string{"/", "/traces"} { + rec := get(t, h, path, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("%s: want 404 in stub mode, got %d", path, rec.Code) + } + body, _ := io.ReadAll(rec.Body) + if !strings.Contains(string(body), "UI not built") { + t.Errorf("%s: stub body should explain the missing build, got %q", path, body) + } + } +} + +// TestRegisterRoutes_RealEmbed exercises the production wiring against the +// actual embedded FS (a stub dist in a source-only checkout). +func TestRegisterRoutes_RealEmbed(t *testing.T) { + s := NewServer(nil, nil, nil) + s.SetMCPConfig(true, "/mcp") + mux := http.NewServeMux() + if err := s.RegisterRoutes(mux); err != nil { + t.Fatalf("RegisterRoutes: %v", err) + } + // /static/style.css is committed and embedded — must always serve. + rec := get(t, mux, "/static/style.css", nil) + if rec.Code != http.StatusOK { + t.Errorf("/static/style.css: want 200, got %d", rec.Code) + } +} From 29962221d196dd8792c89ea0ea75e77aa8509b74 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:05:00 +0000 Subject: [PATCH 10/61] chore(tsdb): gofmt aggregator.go and drop stale nolint:unused directive Pre-existing lint-gate failures (gofmt struct alignment, nolintlint on seriesPerTenant which the unused linter no longer flags). No semantic change. Co-Authored-By: Claude Fable 5 --- internal/tsdb/aggregator.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/tsdb/aggregator.go b/internal/tsdb/aggregator.go index 5b11139..1284f7a 100644 --- a/internal/tsdb/aggregator.go +++ b/internal/tsdb/aggregator.go @@ -26,13 +26,13 @@ type RawMetric struct { // Aggregator manages in-memory tumbling windows for metrics. type Aggregator struct { - repo *storage.Repository - windowSize time.Duration - buckets map[string]*storage.MetricBucket - mu sync.Mutex - stopChan chan struct{} - flushChan chan []storage.MetricBucket - pool sync.Pool + repo *storage.Repository + windowSize time.Duration + buckets map[string]*storage.MetricBucket + mu sync.Mutex + stopChan chan struct{} + flushChan chan []storage.MetricBucket + pool sync.Pool // droppedBatches is incremented in flush() (under no lock other than // the outer mu — but the increment path runs after the unlock) and // read by DroppedBatches() concurrently from telemetry scrape paths. @@ -52,11 +52,11 @@ type Aggregator struct { // to a tenant-specific overflow bucket so a noisy tenant cannot // starve siblings of fresh series. seriesPerTenant counts unique // (non-overflow) bucket keys per tenant and is reset by flush(). - maxCardinality int // 0 = unlimited - perTenantCardinality int // 0 = unlimited (global cap still applies) - cardinalityOverflow func(tenantID string) // labeled per overflow event for Prometheus - seriesPerTenant map[string]int //nolint:unused // touched only via mu - overflowKey string // constant key for the global overflow bucket + maxCardinality int // 0 = unlimited + perTenantCardinality int // 0 = unlimited (global cap still applies) + cardinalityOverflow func(tenantID string) // labeled per overflow event for Prometheus + seriesPerTenant map[string]int // touched only via mu + overflowKey string // constant key for the global overflow bucket // Ring buffer accelerator (optional) ring *RingBuffer From 756e397b8881b2669053b8075b6f7a35de74fafe Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:05:30 +0000 Subject: [PATCH 11/61] fix(tsdb): tenant-scope ring buffer series keys and cap new ring creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the RingBuffer dashboard accelerator: (a) the rings map was uncapped and bypassed cardinality enforcement — Aggregator.Ingest fed the ring before the bucket-level cap check, so a cardinality flood grew Go heap without bound on SQLite deploys. (b) rings were keyed service|metric, merging points from different tenants into one series — a data-isolation breach. Fixes: - key rings by tenant|service|metric; empty tenant coerces to storage.DefaultTenantID so single-tenant reads and writes agree - RingBuffer gains maxSeries (0 = unlimited, backward compatible): NEW series past the cap are refused (Record returns false) while existing series keep recording; refusals fire onSeriesRejected outside the lock - new counter otelcontext_tsdb_ring_series_rejected_total in internal/telemetry/metrics.go surfaces refusals - startup wiring: NewRingBuffer now takes METRIC_MAX_CARDINALITY and the counter's Inc as the rejection callback (main.go, one line) QueryRecent/Record/AllKeys callers: only Aggregator.Ingest and main.go — no external QueryRecent callers exist in the tree. Co-Authored-By: Claude Fable 5 --- internal/telemetry/metrics.go | 9 +- internal/tsdb/aggregator.go | 4 +- internal/tsdb/aggregator_test.go | 28 +++++ internal/tsdb/ringbuffer.go | 60 ++++++++--- internal/tsdb/ringbuffer_test.go | 174 +++++++++++++++++++++++++++++++ main.go | 2 +- 6 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 internal/tsdb/ringbuffer_test.go diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go index 872ed05..30eb009 100644 --- a/internal/telemetry/metrics.go +++ b/internal/telemetry/metrics.go @@ -107,7 +107,10 @@ type Metrics struct { GraphRAGStoreEntities *prometheus.GaugeVec GraphRAGStoreEdges *prometheus.GaugeVec TSDBRingSeriesActive prometheus.Gauge - DrainTemplatesActive prometheus.Gauge + // TSDBRingSeriesRejected — points refused a NEW ring series at the + // tenant-scoped series cap (existing series keep recording). + TSDBRingSeriesRejected prometheus.Counter + DrainTemplatesActive prometheus.Gauge // --- Async ingest pipeline (Phase 1 robustness work) --- // IngestPipelineQueueDepth — current queue depth, sampled on every Submit. @@ -346,6 +349,10 @@ func New() *Metrics { Name: "otelcontext_tsdb_ring_series_active", Help: "Distinct metric series currently held in TSDB ring buffers.", }), + TSDBRingSeriesRejected: promauto.NewCounter(prometheus.CounterOpts{ + Name: "otelcontext_tsdb_ring_series_rejected_total", + Help: "Metric points refused a new TSDB ring series at the cardinality cap (existing series keep recording).", + }), DrainTemplatesActive: promauto.NewGauge(prometheus.GaugeOpts{ Name: "otelcontext_drain_templates_active", Help: "Live Drain log templates (bounded by the 50k LRU cap).", diff --git a/internal/tsdb/aggregator.go b/internal/tsdb/aggregator.go index 1284f7a..45def94 100644 --- a/internal/tsdb/aggregator.go +++ b/internal/tsdb/aggregator.go @@ -161,8 +161,10 @@ func (a *Aggregator) Ingest(m RawMetric) { key := fmt.Sprintf("%s|%s|%s|%s", m.TenantID, m.ServiceName, m.Name, string(attrJSON)) // Feed ring buffer and metric counter outside the lock (both are thread-safe). + // The ring enforces its own tenant-scoped series cap and counts rejections + // via its onSeriesRejected callback, so the bool result is not re-counted here. if a.ring != nil { - a.ring.Record(m.Name, m.ServiceName, m.Value, m.Timestamp) + a.ring.Record(m.TenantID, m.Name, m.ServiceName, m.Value, m.Timestamp) } if a.onIngest != nil { a.onIngest() diff --git a/internal/tsdb/aggregator_test.go b/internal/tsdb/aggregator_test.go index d794f2f..cd594cb 100644 --- a/internal/tsdb/aggregator_test.go +++ b/internal/tsdb/aggregator_test.go @@ -250,6 +250,34 @@ func TestAggregator_DefaultBehaviorUnchanged(t *testing.T) { } } +// TestAggregator_IngestFeedsRingWithTenant verifies Ingest plumbs the +// point's TenantID into the attached ring buffer, so two tenants emitting +// the same service+metric never merge into one ring series. +func TestAggregator_IngestFeedsRingWithTenant(t *testing.T) { + a := NewAggregator(nil, time.Minute) + rb := NewRingBuffer(8, time.Minute, 0, nil) + a.SetRingBuffer(rb) + + mA := newRawMetric("tenant-a", "svc", "latency", 0) + mA.Value = 1.0 + a.Ingest(mA) + mB := newRawMetric("tenant-b", "svc", "latency", 0) + mB.Value = 100.0 + a.Ingest(mB) + + aggsA := rb.QueryRecent("tenant-a", "latency", "svc", 8) + if len(aggsA) != 1 || aggsA[0].Sum != 1.0 { + t.Errorf("tenant-a ring aggs=%v, want one window with Sum=1", aggsA) + } + aggsB := rb.QueryRecent("tenant-b", "latency", "svc", 8) + if len(aggsB) != 1 || aggsB[0].Sum != 100.0 { + t.Errorf("tenant-b ring aggs=%v, want one window with Sum=100", aggsB) + } + if got := rb.MetricCount(); got != 2 { + t.Errorf("ring MetricCount=%d, want 2 tenant-scoped series", got) + } +} + // TestAggregator_OverflowBucketStatsCorrect ensures that successive // overflow points for the same tenant accumulate into ONE overflow // bucket (not many) and that min/max/sum/count update correctly. diff --git a/internal/tsdb/ringbuffer.go b/internal/tsdb/ringbuffer.go index df26fa9..6bc09a5 100644 --- a/internal/tsdb/ringbuffer.go +++ b/internal/tsdb/ringbuffer.go @@ -8,6 +8,8 @@ import ( "sort" "sync" "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" ) // WindowAgg is the pre-computed aggregate for one time window slot. @@ -147,27 +149,48 @@ func (r *MetricRing) Windows(n int) []WindowAgg { return out } -// RingBuffer manages per-metric ring buffers, keyed by "service|metric". +// RingBuffer manages per-metric ring buffers, keyed by "tenant|service|metric". type RingBuffer struct { mu sync.RWMutex rings map[string]*MetricRing slots int windowDur time.Duration + // maxSeries caps the number of distinct ring series (0 = unlimited). + // Past the cap, NEW series are refused — existing series keep + // recording — and onSeriesRejected fires once per refused point. + maxSeries int + onSeriesRejected func() } // NewRingBuffer creates a RingBuffer. // slots × windowDur = total retention (e.g. 120 × 30s = 1 hour). -func NewRingBuffer(slots int, windowDur time.Duration) *RingBuffer { +// maxSeries caps distinct tenant|service|metric series (0 = unlimited); +// onSeriesRejected is invoked once per point refused at the cap (nil = no-op). +func NewRingBuffer(slots int, windowDur time.Duration, maxSeries int, onSeriesRejected func()) *RingBuffer { return &RingBuffer{ - rings: make(map[string]*MetricRing), - slots: slots, - windowDur: windowDur, + rings: make(map[string]*MetricRing), + slots: slots, + windowDur: windowDur, + maxSeries: maxSeries, + onSeriesRejected: onSeriesRejected, + } +} + +// ringKey builds the tenant-scoped series key. An empty tenant is coerced +// to storage.DefaultTenantID so writes without an explicit tenant and reads +// from single-tenant callers agree on the same series. +func ringKey(tenantID, metricName, serviceName string) string { + if tenantID == "" { + tenantID = storage.DefaultTenantID } + return tenantID + "|" + serviceName + "|" + metricName } -// Record records a value for the given metric+service at time t. -func (rb *RingBuffer) Record(metricName, serviceName string, value float64, at time.Time) { - key := serviceName + "|" + metricName +// Record records a value for the given tenant+metric+service at time t. +// Returns false when the point was refused because creating a NEW series +// would exceed maxSeries; existing series always keep recording. +func (rb *RingBuffer) Record(tenantID, metricName, serviceName string, value float64, at time.Time) bool { + key := ringKey(tenantID, metricName, serviceName) rb.mu.RLock() ring, ok := rb.rings[key] rb.mu.RUnlock() @@ -175,18 +198,27 @@ func (rb *RingBuffer) Record(metricName, serviceName string, value float64, at t rb.mu.Lock() ring, ok = rb.rings[key] if !ok { + if rb.maxSeries > 0 && len(rb.rings) >= rb.maxSeries { + rb.mu.Unlock() + // Fire telemetry outside the lock — see Aggregator.Ingest + // for why callbacks must not run under mu. + if rb.onSeriesRejected != nil { + rb.onSeriesRejected() + } + return false + } ring = newMetricRing(metricName, serviceName, rb.slots, rb.windowDur) rb.rings[key] = ring } rb.mu.Unlock() } ring.Record(value, at) + return true } -// QueryRecent returns aggregated windows for the given metric+service. -// Pass an empty serviceName to query across all services (returns first match). -func (rb *RingBuffer) QueryRecent(metricName, serviceName string, windowCount int) []WindowAgg { - key := serviceName + "|" + metricName +// QueryRecent returns aggregated windows for the given tenant+metric+service. +func (rb *RingBuffer) QueryRecent(tenantID, metricName, serviceName string, windowCount int) []WindowAgg { + key := ringKey(tenantID, metricName, serviceName) rb.mu.RLock() ring, ok := rb.rings[key] rb.mu.RUnlock() @@ -196,7 +228,7 @@ func (rb *RingBuffer) QueryRecent(metricName, serviceName string, windowCount in return ring.Windows(windowCount) } -// AllKeys returns all registered metric+service keys. +// AllKeys returns all registered tenant|service|metric keys. func (rb *RingBuffer) AllKeys() []string { rb.mu.RLock() defer rb.mu.RUnlock() @@ -207,7 +239,7 @@ func (rb *RingBuffer) AllKeys() []string { return keys } -// MetricCount returns the number of distinct metric series tracked. +// MetricCount returns the number of distinct tenant-scoped metric series tracked. func (rb *RingBuffer) MetricCount() int { rb.mu.RLock() defer rb.mu.RUnlock() diff --git a/internal/tsdb/ringbuffer_test.go b/internal/tsdb/ringbuffer_test.go new file mode 100644 index 0000000..804563c --- /dev/null +++ b/internal/tsdb/ringbuffer_test.go @@ -0,0 +1,174 @@ +package tsdb + +import ( + "strconv" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// newTestRing builds an unlimited RingBuffer with a wide window so every +// record in a test lands in the current slot. +func newTestRing() *RingBuffer { + return NewRingBuffer(8, time.Minute, 0, nil) +} + +// TestRingBuffer_Record_ScopedByTenant is the data-isolation guarantee — +// two tenants recording under the SAME service+metric must never see each +// other's points (modeled on internal/storage tenant_test.go naming). +func TestRingBuffer_Record_ScopedByTenant(t *testing.T) { + rb := newTestRing() + now := time.Now() + + rb.Record("tenant-a", "latency_ms", "checkout", 1.0, now) + rb.Record("tenant-a", "latency_ms", "checkout", 2.0, now) + rb.Record("tenant-b", "latency_ms", "checkout", 100.0, now) + + aggsA := rb.QueryRecent("tenant-a", "latency_ms", "checkout", 8) + if len(aggsA) != 1 { + t.Fatalf("tenant-a windows=%d, want 1", len(aggsA)) + } + if aggsA[0].Count != 2 || aggsA[0].Sum != 3.0 { + t.Errorf("tenant-a agg Count=%d Sum=%v, want Count=2 Sum=3 — tenant-b data leaked in?", aggsA[0].Count, aggsA[0].Sum) + } + + aggsB := rb.QueryRecent("tenant-b", "latency_ms", "checkout", 8) + if len(aggsB) != 1 { + t.Fatalf("tenant-b windows=%d, want 1", len(aggsB)) + } + if aggsB[0].Count != 1 || aggsB[0].Sum != 100.0 { + t.Errorf("tenant-b agg Count=%d Sum=%v, want Count=1 Sum=100 — tenant-a data leaked in?", aggsB[0].Count, aggsB[0].Sum) + } + + if got := rb.QueryRecent("tenant-c", "latency_ms", "checkout", 8); got != nil { + t.Errorf("tenant-c QueryRecent=%v, want nil (tenant never recorded)", got) + } +} + +// TestRingBuffer_EmptyTenantCoercedToDefault — a point recorded without a +// tenant must be readable under storage.DefaultTenantID (and vice versa), +// matching the RawMetric.TenantID doc ("empty → the default applies"). +func TestRingBuffer_EmptyTenantCoercedToDefault(t *testing.T) { + rb := newTestRing() + now := time.Now() + + rb.Record("", "latency_ms", "checkout", 7.0, now) + + aggs := rb.QueryRecent(storage.DefaultTenantID, "latency_ms", "checkout", 8) + if len(aggs) != 1 || aggs[0].Sum != 7.0 { + t.Fatalf("QueryRecent(DefaultTenantID)=%v, want the empty-tenant point", aggs) + } + aggs = rb.QueryRecent("", "latency_ms", "checkout", 8) + if len(aggs) != 1 || aggs[0].Sum != 7.0 { + t.Fatalf("QueryRecent(\"\")=%v, want the empty-tenant point", aggs) + } + if got := rb.MetricCount(); got != 1 { + t.Errorf("MetricCount=%d, want 1 (\"\" and DefaultTenantID are the same series)", got) + } +} + +// TestRingBuffer_SeriesCapRejectsNewSeries — past maxSeries, NEW series are +// refused (telemetry fires, Record returns false) while EXISTING series +// keep recording. +func TestRingBuffer_SeriesCapRejectsNewSeries(t *testing.T) { + rejected := 0 + rb := NewRingBuffer(8, time.Minute, 2, func() { rejected++ }) + now := time.Now() + + if !rb.Record("t", "m1", "svc", 1.0, now) { + t.Fatal("first series refused below cap") + } + if !rb.Record("t", "m2", "svc", 1.0, now) { + t.Fatal("second series refused below cap") + } + // Third DISTINCT series is past the cap → refused. + if rb.Record("t", "m3", "svc", 1.0, now) { + t.Error("third series accepted past cap=2") + } + if rejected != 1 { + t.Errorf("rejection callback fired %d times, want 1", rejected) + } + if got := rb.MetricCount(); got != 2 { + t.Errorf("MetricCount=%d, want 2 (cap must hold)", got) + } + + // Existing series must still record at the cap. + if !rb.Record("t", "m1", "svc", 2.0, now) { + t.Fatal("existing series refused at cap — only NEW series may be rejected") + } + aggs := rb.QueryRecent("t", "m1", "svc", 8) + if len(aggs) != 1 || aggs[0].Count != 2 || aggs[0].Sum != 3.0 { + t.Errorf("existing series aggs=%v, want Count=2 Sum=3", aggs) + } + // The refused series holds no data. + if got := rb.QueryRecent("t", "m3", "svc", 8); got != nil { + t.Errorf("refused series QueryRecent=%v, want nil", got) + } + if rejected != 1 { + t.Errorf("rejection callback fired %d times after existing-series record, want still 1", rejected) + } +} + +// TestRingBuffer_SeriesCapZeroIsUnlimited — maxSeries=0 preserves the +// legacy uncapped behavior. +func TestRingBuffer_SeriesCapZeroIsUnlimited(t *testing.T) { + rb := NewRingBuffer(8, time.Minute, 0, func() { t.Error("rejection fired with cap=0") }) + now := time.Now() + + for i := range 50 { + if !rb.Record("t", "metric-"+strconv.Itoa(i), "svc", 1.0, now) { + t.Fatalf("series %d refused with cap=0", i) + } + } + if got := rb.MetricCount(); got != 50 { + t.Errorf("MetricCount=%d, want 50", got) + } +} + +// TestRingBuffer_WindowAdvanceKeepsTenantSeries — recording past the +// window boundary advances slots within the SAME tenant-scoped series +// (no new series is created) and both windows stay queryable. +func TestRingBuffer_WindowAdvanceKeepsTenantSeries(t *testing.T) { + rb := newTestRing() + now := time.Now() + + rb.Record("tenant-a", "latency_ms", "checkout", 1.0, now) + rb.Record("tenant-a", "latency_ms", "checkout", 2.0, now.Add(2*time.Minute)) + + if got := rb.MetricCount(); got != 1 { + t.Fatalf("MetricCount=%d, want 1 — window advance must not mint a new series", got) + } + aggs := rb.QueryRecent("tenant-a", "latency_ms", "checkout", 8) + if len(aggs) != 2 { + t.Fatalf("windows=%d, want 2 (one per time bucket)", len(aggs)) + } + // Most-recent window first. + if aggs[0].Sum != 2.0 || aggs[0].Count != 1 { + t.Errorf("newest window Sum=%v Count=%d, want Sum=2 Count=1", aggs[0].Sum, aggs[0].Count) + } + if aggs[1].Sum != 1.0 || aggs[1].Count != 1 { + t.Errorf("older window Sum=%v Count=%d, want Sum=1 Count=1", aggs[1].Sum, aggs[1].Count) + } + if aggs[0].P50 != 2.0 || aggs[0].P99 != 2.0 { + t.Errorf("newest window P50=%v P99=%v, want 2 for a single-sample window", aggs[0].P50, aggs[0].P99) + } +} + +// TestRingBuffer_MetricCount_CountsTenantScopedSeries — the same +// service+metric under two tenants is TWO series, not one. +func TestRingBuffer_MetricCount_CountsTenantScopedSeries(t *testing.T) { + rb := newTestRing() + now := time.Now() + + rb.Record("tenant-a", "latency_ms", "checkout", 1.0, now) + rb.Record("tenant-b", "latency_ms", "checkout", 1.0, now) + rb.Record("tenant-a", "latency_ms", "checkout", 2.0, now) // same series + + if got := rb.MetricCount(); got != 2 { + t.Errorf("MetricCount=%d, want 2 tenant-scoped series", got) + } + if got := len(rb.AllKeys()); got != 2 { + t.Errorf("AllKeys len=%d, want 2", got) + } +} diff --git a/main.go b/main.go index ee58982..cb1908a 100644 --- a/main.go +++ b/main.go @@ -356,7 +356,7 @@ func main() { func() { metrics.TSDBIngestTotal.Inc() }, func() { metrics.TSDBBatchesDropped.Inc() }, ) - ringBuf := tsdb.NewRingBuffer(120, 30*time.Second) + ringBuf := tsdb.NewRingBuffer(120, 30*time.Second, cfg.MetricMaxCardinality, metrics.TSDBRingSeriesRejected.Inc) tsdbAgg.SetRingBuffer(ringBuf) slog.Info("📈 TSDB ring buffer attached (120 slots × 30s = 1h retention)") From abbc5089b89fc520f6134e07c89242d90bbb32d6 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:05:51 +0000 Subject: [PATCH 12/61] feat(ui-build): build-time brotli/gzip precompression of dist assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New zero-dep ui/scripts/precompress.mjs (node:zlib only — air-gapped build friendly) emits .br (quality 11) and .gz (level 9) siblings for every dist *.{js,css,html,svg,json} >= 1KB, printing a size table. Wired into the ui build script after vite build; the Go binary's all:dist embed picks the siblings up and internal/ui negotiates them via Accept-Encoding. dist content stays uncommitted (source-only main) — the script runs at release time via the existing build flow. Co-Authored-By: Claude Fable 5 --- ui/package.json | 2 +- ui/scripts/precompress.mjs | 79 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 ui/scripts/precompress.mjs diff --git a/ui/package.json b/ui/package.json index 17bfa21..1575385 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/precompress.mjs", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", diff --git a/ui/scripts/precompress.mjs b/ui/scripts/precompress.mjs new file mode 100644 index 0000000..dece9ca --- /dev/null +++ b/ui/scripts/precompress.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Build-time precompression for the embedded UI bundle. +// +// Emits .br (brotli, quality 11) and .gz (gzip, level 9) siblings next to +// every compressible dist asset so the Go binary can serve precompressed +// bytes with zero runtime CPU — internal/ui/ui.go negotiates Accept-Encoding +// against the siblings picked up by its `all:dist` embed directive. +// +// Zero dependencies: node:zlib + node:fs only (air-gapped build friendly). +// Files under 1KB are skipped (header overhead beats the savings) and the +// emitted .br/.gz siblings are themselves ignored, so reruns are idempotent. +// +// Usage: node scripts/precompress.mjs [distDir] +// distDir defaults to ../../internal/ui/dist (the Vite outDir). + +import { brotliCompressSync, gzipSync, constants } from 'node:zlib'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, extname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const COMPRESSIBLE = new Set(['.js', '.css', '.html', '.svg', '.json']); +const MIN_BYTES = 1024; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const distDir = resolve( + process.argv[2] ?? join(scriptDir, '..', '..', 'internal', 'ui', 'dist'), +); + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (entry.isFile()) yield full; + } +} + +const kb = (n) => `${(n / 1024).toFixed(1)} KB`; +const rows = []; + +for (const file of walk(distDir)) { + if (!COMPRESSIBLE.has(extname(file))) continue; + const buf = readFileSync(file); + if (buf.length < MIN_BYTES) continue; + const br = brotliCompressSync(buf, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + [constants.BROTLI_PARAM_SIZE_HINT]: buf.length, + }, + }); + const gz = gzipSync(buf, { level: 9 }); + writeFileSync(`${file}.br`, br); + writeFileSync(`${file}.gz`, gz); + rows.push({ file: relative(distDir, file), orig: buf.length, gz: gz.length, br: br.length }); +} + +if (rows.length === 0) { + console.log(`precompress: nothing to do in ${distDir} (no compressible files >= 1KB)`); +} else { + rows.sort((a, b) => b.orig - a.orig); + const width = Math.max(...rows.map((r) => r.file.length), 'total'.length); + console.log(`precompress: ${distDir}`); + console.log( + `${'file'.padEnd(width)} ${'orig'.padStart(10)} ${'gzip'.padStart(10)} ${'brotli'.padStart(10)}`, + ); + let torig = 0; + let tgz = 0; + let tbr = 0; + for (const r of rows) { + torig += r.orig; + tgz += r.gz; + tbr += r.br; + console.log( + `${r.file.padEnd(width)} ${kb(r.orig).padStart(10)} ${kb(r.gz).padStart(10)} ${kb(r.br).padStart(10)}`, + ); + } + console.log( + `${'total'.padEnd(width)} ${kb(torig).padStart(10)} ${kb(tgz).padStart(10)} ${kb(tbr).padStart(10)} (brotli saves ${(100 * (1 - tbr / torig)).toFixed(0)}%)`, + ); +} From 8a11ede08a7999733b8c096daf1f8b3c6e6c17ba Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:06:21 +0000 Subject: [PATCH 13/61] perf(sqlite): budget-scale cache_size/mmap_size PRAGMAs + incremental auto_vacuum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure-Go SQLite driver's page cache and mmap window are Go-heap and address-space costs, so the hardcoded 256 MB cache + 1 GB mmap pair starved 4 GB hosts. The stanza now scales both against the detected memory budget (internal/membudget: cgroup v2 -> v1 -> /proc/meminfo): cache = budget/32 clamped to [64 MB, 256 MB], mmap = budget/8 clamped to [256 MB, 1 GB] — a 4 GB host yields 128 MB cache + 512 MB mmap. Detection failure falls back to the previous hardcoded values; operator overrides SQLITE_CACHE_SIZE_KB / SQLITE_MMAP_SIZE_BYTES win unconditionally. The fail-closed Exec loop and every other pragma are unchanged (round-trip-guarded by tests). Also sets PRAGMA auto_vacuum=INCREMENTAL best-effort (log, never abort) BEFORE journal_mode=WAL — the WAL switch initializes the file header, after which the stored auto_vacuum mode is frozen. Only takes effect on databases this process creates; prepares fresh deploys for incremental_vacuum-based retention maintenance. Co-Authored-By: Claude Fable 5 --- .env.example | 8 ++ internal/storage/factory.go | 82 ++++++++++++++++- internal/storage/factory_test.go | 147 +++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f6bb338..bfab1b6 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,14 @@ # Override by setting the env var explicitly. See # docs/superpowers/specs/2026-05-24-mcp-7tool-sqlite-survival-design.md for # per-default rationale and the SQLite PRAGMA stanza applied at startup. +# +# The PRAGMA stanza budget-scales its two memory knobs against the detected +# memory budget (cgroup v2 → cgroup v1 → /proc/meminfo): page cache = +# budget/32 clamped to [64 MB, 256 MB], mmap window = budget/8 clamped to +# [256 MB, 1 GB]. A 4 GB host gets 128 MB cache + 512 MB mmap; detection +# failure falls back to the 256 MB / 1 GB ceilings. Explicit overrides win: +# SQLITE_CACHE_SIZE_KB=131072 # Page cache in KB (> 0). Pure-Go driver: this is Go-heap memory. +# SQLITE_MMAP_SIZE_BYTES=536870912 # mmap window in bytes (>= 0; 0 disables mmap). # ---- Azure Entra (passwordless Postgres) ------------------------------------ # DB_AZURE_AUTH=false # Enables DefaultAzureCredential for Postgres. Requires strict TLS diff --git a/internal/storage/factory.go b/internal/storage/factory.go index 8e4f178..857805d 100644 --- a/internal/storage/factory.go +++ b/internal/storage/factory.go @@ -18,6 +18,8 @@ import ( "gorm.io/gorm/logger" _ "github.com/microsoft/go-mssqldb/azuread" + + "github.com/RandomCodeSpace/otelcontext/internal/membudget" ) // NewDatabase creates a GORM database connection for any supported driver. @@ -103,17 +105,30 @@ func NewDatabase(driver, dsn string) (*gorm.DB, error) { // default-tuned behaviour. The set was hardened on 2026-05-24 to make // the platform survivable at 120 services on SQLite. // - // cache_size=-262144 = 256 MB page cache (negative = KB). - // mmap_size=1073741824 = 1 GB memory-mapped read window. + // cache_size and mmap_size are budget-scaled (the pure-Go driver's page + // cache lives on the Go heap, so a fixed 256 MB/1 GB pair starved 4 GB + // hosts) — see sqliteMemorySizes for the scaling and override rules. // wal_autocheckpoint=10000 = checkpoint after 10k pages so WAL stays bounded. // journal_size_limit=67108864 = hard-cap the WAL file at 64 MB. if strings.ToLower(driver) == "sqlite" || driver == "" { + budget, budgetSource := membudget.Detect() + cacheKB, mmapBytes := sqliteMemorySizes(budget) + // Best-effort, deliberately NOT fail-closed, and ordered BEFORE the + // stanza below: auto_vacuum only takes effect on databases this + // process creates, and the journal_mode=WAL switch initializes the + // file header — after which the stored auto_vacuum mode is frozen + // (pre-existing files keep theirs either way). INCREMENTAL lets the + // retention scheduler reclaim pages via PRAGMA incremental_vacuum + // instead of a full daily VACUUM. + if err := db.Exec("PRAGMA auto_vacuum=INCREMENTAL").Error; err != nil { + log.Printf("⚠️ PRAGMA auto_vacuum=INCREMENTAL failed (best-effort, continuing): %v", err) + } pragmas := []string{ "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", - "PRAGMA cache_size=-262144", + fmt.Sprintf("PRAGMA cache_size=-%d", cacheKB), "PRAGMA temp_store=MEMORY", - "PRAGMA mmap_size=1073741824", + fmt.Sprintf("PRAGMA mmap_size=%d", mmapBytes), "PRAGMA wal_autocheckpoint=10000", "PRAGMA journal_size_limit=67108864", "PRAGMA busy_timeout=5000", @@ -123,6 +138,11 @@ func NewDatabase(driver, dsn string) (*gorm.DB, error) { return nil, fmt.Errorf("sqlite pragma %q failed: %w", p, err) } } + if budgetSource == "" { + budgetSource = "none (fallback to hardcoded ceilings)" + } + log.Printf("📊 SQLite memory tuning: cache=%d KB, mmap=%d MB (budget=%d MB, source=%s)", + cacheKB, mmapBytes/(1<<20), budget/(1<<20), budgetSource) } // Configure Connection Pool — configurable via env vars for non-SQLite drivers. @@ -168,6 +188,60 @@ func getEnvPoolInt(key string, fallback int) int { return fallback } +// SQLite memory sizing bounds. The max values are the pre-2026-06 hardcoded +// stanza (256 MB page cache, 1 GB mmap window) and double as the fallback +// when budget detection fails — never worse than the previous behaviour. +const ( + sqliteCacheKBMin = 65536 // 64 MB + sqliteCacheKBMax = 262144 // 256 MB (legacy hardcoded value) + sqliteMmapBytesMin = 268435456 // 256 MB + sqliteMmapBytesMax = 1073741824 // 1 GB (legacy hardcoded value) +) + +// sqliteMemorySizes resolves the SQLite page-cache size (in KB, applied as a +// negative cache_size) and mmap window (in bytes) for the startup PRAGMA +// stanza. With the pure-Go driver both budgets are Go-heap/address-space +// costs, so they scale with the detected memory budget instead of being +// hardcoded: cache = budget/32 clamped to [64 MB, 256 MB], mmap = budget/8 +// clamped to [256 MB, 1 GB] — a 4 GB host yields 128 MB cache + 512 MB mmap. +// budget <= 0 (detection failed) falls back to the legacy hardcoded maxima. +// Operator overrides win unconditionally: SQLITE_CACHE_SIZE_KB (> 0) and +// SQLITE_MMAP_SIZE_BYTES (>= 0; 0 disables mmap). Invalid values are ignored. +func sqliteMemorySizes(budget int64) (cacheKB, mmapBytes int64) { + cacheKB = sqliteCacheKBMax + mmapBytes = sqliteMmapBytesMax + if budget > 0 { + cacheKB = clampInt64(budget/32/1024, sqliteCacheKBMin, sqliteCacheKBMax) + mmapBytes = clampInt64(budget/8, sqliteMmapBytesMin, sqliteMmapBytesMax) + } + if v, ok := getEnvInt64("SQLITE_CACHE_SIZE_KB"); ok && v > 0 { + cacheKB = v + } + if v, ok := getEnvInt64("SQLITE_MMAP_SIZE_BYTES"); ok && v >= 0 { + mmapBytes = v + } + return cacheKB, mmapBytes +} + +func clampInt64(v, lo, hi int64) int64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func getEnvInt64(key string) (int64, bool) { + if v, ok := os.LookupEnv(key); ok { + if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + return i, true + } + } + return 0, false +} + // scrubDSN returns a DSN-like string with passwords and sensitive fields redacted. // Handles two common forms: // - URL: postgres://user:PASS@host/db → postgres://user:REDACTED@host/db diff --git a/internal/storage/factory_test.go b/internal/storage/factory_test.go index ffc0f98..ca7a26b 100644 --- a/internal/storage/factory_test.go +++ b/internal/storage/factory_test.go @@ -1,8 +1,13 @@ package storage import ( + "path/filepath" "strings" "testing" + + "gorm.io/gorm" + + "github.com/RandomCodeSpace/otelcontext/internal/membudget" ) func TestNewDatabase_UnsupportedDriver(t *testing.T) { @@ -76,6 +81,148 @@ func TestAutoMigrateModels_SQLite_AllTablesCreated(t *testing.T) { } } +// clearSQLiteMemoryEnv neutralizes the two PRAGMA override env vars for the +// duration of the test. t.Setenv("") registers the restore; an empty value +// fails strconv parsing and is therefore ignored by sqliteMemorySizes. +func clearSQLiteMemoryEnv(t *testing.T) { + t.Helper() + t.Setenv("SQLITE_CACHE_SIZE_KB", "") + t.Setenv("SQLITE_MMAP_SIZE_BYTES", "") +} + +// pragmaInt64 round-trips a PRAGMA query against the live connection. +func pragmaInt64(t *testing.T, db *gorm.DB, pragma string) int64 { + t.Helper() + var v int64 + if err := db.Raw("PRAGMA " + pragma).Scan(&v).Error; err != nil { + t.Fatalf("PRAGMA %s: %v", pragma, err) + } + return v +} + +func TestSQLiteMemorySizes_BudgetScaling(t *testing.T) { + clearSQLiteMemoryEnv(t) + cases := []struct { + name string + budget int64 + wantCacheKB int64 + wantMmap int64 + }{ + {"detection failed -> legacy hardcoded", 0, 262144, 1073741824}, + {"4GB host -> 128MB cache, 512MB mmap", 4 << 30, 131072, 512 << 20}, + {"1GB host clamps to floors", 1 << 30, 65536, 268435456}, + {"64GB host clamps to ceilings", 64 << 30, 262144, 1073741824}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cacheKB, mmapBytes := sqliteMemorySizes(tc.budget) + if cacheKB != tc.wantCacheKB || mmapBytes != tc.wantMmap { + t.Fatalf("sqliteMemorySizes(%d)=(%d,%d) want (%d,%d)", + tc.budget, cacheKB, mmapBytes, tc.wantCacheKB, tc.wantMmap) + } + }) + } +} + +func TestSQLiteMemorySizes_EnvOverridesWinOverBudget(t *testing.T) { + t.Setenv("SQLITE_CACHE_SIZE_KB", "12345") + t.Setenv("SQLITE_MMAP_SIZE_BYTES", "0") // 0 = disable mmap, a legitimate override + cacheKB, mmapBytes := sqliteMemorySizes(4 << 30) + if cacheKB != 12345 || mmapBytes != 0 { + t.Fatalf("env overrides ignored: got (%d,%d) want (12345,0)", cacheKB, mmapBytes) + } +} + +func TestSQLiteMemorySizes_InvalidEnvIgnored(t *testing.T) { + t.Setenv("SQLITE_CACHE_SIZE_KB", "not-a-number") + t.Setenv("SQLITE_MMAP_SIZE_BYTES", "-1") // negative mmap is invalid + cacheKB, mmapBytes := sqliteMemorySizes(4 << 30) + if cacheKB != 131072 || mmapBytes != 512<<20 { + t.Fatalf("invalid env must fall back to budget scaling: got (%d,%d)", cacheKB, mmapBytes) + } +} + +func TestNewDatabase_SQLitePragmas_EnvOverrideRoundTrip(t *testing.T) { + t.Setenv("SQLITE_CACHE_SIZE_KB", "12345") + t.Setenv("SQLITE_MMAP_SIZE_BYTES", "33554432") + + db, err := NewDatabase("sqlite", filepath.Join(t.TempDir(), "override.db")) + if err != nil { + t.Fatalf("sqlite: %v", err) + } + defer closeDB(db) + + if got := pragmaInt64(t, db, "cache_size"); got != -12345 { + t.Fatalf("cache_size=%d want -12345", got) + } + if got := pragmaInt64(t, db, "mmap_size"); got != 33554432 { + t.Fatalf("mmap_size=%d want 33554432", got) + } +} + +func TestNewDatabase_SQLitePragmas_BudgetScaledRoundTrip(t *testing.T) { + clearSQLiteMemoryEnv(t) + + // Whatever this host's budget resolves to, the live connection must carry + // the same numbers the sizing function computes — proves the wiring, not + // the host RAM. + budget, _ := membudget.Detect() + wantCacheKB, wantMmap := sqliteMemorySizes(budget) + + db, err := NewDatabase("sqlite", filepath.Join(t.TempDir(), "scaled.db")) + if err != nil { + t.Fatalf("sqlite: %v", err) + } + defer closeDB(db) + + if got := pragmaInt64(t, db, "cache_size"); got != -wantCacheKB { + t.Fatalf("cache_size=%d want %d", got, -wantCacheKB) + } + if got := pragmaInt64(t, db, "mmap_size"); got != wantMmap { + t.Fatalf("mmap_size=%d want %d", got, wantMmap) + } + + // The rest of the hardened stanza must stay byte-identical — guard the + // values that round-trip numerically. + fixed := []struct { + pragma string + want int64 + }{ + {"synchronous", 1}, // NORMAL + {"temp_store", 2}, // MEMORY + {"wal_autocheckpoint", 10000}, + {"journal_size_limit", 67108864}, + {"busy_timeout", 5000}, + } + for _, f := range fixed { + if got := pragmaInt64(t, db, f.pragma); got != f.want { + t.Fatalf("PRAGMA %s=%d want %d (hardened stanza must not drift)", f.pragma, got, f.want) + } + } + var mode string + if err := db.Raw("PRAGMA journal_mode").Scan(&mode).Error; err != nil || !strings.EqualFold(mode, "wal") { + t.Fatalf("journal_mode=%q err=%v want wal", mode, err) + } +} + +func TestNewDatabase_SQLiteAutoVacuumIncremental(t *testing.T) { + clearSQLiteMemoryEnv(t) + + // Best-effort PRAGMA at startup: on a fresh database file it must take + // effect (auto_vacuum=2 = INCREMENTAL) because it runs before the first + // table is created. Pre-existing files keep their stored mode — that is + // accepted and not asserted here. + db, err := NewDatabase("sqlite", filepath.Join(t.TempDir(), "fresh.db")) + if err != nil { + t.Fatalf("sqlite: %v", err) + } + defer closeDB(db) + + if got := pragmaInt64(t, db, "auto_vacuum"); got != 2 { + t.Fatalf("auto_vacuum=%d want 2 (INCREMENTAL) on a fresh database", got) + } +} + func TestScrubDSN(t *testing.T) { cases := []struct { name string From b061c230d871c72ee0c4622e463a39c86a6665c9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:06:52 +0000 Subject: [PATCH 14/61] feat(graphrag): per-tenant span cap in TraceStore (P1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TraceStore gains MaxSpans (GRAPHRAG_MAX_SPANS_PER_TENANT, default 500k): at the cap, NEW spans are skipped — UpsertSpan returns false and processSpan records the drop via the existing recordEventDrop seam as signal="span_capacity" on otelcontext_graphrag_events_dropped_total. Updates to resident span IDs still apply; the graph is best-effort and the DB stays the source of truth. main.go wires MaxSpansPerTenant plus the GRAPHRAG_TRACE_TTL / GRAPHRAG_TENANT_IDLE_TTL duration knobs (unparsable values fall back to package defaults, DLQ-interval style). Also lands the tenantStores lastAccess/lastEventAt/lastRebuildMax bookkeeping fields and the storesForTenant/tenantStoresNoTouch split consumed by the follow-up eviction and incremental-rebuild commits. Co-Authored-By: Claude Fable 5 --- internal/graphrag/anomaly_dedup_test.go | 2 +- internal/graphrag/builder.go | 113 ++++++++++++++++++------ internal/graphrag/span_cap_test.go | 105 ++++++++++++++++++++++ internal/graphrag/store.go | 56 ++++++++++-- main.go | 12 +++ 5 files changed, 250 insertions(+), 38 deletions(-) create mode 100644 internal/graphrag/span_cap_test.go diff --git a/internal/graphrag/anomaly_dedup_test.go b/internal/graphrag/anomaly_dedup_test.go index e007e45..9250aac 100644 --- a/internal/graphrag/anomaly_dedup_test.go +++ b/internal/graphrag/anomaly_dedup_test.go @@ -13,7 +13,7 @@ import ( // 15-min soak). Under the fix, node and edge counts stay bounded regardless of // how many ticks fire. func TestAnomalyDedupBoundsStore(t *testing.T) { - stores := newTenantStores(time.Hour) + stores := newTenantStores(time.Hour, 0) base := time.Unix(1_700_000_000, 0) const ticks = 100 diff --git a/internal/graphrag/builder.go b/internal/graphrag/builder.go index e2b058e..c43f3c4 100644 --- a/internal/graphrag/builder.go +++ b/internal/graphrag/builder.go @@ -44,6 +44,13 @@ const ( defaultRefreshEvery = 60 * time.Second defaultSnapshotEvery = 15 * time.Minute defaultAnomalyEvery = 10 * time.Second + // defaultMaxSpansPerTenant caps the in-memory TraceStore per tenant + // (~850 B/span ⇒ ~425 MB worst case). Overridable via + // GRAPHRAG_MAX_SPANS_PER_TENANT. + defaultMaxSpansPerTenant = 500000 + // defaultTenantIdleTTL evicts a tenant slice after this much time with + // no ingest event or query. Overridable via GRAPHRAG_TENANT_IDLE_TTL. + defaultTenantIdleTTL = 24 * time.Hour ) // spanEvent is sent through the ingestion channel. @@ -99,11 +106,13 @@ type GraphRAG struct { stopCh chan struct{} // Configuration - traceTTL time.Duration - refreshEvery time.Duration - snapshotEvery time.Duration - anomalyEvery time.Duration - workerCount int // 0 = defaultWorkerCount (set by New from Config) + traceTTL time.Duration + refreshEvery time.Duration + snapshotEvery time.Duration + anomalyEvery time.Duration + workerCount int // 0 = defaultWorkerCount (set by New from Config) + maxSpansPerTenant int // per-tenant TraceStore span cap; <=0 = unbounded + tenantIdleTTL time.Duration // idle window before tenant eviction; <=0 disables // Event drop counters. Atomic so OnSpanIngested/OnLogIngested/ // OnMetricIngested can record overflows without taking any lock — @@ -111,6 +120,11 @@ type GraphRAG struct { droppedSpans atomic.Int64 droppedLogs atomic.Int64 droppedMetrics atomic.Int64 + // droppedSpanCap counts spans skipped because the tenant's TraceStore + // hit MaxSpans (signal "span_capacity" on the Prometheus counter). + droppedSpanCap atomic.Int64 + // tenantsEvicted counts tenant slices removed by evictIdleTenants. + tenantsEvicted atomic.Int64 // metrics is an optional Prometheus hook for exporting event drops. // Assigned via SetMetrics; nil-safe at call sites. @@ -145,6 +159,15 @@ func (g *GraphRAG) DroppedLogsCount() int64 { return g.droppedLogs.Load() } // because the ingestion channel was full. func (g *GraphRAG) DroppedMetricsCount() int64 { return g.droppedMetrics.Load() } +// SpanCapacityDropsCount reports the number of spans skipped because the +// tenant's TraceStore was at its MaxSpans cap. Atomic, safe from any +// goroutine; exported for tests and readiness probes. +func (g *GraphRAG) SpanCapacityDropsCount() int64 { return g.droppedSpanCap.Load() } + +// TenantsEvictedCount reports the number of tenant store slices evicted +// for exceeding the idle TTL since startup. +func (g *GraphRAG) TenantsEvictedCount() int64 { return g.tenantsEvicted.Load() } + // InvestigationInsertCount reports cooldown-allowed PersistInvestigation // calls. Semantics: this counter increments when the cooldown check // passes, BEFORE the DB write — so a subsequent DB failure still @@ -175,6 +198,8 @@ func (g *GraphRAG) recordEventDrop(signal string) { g.droppedLogs.Add(1) case "metric": g.droppedMetrics.Add(1) + case "span_capacity": + g.droppedSpanCap.Add(1) } if g.metrics != nil && g.metrics.GraphRAGEventsDroppedTotal != nil { g.metrics.GraphRAGEventsDroppedTotal.WithLabelValues(signal).Inc() @@ -189,17 +214,26 @@ type Config struct { AnomalyEvery time.Duration WorkerCount int ChannelSize int + // MaxSpansPerTenant caps each tenant's in-memory TraceStore span map. + // 0 = defaultMaxSpansPerTenant; negative disables the cap. + MaxSpansPerTenant int + // TenantIdleTTL evicts a tenant's store slice after this much time + // without any ingest event or query. 0 = defaultTenantIdleTTL; + // negative disables eviction. + TenantIdleTTL time.Duration } // DefaultConfig returns sensible defaults. func DefaultConfig() Config { return Config{ - TraceTTL: defaultTraceTTL, - RefreshEvery: defaultRefreshEvery, - SnapshotEvery: defaultSnapshotEvery, - AnomalyEvery: defaultAnomalyEvery, - WorkerCount: defaultWorkerCount, - ChannelSize: defaultChannelSize, + TraceTTL: defaultTraceTTL, + RefreshEvery: defaultRefreshEvery, + SnapshotEvery: defaultSnapshotEvery, + AnomalyEvery: defaultAnomalyEvery, + WorkerCount: defaultWorkerCount, + ChannelSize: defaultChannelSize, + MaxSpansPerTenant: defaultMaxSpansPerTenant, + TenantIdleTTL: defaultTenantIdleTTL, } } @@ -227,21 +261,29 @@ func New(repo *storage.Repository, tsdbAgg *tsdb.Aggregator, ringBuf *tsdb.RingB if cfg.ChannelSize == 0 { cfg.ChannelSize = defaultChannelSize } + if cfg.MaxSpansPerTenant == 0 { + cfg.MaxSpansPerTenant = defaultMaxSpansPerTenant + } + if cfg.TenantIdleTTL == 0 { + cfg.TenantIdleTTL = defaultTenantIdleTTL + } g := &GraphRAG{ - tenants: make(map[string]*tenantStores), - repo: repo, - tsdbAgg: tsdbAgg, - ringBuf: ringBuf, - drain: NewDrain(), - eventCh: make(chan event, cfg.ChannelSize), - stopCh: make(chan struct{}), - traceTTL: cfg.TraceTTL, - refreshEvery: cfg.RefreshEvery, - snapshotEvery: cfg.SnapshotEvery, - anomalyEvery: cfg.AnomalyEvery, - workerCount: cfg.WorkerCount, - invCooldown: newInvestigationCooldown(5 * time.Minute), + tenants: make(map[string]*tenantStores), + repo: repo, + tsdbAgg: tsdbAgg, + ringBuf: ringBuf, + drain: NewDrain(), + eventCh: make(chan event, cfg.ChannelSize), + stopCh: make(chan struct{}), + traceTTL: cfg.TraceTTL, + refreshEvery: cfg.RefreshEvery, + snapshotEvery: cfg.SnapshotEvery, + anomalyEvery: cfg.AnomalyEvery, + workerCount: cfg.WorkerCount, + maxSpansPerTenant: cfg.MaxSpansPerTenant, + tenantIdleTTL: cfg.TenantIdleTTL, + invCooldown: newInvestigationCooldown(5 * time.Minute), } // Bootstrap the default tenant slice so refresh/snapshot loops have a @@ -446,7 +488,7 @@ func (g *GraphRAG) processSpan(ev *spanEvent) { // 3. Create TraceNode + SpanNode + CONTAINS + CHILD_OF edges stores.traces.UpsertTrace(span.TraceID, span.ServiceName, ev.Status, durationMs, span.StartTime) - stores.traces.UpsertSpan(SpanNode{ + if !stores.traces.UpsertSpan(SpanNode{ ID: span.SpanID, TraceID: span.TraceID, ParentSpanID: span.ParentSpanID, @@ -456,7 +498,11 @@ func (g *GraphRAG) processSpan(ev *spanEvent) { StatusCode: ev.Status, IsError: isError, Timestamp: span.StartTime, - }) + }) { + // Tenant span cap reached — graph is best-effort; DB is source of + // truth. Service/operation/trace stats above were still updated. + g.recordEventDrop("span_capacity") + } // 4. If parent span exists and belongs to different service, create CALLS edge if span.ParentSpanID != "" { @@ -523,8 +569,19 @@ func (g *GraphRAG) storesFor(ctx context.Context) *tenantStores { // storesForTenant is the tenant-string flavour of storesFor, used by event // handlers that have already resolved the tenant (the callback path carries // it on spanEvent / logEvent / metricEvent). Empty strings are coerced to -// storage.DefaultTenantID. +// storage.DefaultTenantID. Every call refreshes the tenant's idle-eviction +// clock — ingest and queries both count as activity. func (g *GraphRAG) storesForTenant(tenant string) *tenantStores { + slice := g.tenantStoresNoTouch(tenant) + slice.lastAccess.Store(time.Now().UnixNano()) + return slice +} + +// tenantStoresNoTouch returns (lazily creating) the tenant slice WITHOUT +// refreshing its idle-eviction clock. Background maintenance — the 60s DB +// rebuild in particular — goes through here so bookkeeping alone cannot keep +// a dormant tenant alive past tenantIdleTTL; only real ingest or queries do. +func (g *GraphRAG) tenantStoresNoTouch(tenant string) *tenantStores { if tenant == "" { tenant = storage.DefaultTenantID } @@ -539,7 +596,7 @@ func (g *GraphRAG) storesForTenant(tenant string) *tenantStores { if slice, ok = g.tenants[tenant]; ok { return slice } - slice = newTenantStores(g.traceTTL) + slice = newTenantStores(g.traceTTL, g.maxSpansPerTenant) g.tenants[tenant] = slice return slice } diff --git a/internal/graphrag/span_cap_test.go b/internal/graphrag/span_cap_test.go new file mode 100644 index 0000000..56a6a4a --- /dev/null +++ b/internal/graphrag/span_cap_test.go @@ -0,0 +1,105 @@ +package graphrag + +import ( + "fmt" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// mkSpanNode builds a minimal SpanNode for cap tests. +func mkSpanNode(id string, ts time.Time) SpanNode { + return SpanNode{ + ID: id, + TraceID: "trace-cap", + Service: "orders", + Operation: "/op", + Duration: 1.0, + Timestamp: ts, + } +} + +// TestTraceStore_UpsertSpan_CapBlocksNewSpans proves the per-tenant span cap: +// once len(Spans) reaches MaxSpans, NEW span IDs are rejected (UpsertSpan +// returns false and the map does not grow) while updates to already-resident +// span IDs still apply. +func TestTraceStore_UpsertSpan_CapBlocksNewSpans(t *testing.T) { + ts := newTraceStore(time.Hour, 3) + now := time.Now() + + for i := 0; i < 3; i++ { + if !ts.UpsertSpan(mkSpanNode(fmt.Sprintf("s-%d", i), now)) { + t.Fatalf("span s-%d rejected below the cap", i) + } + } + + // 4th NEW span: at cap, must be rejected. + if ts.UpsertSpan(mkSpanNode("s-overflow", now)) { + t.Fatalf("new span accepted at cap — MaxSpans not enforced") + } + if got := len(ts.Spans); got != 3 { + t.Fatalf("Spans len = %d after rejected insert, want 3", got) + } + + // Update to an EXISTING span ID at cap: must still apply. + updated := mkSpanNode("s-1", now) + updated.Duration = 42.0 + if !ts.UpsertSpan(updated) { + t.Fatalf("update to resident span s-1 rejected at cap") + } + if got, _ := ts.GetSpan("s-1"); got.Duration != 42.0 { + t.Fatalf("resident span update not applied at cap: Duration=%v, want 42", got.Duration) + } +} + +// TestTraceStore_UpsertSpan_CapDisabled proves MaxSpans <= 0 leaves the +// store unbounded (legacy behavior). +func TestTraceStore_UpsertSpan_CapDisabled(t *testing.T) { + ts := newTraceStore(time.Hour, 0) + now := time.Now() + for i := 0; i < 10; i++ { + if !ts.UpsertSpan(mkSpanNode(fmt.Sprintf("s-%d", i), now)) { + t.Fatalf("span rejected with cap disabled") + } + } + if got := len(ts.Spans); got != 10 { + t.Fatalf("Spans len = %d, want 10", got) + } +} + +// TestProcessSpan_CapDropRecorded proves the coordinator surfaces cap drops: +// when a tenant's TraceStore is full, processSpan records the skip via the +// span_capacity drop counter (and the Prometheus counter when wired). +func TestProcessSpan_CapDropRecorded(t *testing.T) { + cfg := DefaultConfig() + cfg.MaxSpansPerTenant = 2 + g := New(nil, nil, nil, cfg) + t.Cleanup(g.Stop) + + now := time.Now() + for i := 0; i < 3; i++ { + g.processSpan(&spanEvent{ + Span: storage.Span{ + TraceID: "trace-cap", + SpanID: fmt.Sprintf("s-%d", i), + ServiceName: "orders", + StartTime: now, + }, + TraceID: "trace-cap", + Status: "STATUS_CODE_UNSET", + Tenant: storage.DefaultTenantID, + }) + } + + if got := g.SpanCapacityDropsCount(); got != 1 { + t.Fatalf("SpanCapacityDropsCount = %d, want 1", got) + } + stores := g.storesForTenant(storage.DefaultTenantID) + stores.traces.mu.RLock() + n := len(stores.traces.Spans) + stores.traces.mu.RUnlock() + if n != 2 { + t.Fatalf("tenant span count = %d, want 2 (cap)", n) + } +} diff --git a/internal/graphrag/store.go b/internal/graphrag/store.go index 8d2caa3..ed6a72b 100644 --- a/internal/graphrag/store.go +++ b/internal/graphrag/store.go @@ -3,6 +3,7 @@ package graphrag import ( "math" "sync" + "sync/atomic" "time" ) @@ -16,15 +17,37 @@ type tenantStores struct { traces *TraceStore signals *SignalStore anomalies *AnomalyStore + + // lastAccess is the unix-nano time of the most recent ingest event or + // query routed through storesForTenant. Drives idle-tenant eviction in + // refreshLoop; background maintenance uses tenantStoresNoTouch so a 60s + // bookkeeping pass cannot keep a dormant tenant alive. + lastAccess atomic.Int64 + + // lastEventAt is the unix-nano time of the most recent span/log/metric + // processed for this tenant. detectAnomalies skips tenants whose value + // predates the previous scan tick — their stats cannot have changed. + lastEventAt atomic.Int64 + + // lastRebuildMax is the high-water-mark (unix nanos) of span start_time + // merged by rebuildFromDBForTenant. Subsequent rebuilds only re-read + // rows newer than HWM minus a small overlap instead of the full + // trailing window. Zero on a fresh slice (first build / post-eviction) + // forces a full-window rebuild. + lastRebuildMax atomic.Int64 } -func newTenantStores(traceTTL time.Duration) *tenantStores { - return &tenantStores{ +func newTenantStores(traceTTL time.Duration, maxSpans int) *tenantStores { + ts := &tenantStores{ service: newServiceStore(), - traces: newTraceStore(traceTTL), + traces: newTraceStore(traceTTL, maxSpans), signals: newSignalStore(), anomalies: newAnomalyStore(), } + // Creation counts as access — a slice re-created by the DB rebuild gets + // a full idle window rather than being evicted on the next tick. + ts.lastAccess.Store(time.Now().UnixNano()) + return ts } // ServiceStore holds permanent service topology data. @@ -50,14 +73,19 @@ type TraceStore struct { Spans map[string]*SpanNode // key: span_id Edges map[string]*Edge // key: type|from|to TTL time.Duration + // MaxSpans hard-caps the Spans map: at the cap, NEW span IDs are + // skipped (UpsertSpan returns false) while updates to resident IDs + // still apply. <=0 disables the cap. + MaxSpans int } -func newTraceStore(ttl time.Duration) *TraceStore { +func newTraceStore(ttl time.Duration, maxSpans int) *TraceStore { return &TraceStore{ - Traces: make(map[string]*TraceNode), - Spans: make(map[string]*SpanNode), - Edges: make(map[string]*Edge), - TTL: ttl, + Traces: make(map[string]*TraceNode), + Spans: make(map[string]*SpanNode), + Edges: make(map[string]*Edge), + TTL: ttl, + MaxSpans: maxSpans, } } @@ -270,10 +298,19 @@ func (ts *TraceStore) UpsertTrace(traceID, rootService, status string, durationM } } -func (ts *TraceStore) UpsertSpan(span SpanNode) { +// UpsertSpan inserts or updates a span node and its CONTAINS/CHILD_OF edges. +// Returns false when the span is NEW and MaxSpans is already reached — the +// span is skipped entirely (the graph is best-effort; the DB is the source +// of truth, same doctrine as the event-channel overflow in builder.go). +func (ts *TraceStore) UpsertSpan(span SpanNode) bool { ts.mu.Lock() defer ts.mu.Unlock() + if ts.MaxSpans > 0 && len(ts.Spans) >= ts.MaxSpans { + if _, resident := ts.Spans[span.ID]; !resident { + return false + } + } ts.Spans[span.ID] = &span // CONTAINS edge: trace → span @@ -299,6 +336,7 @@ func (ts *TraceStore) UpsertSpan(span SpanNode) { } } } + return true } func (ts *TraceStore) GetSpan(spanID string) (*SpanNode, bool) { diff --git a/main.go b/main.go index ee58982..57be797 100644 --- a/main.go +++ b/main.go @@ -393,6 +393,15 @@ func main() { graphRAGCfg := graphrag.DefaultConfig() graphRAGCfg.WorkerCount = cfg.GraphRAGWorkerCount graphRAGCfg.ChannelSize = cfg.GraphRAGEventQueueSize + graphRAGCfg.MaxSpansPerTenant = cfg.GraphRAGMaxSpansPerTenant + // Duration knobs follow the DLQ_REPLAY_INTERVAL pattern: unparsable + // values fall back to the package default rather than aborting startup. + if ttl, err := time.ParseDuration(cfg.GraphRAGTraceTTL); err == nil && ttl > 0 { + graphRAGCfg.TraceTTL = ttl + } + if idle, err := time.ParseDuration(cfg.GraphRAGTenantIdleTTL); err == nil && idle > 0 { + graphRAGCfg.TenantIdleTTL = idle + } graphRAG := graphrag.New(repo, tsdbAgg, ringBuf, graphRAGCfg) graphRAG.SetMetrics(metrics) ctxGraphRAG, cancelGraphRAG := context.WithCancel(context.Background()) @@ -400,6 +409,9 @@ func main() { slog.Info("GraphRAG started (layered graph with anomaly detection)", "workers", cfg.GraphRAGWorkerCount, "event_queue_size", cfg.GraphRAGEventQueueSize, + "trace_ttl", graphRAGCfg.TraceTTL, + "max_spans_per_tenant", graphRAGCfg.MaxSpansPerTenant, + "tenant_idle_ttl", graphRAGCfg.TenantIdleTTL, ) // Auto-migrate GraphRAG models (Investigation, DrainTemplateRow) From 9f6e24585ba51851be2f32689e54a8b5da341f5b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:07:38 +0000 Subject: [PATCH 15/61] feat(api): stdlib gzip middleware for GET /api/* responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/api/compress.go: pooled gzip.Writer (sync.Pool) wrapping the ResponseWriter for GET /api/* when the client accepts gzip. Lazy engagement on first write — 204/304 and already-encoded responses pass through, Content-Length is dropped, Vary: Accept-Encoding is set on every eligible path. Flush propagates through the gzip buffer so streaming handlers keep incremental delivery; Unwrap supports http.ResponseController. Wired in main.go as the innermost wrapper (directly around the mux, before TenantMiddleware) so only handler output is compressed and /ws*, /v1/*, /metrics*, and the MCP/SSE path are untouched — WebSocket hijacking keeps the raw writer by construction. Co-Authored-By: Claude Fable 5 --- internal/api/compress.go | 144 +++++++++++++++++++++++ internal/api/compress_test.go | 209 ++++++++++++++++++++++++++++++++++ main.go | 6 + 3 files changed, 359 insertions(+) create mode 100644 internal/api/compress.go create mode 100644 internal/api/compress_test.go diff --git a/internal/api/compress.go b/internal/api/compress.go new file mode 100644 index 0000000..04a5cca --- /dev/null +++ b/internal/api/compress.go @@ -0,0 +1,144 @@ +package api + +import ( + "compress/gzip" + "net/http" + "strings" + "sync" + + "github.com/RandomCodeSpace/otelcontext/internal/httpconst" +) + +// gzipWriterPool recycles gzip writers across requests — a gzip.Writer holds +// ~hundreds of KB of compression state, far too much to allocate per call. +var gzipWriterPool = sync.Pool{ + New: func() any { return gzip.NewWriter(nil) }, +} + +// GzipMiddleware compresses GET /api/* responses for clients that accept +// gzip. The 120-service system-graph JSON shrinks 5-8× on the wire. +// +// Everything outside /api/* passes through untouched, which keeps the +// streaming surfaces safe by construction: WebSocket upgrades (/ws*) still +// see an http.Hijacker, OTLP ingest (/v1/*) and Prometheus scrapes +// (/metrics*) are write paths/exposition formats with their own encodings, +// and MCP SSE must flush uncompressed frames. Those prefixes — plus the +// configured MCP path — are also excluded explicitly in case an operator +// ever nests one under /api/. +// +// Wire this innermost (directly around the mux): only handler output is +// compressed; error responses written by outer middleware (auth, rate +// limit) stay identity-encoded. +func GzipMiddleware(mcpPath string) func(http.Handler) http.Handler { + if mcpPath == "" { + mcpPath = "/mcp" + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !gzipEligible(r, mcpPath) { + next.ServeHTTP(w, r) + return + } + // Vary on every eligible path — even identity responses — so a + // shared cache never hands a gzipped body to an identity-only + // client or vice versa. + w.Header().Add("Vary", "Accept-Encoding") + if !httpconst.AcceptsEncoding(r, "gzip") { + next.ServeHTTP(w, r) + return + } + gw := &gzipResponseWriter{ResponseWriter: w} + defer gw.close() + next.ServeHTTP(gw, r) + }) + } +} + +// gzipEligible reports whether the request may have its response gzipped. +func gzipEligible(r *http.Request, mcpPath string) bool { + if r.Method != http.MethodGet { + return false + } + p := r.URL.Path + if !strings.HasPrefix(p, "/api/") { + return false + } + for _, skip := range []string{"/ws", "/v1/", "/metrics"} { + if strings.HasPrefix(p, skip) { + return false + } + } + if p == mcpPath || strings.HasPrefix(p, mcpPath+"/") { + return false + } + return true +} + +// gzipResponseWriter lazily engages compression on the first write: +// WriteHeader decides — 204/304 and responses that already carry a +// Content-Encoding pass through untouched, everything else gets +// Content-Encoding: gzip with Content-Length dropped (the compressed size +// isn't known up front; net/http falls back to chunked transfer). +type gzipResponseWriter struct { + http.ResponseWriter + gz *gzip.Writer + wroteHeader bool +} + +func (w *gzipResponseWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + h := w.Header() + if code != http.StatusNoContent && code != http.StatusNotModified && h.Get("Content-Encoding") == "" { + h.Set("Content-Encoding", "gzip") + h.Del("Content-Length") + gz := gzipWriterPool.Get().(*gzip.Writer) + gz.Reset(w.ResponseWriter) + w.gz = gz + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *gzipResponseWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if w.gz != nil { + return w.gz.Write(b) + } + return w.ResponseWriter.Write(b) +} + +// Flush completes the current gzip block before flushing downstream so +// incremental delivery keeps working for handlers that stream. +func (w *gzipResponseWriter) Flush() { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if w.gz != nil { + _ = w.gz.Flush() + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap exposes the underlying writer to http.ResponseController for +// per-request deadline control. ResponseController still finds our Flush +// first, so the gzip buffer is never bypassed. +func (w *gzipResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// close finalises the gzip stream (writing the trailer) and returns the +// writer to the pool. No-op when compression never engaged. +func (w *gzipResponseWriter) close() { + if w.gz == nil { + return + } + _ = w.gz.Close() + gzipWriterPool.Put(w.gz) + w.gz = nil +} diff --git a/internal/api/compress_test.go b/internal/api/compress_test.go new file mode 100644 index 0000000..632fa20 --- /dev/null +++ b/internal/api/compress_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// jsonEcho is a handler that writes the given payload as JSON in several +// chunks (exercising the multi-Write path through the gzip writer). +func jsonEcho(payload string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + half := len(payload) / 2 + _, _ = w.Write([]byte(payload[:half])) + _, _ = w.Write([]byte(payload[half:])) + }) +} + +func gzipGet(t *testing.T, h http.Handler, path string, acceptGzip bool) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + if acceptGzip { + req.Header.Set("Accept-Encoding", "gzip") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func gunzip(t *testing.T, body io.Reader) string { + t.Helper() + zr, err := gzip.NewReader(body) + if err != nil { + t.Fatalf("gzip.NewReader: %v", err) + } + defer zr.Close() + b, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("gunzip read: %v", err) + } + return string(b) +} + +func TestGzipMiddleware_CompressesAPIGet(t *testing.T) { + payload := strings.Repeat(`{"service":"checkout","status":"healthy"},`, 100) + h := GzipMiddleware("/mcp")(jsonEcho(payload)) + + rec := gzipGet(t, h, "/api/system/graph", true) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q", got) + } + if cl := rec.Header().Get("Content-Length"); cl != "" { + t.Errorf("Content-Length should be dropped, got %q", cl) + } + if rec.Body.Len() >= len(payload) { + t.Errorf("body not actually compressed: %d >= %d", rec.Body.Len(), len(payload)) + } + if got := gunzip(t, rec.Body); got != payload { + t.Errorf("decompressed body mismatch (len %d vs %d)", len(got), len(payload)) + } +} + +func TestGzipMiddleware_SkipsWithoutAcceptEncoding(t *testing.T) { + payload := strings.Repeat("x", 4096) + h := GzipMiddleware("/mcp")(jsonEcho(payload)) + + rec := gzipGet(t, h, "/api/logs", false) + + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding = %q, want empty", got) + } + if rec.Body.String() != payload { + t.Errorf("identity body mangled") + } + // Vary still set: caches must key on Accept-Encoding either way. + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q", got) + } +} + +func TestGzipMiddleware_SkipsNonAPIPaths(t *testing.T) { + payload := strings.Repeat("y", 2048) + for _, path := range []string{"/ws", "/ws/events", "/v1/traces", "/metrics/prometheus", "/mcp", "/mcp/session", "/"} { + h := GzipMiddleware("/mcp")(jsonEcho(payload)) + rec := gzipGet(t, h, path, true) + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("%s: Content-Encoding = %q, want empty", path, got) + } + if rec.Body.String() != payload { + t.Errorf("%s: body mangled", path) + } + } +} + +func TestGzipMiddleware_SkipsNonGET(t *testing.T) { + h := GzipMiddleware("/mcp")(jsonEcho(strings.Repeat("z", 2048))) + req := httptest.NewRequest(http.MethodPost, "/api/admin/vacuum", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("POST: Content-Encoding = %q, want empty", got) + } +} + +func TestGzipMiddleware_RespectsExistingContentEncoding(t *testing.T) { + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Encoding", "br") + _, _ = w.Write([]byte("pre-compressed-bytes")) + })) + rec := gzipGet(t, h, "/api/whatever", true) + if got := rec.Header().Get("Content-Encoding"); got != "br" { + t.Fatalf("Content-Encoding = %q, want br untouched", got) + } + if rec.Body.String() != "pre-compressed-bytes" { + t.Errorf("double-compressed an already-encoded response") + } +} + +func TestGzipMiddleware_No304Body(t *testing.T) { + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotModified) + })) + rec := gzipGet(t, h, "/api/stats", true) + if rec.Code != http.StatusNotModified { + t.Fatalf("want 304, got %d", rec.Code) + } + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("304 must not carry Content-Encoding, got %q", got) + } + if rec.Body.Len() != 0 { + t.Errorf("304 body should be empty, got %d bytes", rec.Body.Len()) + } +} + +func TestGzipMiddleware_EmptyHandlerBody(t *testing.T) { + // Handler that never writes: net/http sends an implicit 200 with no + // body; the wrapper must not inject a stray empty gzip stream header. + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + rec := gzipGet(t, h, "/api/empty", true) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("empty handler should produce empty body, got %d bytes", rec.Body.Len()) + } +} + +func TestGzipMiddleware_FlushSupported(t *testing.T) { + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("first")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = w.Write([]byte("second")) + })) + rec := gzipGet(t, h, "/api/stream", true) + if !rec.Flushed { + t.Errorf("Flush did not propagate to the underlying writer") + } + if got := gunzip(t, rec.Body); got != "firstsecond" { + t.Errorf("flushed body = %q", got) + } +} + +// TestGzipMiddleware_PoolReuseConcurrent hammers the middleware from many +// goroutines to prove the sync.Pool recycling is race-free and never +// cross-wires response bodies. +func TestGzipMiddleware_PoolReuseConcurrent(t *testing.T) { + payload := strings.Repeat(`{"n":1},`, 512) + h := GzipMiddleware("/mcp")(jsonEcho(payload)) + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + for range 20 { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/x", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(rec, req) + zr, err := gzip.NewReader(rec.Body) + if err != nil { + t.Errorf("gzip.NewReader: %v", err) + return + } + b, err := io.ReadAll(zr) + _ = zr.Close() + if err != nil || string(b) != payload { + t.Errorf("corrupted body under concurrency (err=%v, len=%d)", err, len(b)) + return + } + } + }() + } + wg.Wait() +} diff --git a/main.go b/main.go index ee58982..96a29eb 100644 --- a/main.go +++ b/main.go @@ -718,6 +718,12 @@ func main() { var httpHandler http.Handler = mux + // Gzip GET /api/* responses (innermost wrapper — only handler output is + // compressed; error responses written by outer middleware like auth and + // rate limiting stay identity-encoded, and /ws*, /v1/*, /metrics*, and + // the MCP/SSE path pass through untouched). + httpHandler = api.GzipMiddleware(cfg.MCPPath)(httpHandler) + // Resolve tenant on /api/* read-side requests (passes through OTLP /v1, // MCP, UI assets, and health probes untouched). httpHandler = api.TenantMiddleware(cfg)(httpHandler) From c7d7c1a7b502317dadfc2840f49afc790e60c295 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:09:05 +0000 Subject: [PATCH 16/61] feat(ingest): byte-bounded async ingest queue (P1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue gated only on item count; each Batch holds unbounded Traces/Spans/Logs slices, so 50k queued batches could pin GBs of heap. Add byte accounting: - (*Batch).approxBytes() — O(records) heap estimate, computed once at Submit and released by process() (deferred; panic path included) - hard cap INGEST_PIPELINE_MAX_BYTES (default 512MB, SQLite default 128MB, 1MB floor) — at the cap Submit rejects with ErrQueueFull even for priority batches: a 429 is recoverable, an OOM kill is not - soft backpressure now fires on max(itemFullness, byteFullness) - new gauge otelcontext_ingest_pipeline_queue_bytes; drop reason "bytes_full" on otelcontext_ingest_pipeline_dropped_total - Stats() exposes QueueBytes / MaxBytes / RejectedBytes Co-Authored-By: Claude Fable 5 --- .env.example | 2 + internal/config/config.go | 13 +- internal/config/driver_defaults_test.go | 23 +- internal/ingest/pipeline.go | 102 ++++++++- internal/ingest/pipeline_test.go | 282 ++++++++++++++++++++++++ internal/telemetry/metrics.go | 9 + main.go | 1 + 7 files changed, 419 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index f6bb338..ed71610 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,8 @@ # DB_MAX_IDLE_CONNS 10 → 1 # INGEST_PIPELINE_WORKERS 8 → 2 # INGEST_PIPELINE_QUEUE_SIZE 50000 → 10000 +# INGEST_PIPELINE_MAX_BYTES 536870912 → 134217728 (512MB → 128MB byte cap on queued batches; +# at the cap ingest gets 429 even for error batches) # METRIC_MAX_CARDINALITY 10000 → 3000 # STORE_MIN_SEVERITY "" → "WARN" (INFO/DEBUG still flow to GraphRAG/Drain, just not persisted) # SAMPLING_RATE 1.0 → 0.05 (errors and slow spans always kept) diff --git a/internal/config/config.go b/internal/config/config.go index a36aa25..9f8c30c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -143,7 +143,14 @@ type Config struct { // 100% queue — return RESOURCE_EXHAUSTED so OTLP clients back off IngestAsyncEnabled bool // default true; opt out via INGEST_ASYNC_ENABLED=false IngestPipelineQueueSize int // default 50000 batches; per-deployment tunable - IngestPipelineWorkers int // default 8 worker goroutines + // IngestPipelineMaxBytes caps the approximate bytes held by queued + // batches. The item-count queue size alone cannot bound memory — one + // batch may carry arbitrarily large span/log payloads. At the cap the + // pipeline rejects with RESOURCE_EXHAUSTED / HTTP 429 even for priority + // (error/slow) batches: a 429 is recoverable, an OOM kill is not. + // Default 512MB; SQLite default 128MB (see applyDriverDefaults). + IngestPipelineMaxBytes int + IngestPipelineWorkers int // default 8 worker goroutines // IngestPipelinePerTenantCap caps in-flight batches per tenant so a noisy // tenant cannot starve siblings of fresh queue slots when fullness is // below the soft-backpressure threshold. 0 (default) disables — single- @@ -302,6 +309,7 @@ func Load(customPath string) (*Config, error) { // Async ingest pipeline IngestAsyncEnabled: getEnvBool("INGEST_ASYNC_ENABLED", true), IngestPipelineQueueSize: getEnvInt("INGEST_PIPELINE_QUEUE_SIZE", 50000), + IngestPipelineMaxBytes: getEnvInt("INGEST_PIPELINE_MAX_BYTES", 512<<20), IngestPipelineWorkers: getEnvInt("INGEST_PIPELINE_WORKERS", 8), IngestPipelinePerTenantCap: getEnvInt("INGEST_PIPELINE_PER_TENANT_CAP", 0), @@ -363,6 +371,9 @@ var sqliteOverrides = []struct { {"DB_MAX_IDLE_CONNS", func(c *Config) { c.DBMaxIdleConns = 1 }}, {"INGEST_PIPELINE_WORKERS", func(c *Config) { c.IngestPipelineWorkers = 2 }}, {"INGEST_PIPELINE_QUEUE_SIZE", func(c *Config) { c.IngestPipelineQueueSize = 10000 }}, + // The SQLite single writer drains slowly, so the ingest queue is the + // first structure to bloat — bound it to 128MB instead of 512MB. + {"INGEST_PIPELINE_MAX_BYTES", func(c *Config) { c.IngestPipelineMaxBytes = 128 << 20 }}, {"METRIC_MAX_CARDINALITY", func(c *Config) { c.MetricMaxCardinality = 3000 }}, {"STORE_MIN_SEVERITY", func(c *Config) { c.StoreMinSeverity = "WARN" }}, {"SAMPLING_RATE", func(c *Config) { c.SamplingRate = 0.05 }}, diff --git a/internal/config/driver_defaults_test.go b/internal/config/driver_defaults_test.go index fddaa61..9e11f58 100644 --- a/internal/config/driver_defaults_test.go +++ b/internal/config/driver_defaults_test.go @@ -13,6 +13,7 @@ var sqliteEnvKeys = []string{ "DB_MAX_IDLE_CONNS", "INGEST_PIPELINE_WORKERS", "INGEST_PIPELINE_QUEUE_SIZE", + "INGEST_PIPELINE_MAX_BYTES", "METRIC_MAX_CARDINALITY", "STORE_MIN_SEVERITY", "SAMPLING_RATE", @@ -47,16 +48,17 @@ func clearSQLiteEnv(t *testing.T) { func postgresDefaultsConfig(driver string) *Config { return &Config{ DBDriver: driver, - DBMaxOpenConns: 50, // Postgres default - DBMaxIdleConns: 10, // Postgres default - IngestPipelineWorkers: 8, // Postgres default - IngestPipelineQueueSize: 50000, // Postgres default - MetricMaxCardinality: 10000, // Postgres default - StoreMinSeverity: "", // same-as-ingest default - SamplingRate: 1.0, // keep-all default - GRPCMaxConcurrentStreams: 1000, // Postgres default - GraphRAGEventQueueSize: 100000, // Postgres default - LogFTSEnabled: false, // FTS5 opt-in default + DBMaxOpenConns: 50, // Postgres default + DBMaxIdleConns: 10, // Postgres default + IngestPipelineWorkers: 8, // Postgres default + IngestPipelineQueueSize: 50000, // Postgres default + IngestPipelineMaxBytes: 512 << 20, // Postgres default + MetricMaxCardinality: 10000, // Postgres default + StoreMinSeverity: "", // same-as-ingest default + SamplingRate: 1.0, // keep-all default + GRPCMaxConcurrentStreams: 1000, // Postgres default + GraphRAGEventQueueSize: 100000, // Postgres default + LogFTSEnabled: false, // FTS5 opt-in default } } @@ -77,6 +79,7 @@ func TestApplyDriverDefaults_SQLite_FlipsAllWhenNoEnv(t *testing.T) { {"DBMaxIdleConns", cfg.DBMaxIdleConns, 1}, {"IngestPipelineWorkers", cfg.IngestPipelineWorkers, 2}, {"IngestPipelineQueueSize", cfg.IngestPipelineQueueSize, 10000}, + {"IngestPipelineMaxBytes", cfg.IngestPipelineMaxBytes, 128 << 20}, {"MetricMaxCardinality", cfg.MetricMaxCardinality, 3000}, {"StoreMinSeverity", cfg.StoreMinSeverity, "WARN"}, {"SamplingRate", cfg.SamplingRate, 0.05}, diff --git a/internal/ingest/pipeline.go b/internal/ingest/pipeline.go index 868a300..4881fdb 100644 --- a/internal/ingest/pipeline.go +++ b/internal/ingest/pipeline.go @@ -60,6 +60,34 @@ type Batch struct { LogCallback func(storage.Log) enqueuedAt time.Time + + // sizeBytes is the approxBytes() estimate computed once at Submit time + // and released by process() — keeping the reservation and the release + // reading the same number even if the slices are mutated in between. + sizeBytes int64 +} + +// approxBytes estimates the heap footprint of the batch without marshaling. +// Per-record fixed costs approximate struct overhead (time.Time fields, +// GORM bookkeeping, slice/string headers); variable costs are the lengths +// of the dominant string payloads — Body and AttributesJSON are what make +// a batch fat. AttributesJSON/AIInsight are storage.CompressedText (string +// kind), so len() over the string cast is O(1). Whole walk is O(records). +func (b *Batch) approxBytes() int64 { + var n int64 + for i := range b.Traces { + t := &b.Traces[i] + n += 128 + int64(len(t.TraceID)+len(t.ServiceName)+len(t.Status)+len(t.Operation)) + } + for i := range b.Spans { + s := &b.Spans[i] + n += 256 + int64(len(s.OperationName)+len(string(s.AttributesJSON))+len(s.ServiceName)+len(s.Status)) + } + for i := range b.Logs { + l := &b.Logs[i] + n += 192 + int64(len(l.Body)+len(string(l.AttributesJSON))+len(string(l.AIInsight))+len(l.ServiceName)+len(l.Severity)) + } + return n } // Priority reports whether the batch is protected from soft-backpressure @@ -76,6 +104,12 @@ type PipelineConfig struct { Capacity int // total queue depth across all signal types Workers int // worker goroutines draining the queue SoftThreshold float64 // fullness fraction above which healthy batches are dropped (0.0–1.0) + // MaxBytes caps the approximate bytes held by queued batches. Capacity + // alone cannot bound memory — a single Batch may carry arbitrarily + // large span/log payloads. At the cap, Submit rejects with ErrQueueFull + // even for priority batches: a 429 is recoverable by the client's retry + // loop, an OOM kill is not. <=0 falls back to the 512MB default. + MaxBytes int64 } // Defensive upper bounds on operator-supplied capacity/workers. Env-var @@ -87,6 +121,10 @@ type PipelineConfig struct { const ( maxPipelineCapacity = 1_000_000 maxPipelineWorkers = 256 + // minPipelineMaxBytes is the floor for the byte cap. A sub-1MB cap + // would reject every gRPC batch outright (GRPC_MAX_RECV_MB default is + // 16) — clamp up instead of letting a typo black-hole all ingest. + minPipelineMaxBytes = 1 << 20 ) // DefaultPipelineConfig returns production-sized defaults. @@ -95,6 +133,7 @@ func DefaultPipelineConfig() PipelineConfig { Capacity: 50000, Workers: 8, SoftThreshold: 0.9, + MaxBytes: 512 << 20, } } @@ -140,9 +179,16 @@ type Pipeline struct { processedTotal atomic.Int64 droppedHealthy atomic.Int64 rejectedFull atomic.Int64 + rejectedBytes atomic.Int64 processFailures atomic.Int64 tenantDropped atomic.Int64 + // inFlightBytes tracks the approxBytes() sum of every batch currently + // in the queue or being processed. Reserved in Submit before the + // channel send, released by process() (deferred, so the panic path + // releases too). Bounded by cfg.MaxBytes. + inFlightBytes atomic.Int64 + // Per-tenant in-flight cap — bounds the queue slots a single tenant // can consume so a noisy tenant cannot starve siblings of fresh // healthy submissions when fullness is below the soft threshold. @@ -201,6 +247,16 @@ func NewPipeline(writer pipelineWriter, metrics *telemetry.Metrics, cfg Pipeline } cfg.Capacity = capacity cfg.Workers = workers + if cfg.MaxBytes <= 0 { + cfg.MaxBytes = d.MaxBytes + } + if cfg.MaxBytes < minPipelineMaxBytes { + slog.Warn("ingest pipeline: max bytes clamped up to floor — a sub-1MB cap would reject every gRPC batch", + "requested", cfg.MaxBytes, + "min", int64(minPipelineMaxBytes), + ) + cfg.MaxBytes = minPipelineMaxBytes + } // Zero-value config falls back to defaults — the field is internal // (no env-var surface) and TestPipeline_DefaultsApplied enforces this. // Priority-only mode (always-soft-drop) is not a supported configuration @@ -300,9 +356,13 @@ func (p *Pipeline) Submit(b *Batch) error { return nil } b.enqueuedAt = time.Now() + b.sizeBytes = b.approxBytes() - fullness := float64(len(p.queue)) / float64(p.cfg.Capacity) - if fullness >= p.cfg.SoftThreshold && !b.Priority() { + // Soft backpressure engages on whichever dimension is more saturated: + // item count (many small batches) or bytes (few fat batches). + itemFullness := float64(len(p.queue)) / float64(p.cfg.Capacity) + byteFullness := float64(p.inFlightBytes.Load()) / float64(p.cfg.MaxBytes) + if max(itemFullness, byteFullness) >= p.cfg.SoftThreshold && !b.Priority() { p.droppedHealthy.Add(1) p.observeDrop(b.Type, "soft_backpressure") return nil @@ -326,12 +386,28 @@ func (p *Pipeline) Submit(b *Batch) error { p.tenantMu.Unlock() } + // Byte-cap reservation — after the soft and tenant checks so dropped + // batches never reserve, before the channel send so the cap is never + // overshot. Priority batches get NO exemption here: a 429 is + // recoverable by the OTLP client's retry loop, an OOM kill is not. + if newTotal := p.inFlightBytes.Add(b.sizeBytes); newTotal > p.cfg.MaxBytes { + p.inFlightBytes.Add(-b.sizeBytes) + if tenantReserved { + p.releaseTenantSlot(b.Tenant) + } + p.rejectedBytes.Add(1) + p.observeDrop(b.Type, "bytes_full") + return ErrQueueFull + } + select { case p.queue <- b: p.enqueuedTotal.Add(1) p.observeQueueDepth(b.Type) + p.observeQueueBytes() return nil default: + p.inFlightBytes.Add(-b.sizeBytes) if tenantReserved { p.releaseTenantSlot(b.Tenant) } @@ -377,10 +453,13 @@ func (p *Pipeline) Stats() PipelineStats { Processed: p.processedTotal.Load(), DroppedHealthy: p.droppedHealthy.Load(), RejectedFull: p.rejectedFull.Load(), + RejectedBytes: p.rejectedBytes.Load(), ProcessFailures: p.processFailures.Load(), StoreFiltered: p.storeFiltered.Load(), QueueDepth: len(p.queue), Capacity: p.cfg.Capacity, + QueueBytes: p.inFlightBytes.Load(), + MaxBytes: p.cfg.MaxBytes, } } @@ -390,10 +469,13 @@ type PipelineStats struct { Processed int64 DroppedHealthy int64 RejectedFull int64 + RejectedBytes int64 // batches rejected because the byte cap was exceeded ProcessFailures int64 StoreFiltered int64 // logs dropped by STORE_MIN_SEVERITY at persist time QueueDepth int Capacity int + QueueBytes int64 // approx bytes currently reserved by in-flight batches + MaxBytes int64 // configured byte cap } // worker drains the queue. Exits when stopCh closes (after draining @@ -436,6 +518,15 @@ func (p *Pipeline) process(b *Batch) { if b == nil { return } + // Release the byte reservation taken at Submit time. Unconditional — + // every batch that reached the channel reserved sizeBytes, priority or + // not — and deferred so the panic path below releases too. Mirrors the + // reservation exactly; an asymmetry here ratchets inFlightBytes up + // until the cap rejects all traffic. + defer func() { + p.inFlightBytes.Add(-b.sizeBytes) + p.observeQueueBytes() + }() // Release the per-tenant slot reserved at Submit time. Registered as // a defer so it runs even if the batch panics. Priority batches don't // reserve at submit, so they don't release here either — the conditions @@ -507,6 +598,13 @@ func (p *Pipeline) observeQueueDepth(t SignalType) { p.metrics.IngestPipelineQueueDepth.WithLabelValues(signalLabel(t)).Set(float64(len(p.queue))) } +func (p *Pipeline) observeQueueBytes() { + if p.metrics == nil || p.metrics.IngestPipelineQueueBytes == nil { + return + } + p.metrics.IngestPipelineQueueBytes.Set(float64(p.inFlightBytes.Load())) +} + func (p *Pipeline) observeDrop(t SignalType, reason string) { if p.metrics == nil || p.metrics.IngestPipelineDroppedTotal == nil { return diff --git a/internal/ingest/pipeline_test.go b/internal/ingest/pipeline_test.go index fdf1a9a..9d2f87b 100644 --- a/internal/ingest/pipeline_test.go +++ b/internal/ingest/pipeline_test.go @@ -3,12 +3,17 @@ package ingest import ( "context" "errors" + "strings" "sync" "sync/atomic" "testing" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/RandomCodeSpace/otelcontext/internal/storage" + "github.com/RandomCodeSpace/otelcontext/internal/telemetry" ) // fakeWriter is a deterministic, in-memory pipelineWriter for tests. @@ -401,6 +406,9 @@ func TestPipeline_DefaultsApplied(t *testing.T) { if p.cfg.SoftThreshold != d.SoftThreshold { t.Errorf("SoftThreshold default not applied: got %v want %v", p.cfg.SoftThreshold, d.SoftThreshold) } + if p.cfg.MaxBytes != d.MaxBytes { + t.Errorf("MaxBytes default not applied: got %d want %d", p.cfg.MaxBytes, d.MaxBytes) + } } func TestPipeline_PerTenantCap_DropsExcessHealthy(t *testing.T) { @@ -671,3 +679,277 @@ func TestPipeline_StoreMinSeverity_Disabled_PersistsAllLogs(t *testing.T) { t.Errorf("Stats().StoreFiltered = %d, want 0 with gate disabled", got) } } + +// ===== Byte-bounded queue (P1.2) ===== + +// fatLogBatch builds a single-log batch whose Body is `bodyLen` bytes — +// the byte-cap tests size their payloads through this. +func fatLogBatch(bodyLen int) *Batch { + return &Batch{ + Type: SignalLogs, + Tenant: "t1", + Logs: []storage.Log{{Body: strings.Repeat("x", bodyLen)}}, + } +} + +func TestBatch_ApproxBytes_Formula(t *testing.T) { + // Pins the per-record estimate: fixed struct overhead + the lengths of + // the dominant string payloads. A drive-by change to the formula must + // consciously update this test. + b := &Batch{ + Traces: []storage.Trace{{TraceID: "abcd", ServiceName: "svc", Status: "OK", Operation: "op"}}, + Spans: []storage.Span{{ + OperationName: "GET /x", + AttributesJSON: storage.CompressedText(`{"k":"v"}`), + ServiceName: "svc", + Status: "OK", + }}, + Logs: []storage.Log{{ + Body: "hello", + AttributesJSON: storage.CompressedText("{}"), + AIInsight: storage.CompressedText("ai"), + ServiceName: "svc", + Severity: "INFO", + }}, + } + wantTrace := int64(128 + 4 + 3 + 2 + 2) // TraceID + ServiceName + Status + Operation + wantSpan := int64(256 + 6 + 9 + 3 + 2) // OperationName + AttributesJSON + ServiceName + Status + wantLog := int64(192 + 5 + 2 + 2 + 3 + 4) // Body + AttributesJSON + AIInsight + ServiceName + Severity + if got, want := b.approxBytes(), wantTrace+wantSpan+wantLog; got != want { + t.Fatalf("approxBytes() = %d, want %d", got, want) + } +} + +func TestBatch_ApproxBytes_GrowsWithPayload(t *testing.T) { + // The estimate must scale with the variable-length payloads — Body and + // AttributesJSON are what actually make a batch fat. + small := fatLogBatch(1) + big := fatLogBatch(1001) + if diff := big.approxBytes() - small.approxBytes(); diff != 1000 { + t.Errorf("Body growth: approxBytes diff = %d, want 1000", diff) + } + + s1 := &Batch{Spans: []storage.Span{{SpanID: "s"}}} + s2 := &Batch{Spans: []storage.Span{{SpanID: "s", AttributesJSON: storage.CompressedText(strings.Repeat("a", 500))}}} + if diff := s2.approxBytes() - s1.approxBytes(); diff != 500 { + t.Errorf("AttributesJSON growth: approxBytes diff = %d, want 500", diff) + } +} + +func TestPipeline_MaxBytes_DefaultAndClamp(t *testing.T) { + // MaxBytes <= 0 falls back to the 512MB default. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 10, Workers: 0}) + if got := p.Stats().MaxBytes; got != 512<<20 { + t.Errorf("MaxBytes default: got %d, want %d", got, int64(512<<20)) + } + // A sub-1MB cap would reject every gRPC batch (GRPC_MAX_RECV_MB default + // is 16) — clamp up to the 1MB floor instead. + p2 := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 10, Workers: 0, MaxBytes: 1000}) + if got := p2.Stats().MaxBytes; got != 1<<20 { + t.Errorf("sub-1MB clamp: got %d, want %d", got, int64(1<<20)) + } + // Exactly the floor passes through unmodified. + p3 := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 10, Workers: 0, MaxBytes: 1 << 20}) + if got := p3.Stats().MaxBytes; got != 1<<20 { + t.Errorf("1MB floor passthrough: got %d, want %d", got, int64(1<<20)) + } +} + +func TestPipeline_ByteCap_RejectsEvenPriority(t *testing.T) { + // Item count is nowhere near Capacity, yet the second fat batch must be + // rejected on bytes — and priority gives no exemption: a 429 is + // recoverable by the OTLP client's retry loop, an OOM kill is not. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 100, Workers: 0, MaxBytes: 1 << 20}) + + first := fatLogBatch(700 * 1024) + first.HasError = true + if err := p.Submit(first); err != nil { + t.Fatalf("first fat batch under the cap rejected: %v", err) + } + firstSize := first.approxBytes() + + second := fatLogBatch(700 * 1024) + second.HasError = true + if err := p.Submit(second); !errors.Is(err, ErrQueueFull) { + t.Fatalf("over-cap priority submit returned %v, want ErrQueueFull", err) + } + + stats := p.Stats() + if stats.RejectedBytes != 1 { + t.Errorf("RejectedBytes = %d, want 1", stats.RejectedBytes) + } + if stats.RejectedFull != 0 { + t.Errorf("RejectedFull = %d, want 0 (byte rejection is a separate counter)", stats.RejectedFull) + } + if stats.QueueBytes != firstSize { + t.Errorf("QueueBytes = %d, want %d (rejected batch must not stay reserved)", stats.QueueBytes, firstSize) + } +} + +func TestPipeline_ByteCap_SingleOversizedBatchRejected(t *testing.T) { + // A lone batch bigger than the whole cap is rejected outright and + // leaves no residual reservation behind. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 100, Workers: 0, MaxBytes: 1 << 20}) + b := fatLogBatch(2 << 20) + b.HasError = true + if err := p.Submit(b); !errors.Is(err, ErrQueueFull) { + t.Fatalf("oversized submit returned %v, want ErrQueueFull", err) + } + if got := p.Stats().QueueBytes; got != 0 { + t.Errorf("QueueBytes = %d after rejection, want 0", got) + } +} + +func TestPipeline_ByteCap_ReleasesTenantSlotOnReject(t *testing.T) { + // A healthy batch reserves its tenant slot before the byte check; a + // byte rejection must hand that slot back, or the tenant cap turns into + // a slow leak. With cap 1, a leaked slot would make the follow-up + // submission a tenant_backpressure drop. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 10, Workers: 0, MaxBytes: 1 << 20}) + p.SetPerTenantCap(1) + + fat := fatLogBatch(2 << 20) // healthy → reserves the tenant slot first + if err := p.Submit(fat); !errors.Is(err, ErrQueueFull) { + t.Fatalf("oversized submit returned %v, want ErrQueueFull", err) + } + if err := p.Submit(healthyBatch()); err != nil { + t.Fatalf("follow-up submit after byte rejection: %v", err) + } + if got := p.TenantDropped(); got != 0 { + t.Errorf("TenantDropped = %d, want 0 (slot leaked by byte rejection)", got) + } + if got := p.Stats().Enqueued; got != 1 { + t.Errorf("Enqueued = %d, want 1", got) + } +} + +func TestPipeline_ChannelFull_ReleasesBytes(t *testing.T) { + // The hard-capacity default branch must undo the byte reservation the + // same way it undoes the tenant slot. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 1, Workers: 0}) + first := errorBatch() + if err := p.Submit(first); err != nil { + t.Fatalf("priming submit: %v", err) + } + if err := p.Submit(errorBatch()); !errors.Is(err, ErrQueueFull) { + t.Fatalf("channel-full submit returned %v, want ErrQueueFull", err) + } + if got, want := p.Stats().QueueBytes, first.approxBytes(); got != want { + t.Errorf("QueueBytes = %d, want %d (channel-full path leaked its reservation)", got, want) + } +} + +func TestPipeline_SoftDropViaByteFullness(t *testing.T) { + // Item fullness is ~0.1% but bytes are at ~93% of the cap — the soft + // check must fire on max(itemFullness, byteFullness) and drop the + // healthy batch. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 1000, Workers: 0, SoftThreshold: 0.9, MaxBytes: 1 << 20}) + fat := fatLogBatch(950 * 1024) // priority → bypasses the soft check itself + fat.HasError = true + if err := p.Submit(fat); err != nil { + t.Fatalf("priming fat priority submit: %v", err) + } + + if err := p.Submit(healthyBatch()); err != nil { + t.Fatalf("soft-dropped submit returned %v, want nil (silent drop)", err) + } + stats := p.Stats() + if stats.DroppedHealthy != 1 { + t.Errorf("DroppedHealthy = %d, want 1 (byteFullness should trip the soft check)", stats.DroppedHealthy) + } + if stats.Enqueued != 1 { + t.Errorf("Enqueued = %d, want 1 (only the priming batch)", stats.Enqueued) + } +} + +func TestPipeline_ByteAccounting_ReservedWhileQueued(t *testing.T) { + // With no workers draining, QueueBytes must equal the sum of the + // enqueued batches' estimates. + p := NewPipeline(&fakeWriter{}, nil, PipelineConfig{Capacity: 10, Workers: 0}) + b1, b2 := healthyBatch(), fatLogBatch(4096) + if err := p.Submit(b1); err != nil { + t.Fatalf("submit 1: %v", err) + } + if err := p.Submit(b2); err != nil { + t.Fatalf("submit 2: %v", err) + } + if got, want := p.Stats().QueueBytes, b1.approxBytes()+b2.approxBytes(); got != want { + t.Errorf("QueueBytes = %d, want %d", got, want) + } +} + +func TestPipeline_ByteAccounting_ReturnsToZeroAfterProcess(t *testing.T) { + // Every reservation taken at Submit must be released by process() — + // including the panic path, or the counter ratchets up until the cap + // rejects all traffic. + w := &fakeWriter{} + p := NewPipeline(w, nil, PipelineConfig{Capacity: 10, Workers: 1}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + p.Start(ctx) + t.Cleanup(p.Stop) + + bad := healthyBatch() + bad.SpanCallback = func(_ storage.Span) { panic("boom") } + good := healthyBatch() + + if err := p.Submit(bad); err != nil { + t.Fatalf("submit bad: %v", err) + } + if err := p.Submit(good); err != nil { + t.Fatalf("submit good: %v", err) + } + if !waitFor(t, 5*time.Second, func() bool { return p.Stats().Processed >= 2 }) { + t.Fatalf("worker did not process both batches — Processed=%d", p.Stats().Processed) + } + if !waitFor(t, 5*time.Second, func() bool { return p.Stats().QueueBytes == 0 }) { + t.Fatalf("QueueBytes = %d after drain, want 0 (panic path leaked its reservation)", p.Stats().QueueBytes) + } +} + +func TestPipeline_QueueBytesGaugeTracksReservations(t *testing.T) { + // The Prometheus gauge must follow the reservation lifecycle: up on + // enqueue, back to zero after the worker drains. Built from a bare + // prometheus.NewGauge (not telemetry.New()) so the default registry + // isn't double-registered across tests. + g := prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_ingest_pipeline_queue_bytes"}) + m := &telemetry.Metrics{IngestPipelineQueueBytes: g} + p := NewPipeline(&fakeWriter{}, m, PipelineConfig{Capacity: 10, Workers: 1}) + + b := healthyBatch() + if err := p.Submit(b); err != nil { + t.Fatalf("submit: %v", err) + } + if got, want := testutil.ToFloat64(g), float64(b.approxBytes()); got != want { + t.Errorf("gauge after enqueue = %v, want %v", got, want) + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + p.Start(ctx) + t.Cleanup(p.Stop) + if !waitFor(t, 5*time.Second, func() bool { return testutil.ToFloat64(g) == 0 }) { + t.Fatalf("gauge did not return to 0 after drain: %v", testutil.ToFloat64(g)) + } +} + +func TestPipeline_ByteAccounting_ReleasedOnWriterFailure(t *testing.T) { + // A failed BatchCreateAll drops the batch — its reservation must be + // released all the same. + w := &fakeWriter{traceErr: errors.New("db down")} + p := NewPipeline(w, nil, PipelineConfig{Capacity: 4, Workers: 1}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + p.Start(ctx) + t.Cleanup(p.Stop) + + if err := p.Submit(healthyBatch()); err != nil { + t.Fatalf("submit: %v", err) + } + if !waitFor(t, 5*time.Second, func() bool { return p.Stats().ProcessFailures >= 1 }) { + t.Fatalf("writer failure never surfaced") + } + if !waitFor(t, 5*time.Second, func() bool { return p.Stats().QueueBytes == 0 }) { + t.Fatalf("QueueBytes = %d after failed process, want 0", p.Stats().QueueBytes) + } +} diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go index 872ed05..cd2a160 100644 --- a/internal/telemetry/metrics.go +++ b/internal/telemetry/metrics.go @@ -113,9 +113,14 @@ type Metrics struct { // IngestPipelineQueueDepth — current queue depth, sampled on every Submit. // Labeled by signal so spikes can be attributed to traces vs logs. IngestPipelineQueueDepth *prometheus.GaugeVec + // IngestPipelineQueueBytes — approximate bytes held by queued batches. + // Reserved at Submit, released when a worker finishes the batch; the + // byte cap (INGEST_PIPELINE_MAX_BYTES) rejects submissions above it. + IngestPipelineQueueBytes prometheus.Gauge // IngestPipelineDroppedTotal — batches that did NOT reach the DB. // reason="soft_backpressure" — healthy batch dropped at >=90% fullness. // reason="queue_full" — batch rejected at 100% capacity (client got 429/RESOURCE_EXHAUSTED). + // reason="bytes_full" — batch rejected at the byte cap (even priority batches). IngestPipelineDroppedTotal *prometheus.CounterVec // HTTPOTLPThrottledTotal — count of HTTP 429s issued by the OTLP HTTP @@ -355,6 +360,10 @@ func New() *Metrics { Name: "otelcontext_ingest_pipeline_queue_depth", Help: "Current depth of the async ingest pipeline queue, by signal type.", }, []string{"signal"}), + IngestPipelineQueueBytes: promauto.NewGauge(prometheus.GaugeOpts{ + Name: "otelcontext_ingest_pipeline_queue_bytes", + Help: "Approximate bytes held by batches in the async ingest queue.", + }), HTTPOTLPThrottledTotal: promauto.NewCounterVec(prometheus.CounterOpts{ Name: "otelcontext_http_otlp_throttled_total", Help: "OTLP HTTP requests rejected with 429 because the async ingest pipeline is at capacity, by signal type.", diff --git a/main.go b/main.go index ee58982..9d6c31e 100644 --- a/main.go +++ b/main.go @@ -461,6 +461,7 @@ func main() { ingestPipeline = ingest.NewPipeline(repo, metrics, ingest.PipelineConfig{ Capacity: cfg.IngestPipelineQueueSize, Workers: cfg.IngestPipelineWorkers, + MaxBytes: int64(cfg.IngestPipelineMaxBytes), }) ingestPipeline.SetPerTenantCap(cfg.IngestPipelinePerTenantCap) From 499303727bff306db63a734925f06b1295d4f2e2 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:08:14 +0000 Subject: [PATCH 17/61] feat(graphrag): evict idle tenant store slices from the refresh tick (P1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenants with no ingest event or query for GRAPHRAG_TENANT_IDLE_TTL (default 24h) are dropped from the coordinator map on the 60s refresh tick, freeing all four per-tenant stores at once. storage.DefaultTenantID is immune. Eviction is self-healing: rebuildAllTenantsFromDB re-creates genuinely active tenants from recent spans within one tick, and any ingest/query re-creates the slice instantly with a fresh idle window. The DB rebuild path deliberately uses tenantStoresNoTouch so 60s bookkeeping alone cannot keep a dormant tenant alive. New counter otelcontext_graphrag_tenants_evicted_total in internal/telemetry/metrics.go (shared file — promauto pattern). Co-Authored-By: Claude Fable 5 --- internal/graphrag/refresh.go | 40 ++++++- internal/graphrag/tenant_eviction_test.go | 130 ++++++++++++++++++++++ internal/telemetry/metrics.go | 10 ++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 internal/graphrag/tenant_eviction_test.go diff --git a/internal/graphrag/refresh.go b/internal/graphrag/refresh.go index 14b5974..acdcc1d 100644 --- a/internal/graphrag/refresh.go +++ b/internal/graphrag/refresh.go @@ -36,6 +36,9 @@ func (g *GraphRAG) refreshLoop(ctx context.Context) { slog.Debug("GraphRAG pruned expired traces/spans", "count", pruned) } g.pruneOldAnomalies() + if evicted := g.evictIdleTenants(); evicted > 0 { + slog.Info("GraphRAG evicted idle tenant stores", "count", evicted) + } // Bound the investigation cooldown map. The 10m cutoff is 2× // the cooldown window (5m) — it retains entries through the // active suppression plus a grace period. This assumes the @@ -49,6 +52,39 @@ func (g *GraphRAG) refreshLoop(ctx context.Context) { } } +// evictIdleTenants drops tenant store slices whose lastAccess is older than +// tenantIdleTTL (GRAPHRAG_TENANT_IDLE_TTL, default 24h). The default tenant +// is never evicted — single-tenant installs route everything through it. +// Eviction is self-healing: a tenant that is actually active reappears +// within one refresh tick (rebuildAllTenantsFromDB re-discovers it from +// recent spans) or instantly on the next ingest event / query via +// storesForTenant. Returns the number of slices evicted. +func (g *GraphRAG) evictIdleTenants() int { + if g.tenantIdleTTL <= 0 { + return 0 + } + cutoff := time.Now().Add(-g.tenantIdleTTL).UnixNano() + g.tenantsMu.Lock() + evicted := 0 + for tenant, st := range g.tenants { + if tenant == storage.DefaultTenantID { + continue + } + if st.lastAccess.Load() < cutoff { + delete(g.tenants, tenant) + evicted++ + } + } + g.tenantsMu.Unlock() + if evicted > 0 { + g.tenantsEvicted.Add(int64(evicted)) + if g.metrics != nil && g.metrics.GraphRAGTenantsEvictedTotal != nil { + g.metrics.GraphRAGTenantsEvictedTotal.Add(float64(evicted)) + } + } + return evicted +} + // snapshotLoop persists Drain templates on the configured cadence so a // restart recovers the learned templates instead of rebuilding from scratch. // @@ -147,7 +183,9 @@ func (g *GraphRAG) rebuildFromDBForTenant(_ context.Context, tenant string, sinc StartTime time.Time } - stores := g.storesForTenant(tenant) + // NoTouch: a 60s bookkeeping rebuild must not refresh the idle-eviction + // clock, or dormant tenants would never reach GRAPHRAG_TENANT_IDLE_TTL. + stores := g.tenantStoresNoTouch(tenant) var rows []spanRow err := g.repo.DB(). diff --git a/internal/graphrag/tenant_eviction_test.go b/internal/graphrag/tenant_eviction_test.go new file mode 100644 index 0000000..1f6b080 --- /dev/null +++ b/internal/graphrag/tenant_eviction_test.go @@ -0,0 +1,130 @@ +package graphrag + +import ( + "context" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// staleNanos returns a unix-nano timestamp comfortably past any idle TTL +// used in these tests. +func staleNanos() int64 { + return time.Now().Add(-48 * time.Hour).UnixNano() +} + +// TestEvictIdleTenants_EvictsStaleKeepsDefault proves the three eviction +// invariants: an idle tenant is removed, the default tenant is immune no +// matter how stale, and a fresh tenant survives. +func TestEvictIdleTenants_EvictsStaleKeepsDefault(t *testing.T) { + g := New(nil, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + stale := g.storesForTenant("tenant-idle") + fresh := g.storesForTenant("tenant-active") + def := g.storesForTenant(storage.DefaultTenantID) + + stale.lastAccess.Store(staleNanos()) + def.lastAccess.Store(staleNanos()) // default tenant must survive anyway + _ = fresh // lastAccess just touched by storesForTenant + + if got := g.evictIdleTenants(); got != 1 { + t.Fatalf("evictIdleTenants = %d, want 1", got) + } + + g.tenantsMu.RLock() + _, idleAlive := g.tenants["tenant-idle"] + _, activeAlive := g.tenants["tenant-active"] + _, defAlive := g.tenants[storage.DefaultTenantID] + g.tenantsMu.RUnlock() + + if idleAlive { + t.Errorf("idle tenant still resident after eviction") + } + if !activeAlive { + t.Errorf("active tenant was evicted") + } + if !defAlive { + t.Errorf("default tenant was evicted — must be immune") + } + if got := g.TenantsEvictedCount(); got != 1 { + t.Errorf("TenantsEvictedCount = %d, want 1", got) + } +} + +// TestEvictIdleTenants_ReactivationCreatesFreshSlice proves eviction is +// self-healing: the next storesForTenant call re-creates the slice with a +// fresh idle window, so a returning tenant is not insta-evicted. +func TestEvictIdleTenants_ReactivationCreatesFreshSlice(t *testing.T) { + g := New(nil, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + old := g.storesForTenant("tenant-a") + old.lastAccess.Store(staleNanos()) + if got := g.evictIdleTenants(); got != 1 { + t.Fatalf("evictIdleTenants = %d, want 1", got) + } + + reborn := g.storesForTenant("tenant-a") + if reborn == old { + t.Fatalf("storesForTenant returned the evicted slice — expected a fresh one") + } + if g.evictIdleTenants() != 0 { + t.Fatalf("freshly re-created tenant must not be evicted") + } +} + +// TestEvictIdleTenants_DisabledWhenNegative proves a negative TenantIdleTTL +// turns eviction off entirely. +func TestEvictIdleTenants_DisabledWhenNegative(t *testing.T) { + cfg := DefaultConfig() + cfg.TenantIdleTTL = -1 + g := New(nil, nil, nil, cfg) + t.Cleanup(g.Stop) + + g.storesForTenant("tenant-b").lastAccess.Store(staleNanos()) + if got := g.evictIdleTenants(); got != 0 { + t.Fatalf("eviction ran with TenantIdleTTL<0: evicted %d", got) + } +} + +// TestRebuild_DoesNotRefreshIdleClock proves the 60s DB rebuild cannot keep +// a dormant tenant alive: rebuildFromDBForTenant goes through +// tenantStoresNoTouch, so lastAccess stays stale and the next eviction pass +// still fires. Without this, every known tenant would be touched every tick +// and GRAPHRAG_TENANT_IDLE_TTL would never elapse. +func TestRebuild_DoesNotRefreshIdleClock(t *testing.T) { + repo := newTestRepo(t) + g := New(repo, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + st := g.storesForTenant("tenant-dormant") + stale := staleNanos() + st.lastAccess.Store(stale) + + // Rebuild walks known tenants even when the DB has no rows for them. + g.rebuildAllTenantsFromDB(context.Background()) + + if got := st.lastAccess.Load(); got != stale { + t.Fatalf("DB rebuild refreshed lastAccess (%d → %d) — dormant tenant would never be evicted", stale, got) + } + if got := g.evictIdleTenants(); got != 1 { + t.Fatalf("evictIdleTenants after rebuild = %d, want 1", got) + } +} + +// TestStoresForTenant_RefreshesLastAccess proves the fast path (slice already +// resident) refreshes the idle clock on every call. +func TestStoresForTenant_RefreshesLastAccess(t *testing.T) { + g := New(nil, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + st := g.storesForTenant("tenant-c") + st.lastAccess.Store(staleNanos()) + + g.storesForTenant("tenant-c") // fast path + if age := time.Since(time.Unix(0, st.lastAccess.Load())); age > time.Minute { + t.Fatalf("lastAccess not refreshed on fast path: %v old", age) + } +} diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go index 872ed05..5861c45 100644 --- a/internal/telemetry/metrics.go +++ b/internal/telemetry/metrics.go @@ -98,6 +98,12 @@ type Metrics struct { // --- GraphRAG overflow --- GraphRAGEventsDroppedTotal *prometheus.CounterVec + // GraphRAGTenantsEvictedTotal counts tenant store slices evicted after + // exceeding GRAPHRAG_TENANT_IDLE_TTL. The default tenant is never + // evicted; a steady non-zero rate on a single-tenant install means + // rogue tenant IDs are reaching ingest. + GraphRAGTenantsEvictedTotal prometheus.Counter + // --- In-memory store census (OOM-survival work) --- // GraphRAGStoreEntities — live node counts per entity kind across tenants // (tenants|services|operations|traces|spans|log_clusters|metrics|anomalies). @@ -334,6 +340,10 @@ func New() *Metrics { Name: "otelcontext_graphrag_events_dropped_total", Help: "Events dropped because the GraphRAG event channel was full.", }, []string{"signal"}), + GraphRAGTenantsEvictedTotal: promauto.NewCounter(prometheus.CounterOpts{ + Name: "otelcontext_graphrag_tenants_evicted_total", + Help: "Tenant store slices evicted after exceeding the idle TTL (GRAPHRAG_TENANT_IDLE_TTL). The default tenant is never evicted.", + }), GraphRAGStoreEntities: promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "otelcontext_graphrag_store_entities", Help: "Live GraphRAG node counts across tenants, by entity kind (tenants|services|operations|traces|spans|log_clusters|metrics|anomalies).", From f018d8c568b670cc73a58b976b7e566815326827 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:10:43 +0000 Subject: [PATCH 18/61] perf(retention): retire automatic daily full VACUUM on SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily maintenance pass ran a full VACUUM, which holds a whole-file exclusive lock for 10-60 minutes on multi-GB databases (admin_handlers.go documents this for drop_fts) — ingest stalls into a 429 storm and the async pipeline queue/RAM spiral. The SQLite branch now keeps PRAGMA optimize and runs PRAGMA incremental_vacuum(10000) instead, releasing up to 10k freelist pages per day without the exclusive lock. Fresh databases are provisioned auto_vacuum=INCREMENTAL by NewDatabase; on legacy auto_vacuum=NONE files the pragma is a harmless no-op. incremental_vacuum is a row-returning pragma that frees one page per step — Exec steps it once on glebarez/modernc, so the statement is queried and drained (drainQuery) to reclaim the full batch. Operators can restore the legacy behavior with RETENTION_FULL_VACUUM=true (new config field, default false); POST /api/admin/vacuum remains for on-demand full VACUUM. Postgres/MySQL maintenance is byte-identical. Co-Authored-By: Claude Fable 5 --- .env.example | 4 + internal/config/config.go | 9 ++ internal/config/config_test.go | 24 ++++++ internal/storage/retention.go | 58 ++++++++++++- internal/storage/retention_test.go | 131 +++++++++++++++++++++++++++++ main.go | 4 +- 6 files changed, 225 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index bfab1b6..daff5bc 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,10 @@ # ---- Retention -------------------------------------------------------------- # HOT_RETENTION_DAYS=7 # RetentionScheduler purge cutoff. Range 1..36500. Set explicitly in prod. +# RETENTION_FULL_VACUUM=false # Daily SQLite maintenance runs PRAGMA incremental_vacuum(10000) by default. +# # true restores the legacy daily full VACUUM (exclusive lock, 10-60 min on +# # multi-GB DBs — expect a 429 storm while it holds the writer lock). +# # On-demand full VACUUM: POST /api/admin/vacuum. Ignored on non-SQLite drivers. # ---- OTel Self-Instrumentation ---------------------------------------------- # OTEL_EXPORTER_OTLP_ENDPOINT= # When set, OtelContext exports its own spans to this OTLP gRPC endpoint. diff --git a/internal/config/config.go b/internal/config/config.go index a36aa25..f643c99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,6 +64,14 @@ type Config struct { RetentionBatchSize int RetentionBatchSleepMs int + // RetentionFullVacuum restores the daily full VACUUM during SQLite + // maintenance. Default false: the daily pass runs + // PRAGMA incremental_vacuum(10000) instead, because a full VACUUM holds + // an exclusive lock for 10-60 minutes on multi-GB files and starves + // ingest into a 429 storm. On-demand full VACUUM remains available via + // POST /api/admin/vacuum. Ignored on non-SQLite drivers. + RetentionFullVacuum bool + // TSDB TSDBRingBufferDuration string // e.g. "1h" @@ -259,6 +267,7 @@ func Load(customPath string) (*Config, error) { HotRetentionDays: getEnvInt("HOT_RETENTION_DAYS", 7), RetentionBatchSize: getEnvInt("RETENTION_BATCH_SIZE", 50000), RetentionBatchSleepMs: getEnvInt("RETENTION_BATCH_SLEEP_MS", 1), + RetentionFullVacuum: getEnvBool("RETENTION_FULL_VACUUM", false), // TSDB TSDBRingBufferDuration: getEnv("TSDB_RING_BUFFER_DURATION", "1h"), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bf57847..24ff650 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -137,6 +137,30 @@ func TestValidate_TLS_ReadableFilesOK(t *testing.T) { } } +func TestLoad_RetentionFullVacuum(t *testing.T) { + // Default: off — the daily SQLite maintenance must not full-VACUUM. + t.Setenv("RETENTION_FULL_VACUUM", "") + if err := os.Unsetenv("RETENTION_FULL_VACUUM"); err != nil { + t.Fatalf("unset: %v", err) + } + cfg, err := Load("__no_such_env_file__") + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.RetentionFullVacuum { + t.Fatal("RetentionFullVacuum must default to false") + } + + t.Setenv("RETENTION_FULL_VACUUM", "true") + cfg, err = Load("__no_such_env_file__") + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.RetentionFullVacuum { + t.Fatal("RETENTION_FULL_VACUUM=true not loaded") + } +} + func TestLoad_EnvVars_TLS_APIKey_OTel_Tenant(t *testing.T) { t.Setenv("TLS_CERT_FILE", "") t.Setenv("TLS_KEY_FILE", "") diff --git a/internal/storage/retention.go b/internal/storage/retention.go index 0733695..ef7af68 100644 --- a/internal/storage/retention.go +++ b/internal/storage/retention.go @@ -2,6 +2,7 @@ package storage import ( "context" + "database/sql" "fmt" "log/slog" "strings" @@ -12,7 +13,8 @@ import ( // RetentionScheduler periodically enforces hot-DB retention and runs DB maintenance. // On startup and hourly thereafter it deletes rows older than retentionDays. -// Daily it runs driver-appropriate maintenance (VACUUM ANALYZE / OPTIMIZE / VACUUM). +// Daily it runs driver-appropriate maintenance (VACUUM ANALYZE / OPTIMIZE / +// PRAGMA optimize + incremental_vacuum). type RetentionScheduler struct { repo *Repository retentionDays int @@ -21,6 +23,14 @@ type RetentionScheduler struct { purgeBatchSize int purgeBatchSleep time.Duration + // fullVacuum restores the pre-2026-06 daily full VACUUM on SQLite + // (RETENTION_FULL_VACUUM=true). Off by default: VACUUM holds an exclusive + // lock for 10-60 minutes on multi-GB files (see handleDropFTS in + // internal/api/admin_handlers.go), starving ingest into a 429 storm. The + // default maintenance runs PRAGMA incremental_vacuum(10000) instead; + // on-demand full VACUUM remains available via POST /api/admin/vacuum. + fullVacuum bool + // started is an atomic so a fast-path Stop() before Start() is lock-free. // mu serializes the Start/Stop transition itself (protects cancel + done). started atomic.Bool @@ -62,6 +72,36 @@ func NewRetentionScheduler(repo *Repository, retentionDays, batchSize int, batch // because a previous run was still executing. Intended for tests and telemetry. func (r *RetentionScheduler) SkippedRuns() int64 { return r.skippedRuns.Load() } +// SetFullVacuum opts the daily SQLite maintenance back into a full VACUUM +// (wired from RETENTION_FULL_VACUUM). Call before Start; not synchronized. +func (r *RetentionScheduler) SetFullVacuum(enabled bool) { r.fullVacuum = enabled } + +// sqliteVacuumStatement returns the page-reclaim statement for the daily +// SQLite maintenance pass. Default is incremental_vacuum: it releases up to +// 10k freelist pages per tick without the whole-file exclusive lock a full +// VACUUM takes (it no-ops harmlessly on auto_vacuum=NONE files — fresh +// deploys are provisioned auto_vacuum=INCREMENTAL by NewDatabase). +func sqliteVacuumStatement(fullVacuum bool) string { + if fullVacuum { + return "VACUUM" + } + return "PRAGMA incremental_vacuum(10000)" +} + +// drainQuery executes a row-returning statement and discards every row. +// PRAGMA incremental_vacuum performs its work per step (one freed page per +// row), so the cursor must be walked to completion for the reclaim to happen. +func drainQuery(ctx context.Context, db *sql.DB, query string) error { + rows, err := db.QueryContext(ctx, query) //nolint:sqlclosecheck // closed below; no rows.Close() lint seam for drain loops + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + for rows.Next() { //nolint:revive // intentionally empty: stepping is the side effect + } + return rows.Err() +} + // Start launches the scheduler goroutine. It runs an initial purge immediately. // Idempotent and race-free: atomic CAS elects the first caller, and mu // publishes cancel+done before any concurrent Stop can observe started=true. @@ -445,11 +485,21 @@ func (r *RetentionScheduler) runMaintenance(ctx context.Context) { slog.Error("retention: PRAGMA optimize failed", "error", err) maintFailed = true } - if _, err := sqlDB.ExecContext(ctx, "VACUUM"); err != nil { - slog.Error("retention: VACUUM failed", "error", err) + vacuumSQL := sqliteVacuumStatement(r.fullVacuum) + var vacErr error + if r.fullVacuum { + _, vacErr = sqlDB.ExecContext(ctx, vacuumSQL) + } else { + // incremental_vacuum is a row-returning pragma that frees one page + // per step — Exec steps it once on this driver, so it must be + // queried and drained to reclaim the full batch. + vacErr = drainQuery(ctx, sqlDB, vacuumSQL) + } + if vacErr != nil { + slog.Error("retention: vacuum step failed", "statement", vacuumSQL, "error", vacErr) maintFailed = true } - // SQLite VACUUM is whole-DB; record a single observation under "all". + // SQLite maintenance is whole-DB; record a single observation under "all". observe("all", time.Since(start)) } slog.Info("retention maintenance complete", "driver", driver) diff --git a/internal/storage/retention_test.go b/internal/storage/retention_test.go index afc4676..f3a8185 100644 --- a/internal/storage/retention_test.go +++ b/internal/storage/retention_test.go @@ -2,9 +2,12 @@ package storage import ( "context" + "path/filepath" "sync" "testing" "time" + + "gorm.io/gorm" ) func TestRetentionScheduler_StopBeforeStart_NoDeadlock(t *testing.T) { @@ -99,6 +102,134 @@ func TestRetentionScheduler_MaintenanceVacuumsSQLite(t *testing.T) { } } +// newFileTestRepo builds a Repository backed by a temp-file SQLite DB. The +// vacuum-mode tests below assert on PRAGMA freelist_count, which needs a real +// database file rather than :memory:. +func newFileTestRepo(t *testing.T) *Repository { + t.Helper() + t.Setenv("LOG_FTS_ENABLED", "false") + db, err := NewDatabase("sqlite", filepath.Join(t.TempDir(), "retention.db")) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if err := AutoMigrateModels(db, "sqlite"); err != nil { + t.Fatalf("AutoMigrateModels: %v", err) + } + repo := &Repository{db: db, driver: "sqlite"} + t.Cleanup(func() { _ = repo.Close() }) + return repo +} + +func freelistCount(t *testing.T, db *gorm.DB) int64 { + t.Helper() + var n int64 + if err := db.Raw("PRAGMA freelist_count").Scan(&n).Error; err != nil { + t.Fatalf("PRAGMA freelist_count: %v", err) + } + return n +} + +// forceAutoVacuumNone rewrites the database file with auto_vacuum=NONE so +// PRAGMA incremental_vacuum becomes a guaranteed no-op — the file shape every +// pre-2026-06 deployment has. VACUUM must run on the raw *sql.DB (GORM's Exec +// wraps statements in an implicit tx, and VACUUM refuses to run inside one). +func forceAutoVacuumNone(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("raw sql.DB: %v", err) + } + if _, err := sqlDB.Exec("PRAGMA auto_vacuum=NONE"); err != nil { + t.Fatalf("PRAGMA auto_vacuum=NONE: %v", err) + } + if _, err := sqlDB.Exec("VACUUM"); err != nil { + t.Fatalf("VACUUM (rebuild to NONE): %v", err) + } + var av int64 + if err := db.Raw("PRAGMA auto_vacuum").Scan(&av).Error; err != nil || av != 0 { + t.Fatalf("auto_vacuum=%d err=%v, want 0 (NONE)", av, err) + } +} + +// seedAndDeleteLogs fills the freelist: insert rows, then bulk-delete them so +// the freed pages sit on the freelist awaiting a vacuum of either flavor. +func seedAndDeleteLogs(t *testing.T, db *gorm.DB) { + t.Helper() + seedLogs(t, db, 2000, time.Now().UTC(), "vacuum-svc") + if err := db.Exec("DELETE FROM logs").Error; err != nil { + t.Fatalf("delete logs: %v", err) + } + if freelistCount(t, db) == 0 { + t.Fatal("test setup broken: bulk delete left an empty freelist") + } +} + +func TestSQLiteVacuumStatement(t *testing.T) { + if got := sqliteVacuumStatement(false); got != "PRAGMA incremental_vacuum(10000)" { + t.Fatalf("default statement = %q, want incremental_vacuum", got) + } + if got := sqliteVacuumStatement(true); got != "VACUUM" { + t.Fatalf("full-vacuum statement = %q, want VACUUM", got) + } +} + +// TestRetentionScheduler_MaintenanceDefaultRunsIncrementalVacuum proves the +// default daily maintenance actually executes incremental_vacuum: on an +// auto_vacuum=INCREMENTAL file (what NewDatabase provisions on fresh deploys) +// freed pages stay on the freelist until incremental_vacuum releases them, so +// an empty freelist after runMaintenance means the statement ran. +func TestRetentionScheduler_MaintenanceDefaultRunsIncrementalVacuum(t *testing.T) { + repo := newFileTestRepo(t) + var av int64 + if err := repo.db.Raw("PRAGMA auto_vacuum").Scan(&av).Error; err != nil || av != 2 { + t.Fatalf("precondition: fresh DB should be auto_vacuum=INCREMENTAL, got %d (err=%v)", av, err) + } + seedAndDeleteLogs(t, repo.db) + + r := NewRetentionScheduler(repo, 7, 10_000, 0) + r.runMaintenance(context.Background()) + + if n := freelistCount(t, repo.db); n != 0 { + t.Fatalf("freelist=%d after default maintenance; incremental_vacuum should have released all pages", n) + } +} + +// TestRetentionScheduler_MaintenanceDefaultSkipsFullVacuum proves the default +// daily maintenance no longer runs a full VACUUM: on an auto_vacuum=NONE file +// (every pre-2026-06 deployment) incremental_vacuum is a no-op, so the +// freelist survives — a full VACUUM would have rebuilt the file and emptied it. +func TestRetentionScheduler_MaintenanceDefaultSkipsFullVacuum(t *testing.T) { + repo := newFileTestRepo(t) + forceAutoVacuumNone(t, repo.db) + seedAndDeleteLogs(t, repo.db) + + r := NewRetentionScheduler(repo, 7, 10_000, 0) + r.runMaintenance(context.Background()) + + // PRAGMA optimize may allocate a few stats pages from the freelist, so + // assert "not fully reclaimed" rather than an exact count. + if n := freelistCount(t, repo.db); n == 0 { + t.Fatal("freelist emptied — a full VACUUM ran during default maintenance; expected incremental_vacuum no-op") + } +} + +// TestRetentionScheduler_MaintenanceFullVacuumWhenEnabled proves the +// RETENTION_FULL_VACUUM opt-in restores the legacy behavior on the same +// auto_vacuum=NONE file shape where incremental_vacuum provably no-ops. +func TestRetentionScheduler_MaintenanceFullVacuumWhenEnabled(t *testing.T) { + repo := newFileTestRepo(t) + forceAutoVacuumNone(t, repo.db) + seedAndDeleteLogs(t, repo.db) + + r := NewRetentionScheduler(repo, 7, 10_000, 0) + r.SetFullVacuum(true) + r.runMaintenance(context.Background()) + + if n := freelistCount(t, repo.db); n != 0 { + t.Fatalf("freelist=%d; RETENTION_FULL_VACUUM=true must run a full VACUUM and empty the freelist", n) + } +} + func TestRetentionScheduler_NoDataNoError(t *testing.T) { repo := newTestRepo(t) r := NewRetentionScheduler(repo, 7, 10_000, 5*time.Millisecond) diff --git a/main.go b/main.go index ee58982..4c8f7b2 100644 --- a/main.go +++ b/main.go @@ -212,7 +212,8 @@ func main() { } slog.Info("💾 Storage initialized", "driver", cfg.DBDriver) - // 2a. Retention scheduler: hourly batched purge + daily VACUUM/ANALYZE. + // 2a. Retention scheduler: hourly batched purge + daily maintenance + // (VACUUM ANALYZE / OPTIMIZE / PRAGMA optimize + incremental_vacuum). ctxRetention, cancelRetention := context.WithCancel(context.Background()) retention := storage.NewRetentionScheduler( repo, @@ -220,6 +221,7 @@ func main() { cfg.RetentionBatchSize, time.Duration(cfg.RetentionBatchSleepMs)*time.Millisecond, ) + retention.SetFullVacuum(cfg.RetentionFullVacuum) retention.Start(ctxRetention) slog.Info("🧹 Retention scheduler started", "retention_days", cfg.HotRetentionDays) From c54aa23d7da39f0e698513e713808b38ae91d401 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:12:04 +0000 Subject: [PATCH 19/61] perf(graphrag): bound SignalStore and the anomaly correlation walk (P1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignalStore.Prune (modeled on TraceStore.Prune) runs on the 60s refresh tick: MetricNodes idle >24h are dropped, the oldest-LastSeen overflow past 2000/tenant is evicted, each removal takes its MEASURED_BY edge, and remaining edges are swept by UpdatedAt. The metric/log-cluster upsert paths now refresh edge UpdatedAt on every hit so the sweep never severs a live correlation (previously edges kept their creation time forever). LogClusters stay untouched — Drain bounds them upstream. AnomaliesSince gains a bounded sibling AnomaliesSinceLimit; correlateWithRecent walks at most 1000 recent anomalies per detection so a pathological backlog cannot turn each 10s tick into an O(N) scan plus O(N) PRECEDED_BY fan-out. Co-Authored-By: Claude Fable 5 --- internal/graphrag/anomaly.go | 8 +- internal/graphrag/anomaly_walk_test.go | 65 +++++++++++++ internal/graphrag/refresh.go | 15 +++ internal/graphrag/signal_prune_test.go | 124 +++++++++++++++++++++++++ internal/graphrag/store.go | 96 ++++++++++++++++--- 5 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 internal/graphrag/anomaly_walk_test.go create mode 100644 internal/graphrag/signal_prune_test.go diff --git a/internal/graphrag/anomaly.go b/internal/graphrag/anomaly.go index ade6109..05f3bef 100644 --- a/internal/graphrag/anomaly.go +++ b/internal/graphrag/anomaly.go @@ -98,11 +98,17 @@ func (g *GraphRAG) detectAnomaliesForTenant(ctx context.Context, tenant string, } } +// maxCorrelationWalk bounds how many recent anomalies correlateWithRecent +// considers per detection. Stable per-(service,type) IDs already keep the +// store small in steady state; this is the backstop against a pathological +// backlog turning every 10s tick into an O(N) scan + edge fan-out. +const maxCorrelationWalk = 1000 + // correlateWithRecent links an anomaly to other anomalies within ±30s in the // same tenant's AnomalyStore. func correlateWithRecent(stores *tenantStores, anomaly AnomalyNode) { window := 30 * time.Second - recent := stores.anomalies.AnomaliesSince(anomaly.Timestamp.Add(-window)) + recent := stores.anomalies.AnomaliesSinceLimit(anomaly.Timestamp.Add(-window), maxCorrelationWalk) for _, prev := range recent { if prev.ID == anomaly.ID { continue diff --git a/internal/graphrag/anomaly_walk_test.go b/internal/graphrag/anomaly_walk_test.go new file mode 100644 index 0000000..c9438d7 --- /dev/null +++ b/internal/graphrag/anomaly_walk_test.go @@ -0,0 +1,65 @@ +package graphrag + +import ( + "fmt" + "testing" + "time" +) + +// TestAnomaliesSinceLimit proves the bounded variant caps the walk while the +// unlimited spellings (n<=0 and the legacy AnomaliesSince) return everything. +func TestAnomaliesSinceLimit(t *testing.T) { + as := newAnomalyStore() + now := time.Now() + for i := 0; i < 10; i++ { + as.AddAnomaly(AnomalyNode{ + ID: fmt.Sprintf("anom-%d", i), + Service: "svc", + Timestamp: now, + }) + } + since := now.Add(-time.Minute) + + if got := len(as.AnomaliesSinceLimit(since, 3)); got != 3 { + t.Errorf("AnomaliesSinceLimit(3) returned %d, want 3", got) + } + if got := len(as.AnomaliesSinceLimit(since, 0)); got != 10 { + t.Errorf("AnomaliesSinceLimit(0) returned %d, want 10 (unlimited)", got) + } + if got := len(as.AnomaliesSince(since)); got != 10 { + t.Errorf("AnomaliesSince returned %d, want 10", got) + } +} + +// TestCorrelateWithRecent_BoundedWalk proves correlateWithRecent fans out at +// most maxCorrelationWalk PRECEDED_BY edges per detection, no matter how +// large the anomaly backlog in the ±30s window is. +func TestCorrelateWithRecent_BoundedWalk(t *testing.T) { + stores := newTenantStores(time.Hour, 0) + now := time.Now() + + for i := 0; i < maxCorrelationWalk+200; i++ { + stores.anomalies.AddAnomaly(AnomalyNode{ + ID: fmt.Sprintf("anom-%d", i), + Service: "svc", + Timestamp: now, + }) + } + + fresh := AnomalyNode{ID: "anom-fresh", Service: "svc", Timestamp: now} + stores.anomalies.AddAnomaly(fresh) + correlateWithRecent(stores, fresh) + + edges := 0 + for _, e := range stores.anomalies.Edges { + if e.Type == EdgePrecededBy && e.FromID == "anom-fresh" { + edges++ + } + } + if edges == 0 { + t.Fatalf("no PRECEDED_BY edges minted — correlation broken") + } + if edges > maxCorrelationWalk { + t.Fatalf("correlateWithRecent minted %d edges, want <= %d", edges, maxCorrelationWalk) + } +} diff --git a/internal/graphrag/refresh.go b/internal/graphrag/refresh.go index acdcc1d..0143db8 100644 --- a/internal/graphrag/refresh.go +++ b/internal/graphrag/refresh.go @@ -8,6 +8,15 @@ import ( "github.com/RandomCodeSpace/otelcontext/internal/storage" ) +const ( + // signalRetention bounds MetricNodes and signal-store edges: anything + // not refreshed within this window is swept on the refresh tick. + signalRetention = 24 * time.Hour + // maxMetricsPerTenant caps each tenant's SignalStore metric map; past + // the cap the oldest-LastSeen series are evicted first. + maxMetricsPerTenant = 2000 +) + // refreshLoop periodically rebuilds/merges from DB and prunes stale data. // Work is sharded per tenant: on each tick we snapshot the coordinator's // tenant map, then rebuild and prune each slice under its own lock. Tenants @@ -29,12 +38,18 @@ func (g *GraphRAG) refreshLoop(ctx context.Context) { case <-ticker.C: g.rebuildAllTenantsFromDB(ctx) pruned := 0 + prunedMetrics := 0 + signalCutoff := time.Now().Add(-signalRetention) for _, stores := range g.snapshotTenants() { pruned += stores.traces.Prune() + prunedMetrics += stores.signals.Prune(signalCutoff, maxMetricsPerTenant) } if pruned > 0 { slog.Debug("GraphRAG pruned expired traces/spans", "count", pruned) } + if prunedMetrics > 0 { + slog.Debug("GraphRAG pruned stale/over-cap metrics", "count", prunedMetrics) + } g.pruneOldAnomalies() if evicted := g.evictIdleTenants(); evicted > 0 { slog.Info("GraphRAG evicted idle tenant stores", "count", evicted) diff --git a/internal/graphrag/signal_prune_test.go b/internal/graphrag/signal_prune_test.go new file mode 100644 index 0000000..925f07d --- /dev/null +++ b/internal/graphrag/signal_prune_test.go @@ -0,0 +1,124 @@ +package graphrag + +import ( + "fmt" + "testing" + "time" +) + +// TestSignalStore_Prune_DropsStaleMetricsKeepsFresh proves the TTL half of +// the SignalStore bound: metrics idle past the cutoff are removed together +// with their MEASURED_BY edge, while a fresh metric and its edge survive. +func TestSignalStore_Prune_DropsStaleMetricsKeepsFresh(t *testing.T) { + ss := newSignalStore() + now := time.Now() + stale := now.Add(-48 * time.Hour) + + ss.UpsertMetric("cpu", "old-svc", 1.0, stale) + ss.UpsertMetric("cpu", "new-svc", 1.0, now) + + if got := ss.Prune(now.Add(-24*time.Hour), 0); got != 1 { + t.Fatalf("Prune removed %d metrics, want 1", got) + } + if _, ok := ss.Metrics["cpu|old-svc"]; ok { + t.Errorf("stale metric survived Prune") + } + if _, ok := ss.Metrics["cpu|new-svc"]; !ok { + t.Errorf("fresh metric was pruned") + } + if _, ok := ss.Edges[edgeKey(EdgeMeasuredBy, "cpu|old-svc", "old-svc")]; ok { + t.Errorf("stale metric's MEASURED_BY edge survived Prune") + } + if _, ok := ss.Edges[edgeKey(EdgeMeasuredBy, "cpu|new-svc", "new-svc")]; !ok { + t.Errorf("fresh metric's MEASURED_BY edge was swept") + } +} + +// TestSignalStore_Prune_CapEvictsOldestFirst proves the cap half: when the +// map still exceeds maxMetrics after the TTL pass, the oldest-LastSeen +// entries go first and their edges go with them. +func TestSignalStore_Prune_CapEvictsOldestFirst(t *testing.T) { + ss := newSignalStore() + now := time.Now() + + // 5 fresh metrics with strictly ascending LastSeen. + for i := 0; i < 5; i++ { + ss.UpsertMetric(fmt.Sprintf("m%d", i), "svc", 1.0, now.Add(time.Duration(i)*time.Minute)) + } + + if got := ss.Prune(now.Add(-24*time.Hour), 2); got != 3 { + t.Fatalf("Prune removed %d metrics, want 3", got) + } + for i := 0; i < 3; i++ { + key := fmt.Sprintf("m%d|svc", i) + if _, ok := ss.Metrics[key]; ok { + t.Errorf("oldest metric %s survived cap eviction", key) + } + if _, ok := ss.Edges[edgeKey(EdgeMeasuredBy, key, "svc")]; ok { + t.Errorf("cap-evicted metric %s left a dangling MEASURED_BY edge", key) + } + } + for i := 3; i < 5; i++ { + if _, ok := ss.Metrics[fmt.Sprintf("m%d|svc", i)]; !ok { + t.Errorf("newest metric m%d was evicted — cap must drop oldest first", i) + } + } +} + +// TestSignalStore_Prune_SweepsStaleLogClusterEdgesOnly proves LogClusters +// themselves are untouched (they are Drain-bounded upstream) while their +// stale EMITTED_BY / LOGGED_DURING edges are swept. +func TestSignalStore_Prune_SweepsStaleLogClusterEdgesOnly(t *testing.T) { + ss := newSignalStore() + now := time.Now() + stale := now.Add(-48 * time.Hour) + + ss.UpsertLogCluster("c-old", "tmpl", "ERROR", "svc", stale) + ss.AddLoggedDuringEdge("c-old", "span-old", stale) + ss.UpsertLogCluster("c-new", "tmpl", "ERROR", "svc2", now) + + ss.Prune(now.Add(-24*time.Hour), 0) + + if len(ss.LogClusters) != 2 { + t.Fatalf("LogClusters len = %d, want 2 — Prune must not touch clusters", len(ss.LogClusters)) + } + if _, ok := ss.Edges[edgeKey(EdgeEmittedBy, "c-old", "svc")]; ok { + t.Errorf("stale EMITTED_BY edge survived") + } + if _, ok := ss.Edges[edgeKey(EdgeLoggedDuring, "c-old", "span-old")]; ok { + t.Errorf("stale LOGGED_DURING edge survived") + } + if _, ok := ss.Edges[edgeKey(EdgeEmittedBy, "c-new", "svc2")]; !ok { + t.Errorf("fresh EMITTED_BY edge was swept") + } +} + +// TestSignalStore_UpsertRefreshesEdgeTimestamps proves the upsert paths +// refresh edge UpdatedAt on every hit — without this, a continuously-active +// metric or log cluster would lose its correlation edge 24h after creation +// even though the node itself stays fresh. +func TestSignalStore_UpsertRefreshesEdgeTimestamps(t *testing.T) { + ss := newSignalStore() + now := time.Now() + stale := now.Add(-48 * time.Hour) + + // Created stale, then re-upserted fresh: edges must carry the fresh time. + ss.UpsertMetric("cpu", "svc", 1.0, stale) + ss.UpsertMetric("cpu", "svc", 2.0, now) + ss.UpsertLogCluster("c1", "tmpl", "ERROR", "svc", stale) + ss.UpsertLogCluster("c1", "tmpl", "ERROR", "svc", now) + ss.AddLoggedDuringEdge("c1", "span-1", stale) + ss.AddLoggedDuringEdge("c1", "span-1", now) + + ss.Prune(now.Add(-24*time.Hour), 0) + + for _, ek := range []string{ + edgeKey(EdgeMeasuredBy, "cpu|svc", "svc"), + edgeKey(EdgeEmittedBy, "c1", "svc"), + edgeKey(EdgeLoggedDuring, "c1", "span-1"), + } { + if _, ok := ss.Edges[ek]; !ok { + t.Errorf("edge %s swept despite a fresh upsert — UpdatedAt not refreshed", ek) + } + } +} diff --git a/internal/graphrag/store.go b/internal/graphrag/store.go index ed6a72b..9fb5856 100644 --- a/internal/graphrag/store.go +++ b/internal/graphrag/store.go @@ -2,6 +2,7 @@ package graphrag import ( "math" + "sort" "sync" "sync/atomic" "time" @@ -450,9 +451,14 @@ func (ss *SignalStore) UpsertLogClusterWithTemplate(id, template, severity, serv lc.LastSeen = ts } - // EMITTED_BY edge + // EMITTED_BY edge. Refresh UpdatedAt on every hit so Prune's edge sweep + // never severs a cluster that is still receiving logs. ek := edgeKey(EdgeEmittedBy, id, service) - if _, exists := ss.Edges[ek]; !exists { + if e, exists := ss.Edges[ek]; exists { + if ts.After(e.UpdatedAt) { + e.UpdatedAt = ts + } + } else { ss.Edges[ek] = &Edge{ Type: EdgeEmittedBy, FromID: id, @@ -466,13 +472,18 @@ func (ss *SignalStore) AddLoggedDuringEdge(clusterID, spanID string, ts time.Tim ek := edgeKey(EdgeLoggedDuring, clusterID, spanID) ss.mu.Lock() defer ss.mu.Unlock() - if _, exists := ss.Edges[ek]; !exists { - ss.Edges[ek] = &Edge{ - Type: EdgeLoggedDuring, - FromID: clusterID, - ToID: spanID, - UpdatedAt: ts, + if e, exists := ss.Edges[ek]; exists { + // Keep the correlation alive across Prune's edge sweep. + if ts.After(e.UpdatedAt) { + e.UpdatedAt = ts } + return + } + ss.Edges[ek] = &Edge{ + Type: EdgeLoggedDuring, + FromID: clusterID, + ToID: spanID, + UpdatedAt: ts, } } @@ -493,9 +504,15 @@ func (ss *SignalStore) UpsertMetric(metricName, service string, value float64, t LastSeen: ts, } ss.Metrics[key] = m - - // MEASURED_BY edge - ek := edgeKey(EdgeMeasuredBy, key, service) + } + // MEASURED_BY edge — created on the first sample, UpdatedAt refreshed on + // every sample so Prune's edge sweep never severs a live metric. + ek := edgeKey(EdgeMeasuredBy, key, service) + if e, exists := ss.Edges[ek]; exists { + if ts.After(e.UpdatedAt) { + e.UpdatedAt = ts + } + } else { ss.Edges[ek] = &Edge{ Type: EdgeMeasuredBy, FromID: key, @@ -529,6 +546,51 @@ func (ss *SignalStore) LogClustersForService(service string) []*LogClusterNode { return out } +// Prune bounds the SignalStore (modeled on TraceStore.Prune): MetricNodes +// whose LastSeen predates cutoff are removed; if the map still exceeds +// maxMetrics (<=0 = uncapped) the oldest-LastSeen overflow is evicted. Each +// removed metric takes its MEASURED_BY edge with it. Finally, Edges whose +// UpdatedAt predates cutoff are swept — the upsert paths refresh edge +// timestamps, so live correlations survive. LogClusters are NOT pruned +// here: they are bounded upstream by the Drain template LRU; only their +// stale edges go. Returns the number of metrics removed. +func (ss *SignalStore) Prune(cutoff time.Time, maxMetrics int) int { + ss.mu.Lock() + defer ss.mu.Unlock() + + pruned := 0 + for id, m := range ss.Metrics { + if m.LastSeen.Before(cutoff) { + delete(ss.Metrics, id) + delete(ss.Edges, edgeKey(EdgeMeasuredBy, id, m.Service)) + pruned++ + } + } + if maxMetrics > 0 && len(ss.Metrics) > maxMetrics { + type metricAge struct { + id string + service string + seen time.Time + } + byAge := make([]metricAge, 0, len(ss.Metrics)) + for id, m := range ss.Metrics { + byAge = append(byAge, metricAge{id, m.Service, m.LastSeen}) + } + sort.Slice(byAge, func(i, j int) bool { return byAge[i].seen.Before(byAge[j].seen) }) + for _, e := range byAge[:len(byAge)-maxMetrics] { + delete(ss.Metrics, e.id) + delete(ss.Edges, edgeKey(EdgeMeasuredBy, e.id, e.service)) + pruned++ + } + } + for ek, e := range ss.Edges { + if e.UpdatedAt.Before(cutoff) { + delete(ss.Edges, ek) + } + } + return pruned +} + func (ss *SignalStore) MetricsForService(service string) []*MetricNode { ss.mu.RLock() defer ss.mu.RUnlock() @@ -571,12 +633,24 @@ func (as *AnomalyStore) AddPrecededByEdge(anomalyID, precedingID string, ts time } func (as *AnomalyStore) AnomaliesSince(since time.Time) []*AnomalyNode { + return as.AnomaliesSinceLimit(since, 0) +} + +// AnomaliesSinceLimit is AnomaliesSince with a result cap (n <= 0 means +// unlimited). correlateWithRecent walks this on every detection tick, so the +// cap keeps a pathological anomaly backlog from turning each tick into an +// O(N) scan plus O(N) edge fan-out. Selection past the cap follows map +// iteration order — correlation is best-effort by design. +func (as *AnomalyStore) AnomaliesSinceLimit(since time.Time, n int) []*AnomalyNode { as.mu.RLock() defer as.mu.RUnlock() var out []*AnomalyNode for _, a := range as.Anomalies { if a.Timestamp.After(since) { out = append(out, a) + if n > 0 && len(out) >= n { + break + } } } return out From 83f86d48511cc808fc86ebeba640c915001ce4bb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:12:37 +0000 Subject: [PATCH 20/61] perf(storage): aggregate service map nodes in SQL + narrow edge scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetServiceMapMetrics loaded up to 500k full Span rows — zstd-decompressing the CompressedText attributes column per row in Scan() — then aggregated in Go, costing 2-5s and ~300MB transient on dashboard loads. Node stats now come from one portable GROUP BY aggregate (COUNT/AVG/SUM CASE WHEN, runs on sqlite/postgres/mysql/sqlserver; duration * 1.0 keeps AVG in floating point where AVG(bigint) truncates). Edge stats keep the in-Go parent-resolution pass but over a six-column projection so the attributes column is never scanned. Output proven equivalent to the old implementation by a reference-copy equality test on a fixture covering every branch. One intentional delta: ServiceMapNode.ErrorCount is now populated from span status (STATUS_CODE_ERROR). The old path left it permanently 0 — a latent bug, since buildGraphFromDB divides ErrorCount/TotalTraces for error rates. Node stats are also no longer subject to the 500k row cap (the aggregate is unbounded; the cap now applies only to the edge scan). Benchmark (5000 spans, in-memory SQLite): 22.1ms/5.1MB/150k allocs vs 37.4ms/14.6MB/310k allocs before — the gap widens with row count and attribute payload size. Co-Authored-By: Claude Fable 5 --- internal/storage/service_map_test.go | 465 +++++++++++++++++++++++++++ internal/storage/trace_repo.go | 104 +++--- 2 files changed, 533 insertions(+), 36 deletions(-) create mode 100644 internal/storage/service_map_test.go diff --git a/internal/storage/service_map_test.go b/internal/storage/service_map_test.go new file mode 100644 index 0000000..26e7173 --- /dev/null +++ b/internal/storage/service_map_test.go @@ -0,0 +1,465 @@ +package storage + +import ( + "context" + "fmt" + "math" + "sort" + "testing" + "time" + + "gorm.io/gorm" +) + +// referenceServiceMapMetrics is a verbatim copy of the pre-P2.1 +// GetServiceMapMetrics aggregation: load full Span rows (including the +// CompressedText attributes column) and group in Go. It exists so the +// SQL-aggregated implementation can be proven equivalent on a shared fixture. +// +// Note: this old path never populated ServiceMapNode.ErrorCount or +// ServiceMapEdge.ErrorRate — both stayed 0 — so node error counts are +// asserted separately in TestGetServiceMapMetrics_NodeErrorCounts. +func referenceServiceMapMetrics(t *testing.T, repo *Repository, ctx context.Context, start, end time.Time) *ServiceMapMetrics { + t.Helper() + tenant := TenantFromContext(ctx) + var spans []Span + query := repo.db.WithContext(ctx).Model(&Span{}).Where(sqlWhereTenantID, tenant) + if !start.IsZero() && !end.IsZero() { + query = query.Where("start_time BETWEEN ? AND ?", start, end) + } + if err := query.Limit(serviceMapSpanLimit).Find(&spans).Error; err != nil { + t.Fatalf("reference span load: %v", err) + } + + spanMap := make(map[string]Span) + nodeStats := make(map[string]*ServiceMapNode) + edgeStats := make(map[string]*ServiceMapEdge) + + for _, s := range spans { + spanMap[s.SpanID] = s + if s.ServiceName == "" { + continue + } + if _, ok := nodeStats[s.ServiceName]; !ok { + nodeStats[s.ServiceName] = &ServiceMapNode{Name: s.ServiceName} + } + ns := nodeStats[s.ServiceName] + ns.TotalTraces++ + ns.AvgLatencyMs += float64(s.Duration) + } + + nodes := make([]ServiceMapNode, 0) //nolint:prealloc // verbatim copy of the old implementation + for _, ns := range nodeStats { + if ns.TotalTraces > 0 { + ns.AvgLatencyMs = ns.AvgLatencyMs / float64(ns.TotalTraces) / 1000.0 + ns.AvgLatencyMs = math.Round(ns.AvgLatencyMs*100) / 100 + } + nodes = append(nodes, *ns) + } + + for _, s := range spans { + if s.ParentSpanID == "" || s.ParentSpanID == "0000000000000000" { + continue + } + parent, ok := spanMap[s.ParentSpanID] + if !ok { + continue + } + source := parent.ServiceName + target := s.ServiceName + if source == "" || target == "" || source == target { + continue + } + key := fmt.Sprintf("%s->%s", source, target) + if _, ok := edgeStats[key]; !ok { + edgeStats[key] = &ServiceMapEdge{Source: source, Target: target} + } + es := edgeStats[key] + es.CallCount++ + es.AvgLatencyMs += float64(s.Duration) + } + + edges := make([]ServiceMapEdge, 0) //nolint:prealloc // verbatim copy of the old implementation + for _, es := range edgeStats { + if es.CallCount > 0 { + es.AvgLatencyMs = es.AvgLatencyMs / float64(es.CallCount) / 1000.0 + es.AvgLatencyMs = math.Round(es.AvgLatencyMs*100) / 100 + } + edges = append(edges, *es) + } + + return &ServiceMapMetrics{Nodes: nodes, Edges: edges} +} + +// serviceMapFixtureWindow is the query window the fixture spans sit inside. +func serviceMapFixtureWindow() (start, end time.Time) { + base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + return base, base.Add(10 * time.Minute) +} + +// seedServiceMapFixture inserts a topology exercising every branch of the +// aggregation: cross-service parent-child edges, error spans, same-service +// child (no edge), root span, zero parent id, dangling parent id, empty +// service name, out-of-window span, and a foreign-tenant span. +// +// Expected (window-scoped, tenant "default"): +// +// nodes: svc-a {2 spans, 0 errors, 55ms} — a1 root 100000µs, a2 child-of-a1 10000µs +// svc-b {4 spans, 1 error, 25ms} — b1 50000µs ERROR, b2 30000µs, m1 7000µs +// (dangling parent), f1 13000µs (parent has +// empty service → no edge) +// svc-c {2 spans, 1 error, 30ms} — c1 20000µs ERROR (parent b1), +// z1 40000µs (parent 0000…) +// edges: svc-a→svc-b {2 calls, 40ms}, svc-b→svc-c {1 call, 20ms} +func seedServiceMapFixture(t *testing.T, repo *Repository) { + t.Helper() + start, _ := serviceMapFixtureWindow() + in := start.Add(1 * time.Minute) + out := start.Add(20 * time.Minute) + + mk := func(tenant, spanID, parentID, service string, dur int64, status string, ts time.Time) Span { + return Span{ + TenantID: tenant, + TraceID: "trace-svcmap-1", + SpanID: spanID, + ParentSpanID: parentID, + OperationName: "op-" + spanID, + StartTime: ts, + EndTime: ts.Add(time.Duration(dur) * time.Microsecond), + Duration: dur, + ServiceName: service, + Status: status, + AttributesJSON: CompressedText(`{"http.method":"GET","fixture":"` + spanID + `"}`), + } + } + + spans := []Span{ + mk("default", "a1", "", "svc-a", 100000, "STATUS_CODE_UNSET", in), + mk("default", "a2", "a1", "svc-a", 10000, "STATUS_CODE_UNSET", in), // same-service child: node yes, edge no + mk("default", "b1", "a1", "svc-b", 50000, "STATUS_CODE_ERROR", in), // edge a→b + mk("default", "b2", "a1", "svc-b", 30000, "STATUS_CODE_UNSET", in), // edge a→b + mk("default", "m1", "ffffffffffffffff", "svc-b", 7000, "STATUS_CODE_UNSET", in), // dangling parent: no edge + mk("default", "e1", "a1", "", 5000, "STATUS_CODE_UNSET", in), // empty service: no node, no edge + mk("default", "f1", "e1", "svc-b", 13000, "STATUS_CODE_UNSET", in), // parent has empty service: no edge + mk("default", "c1", "b1", "svc-c", 20000, "STATUS_CODE_ERROR", in), // edge b→c + mk("default", "z1", "0000000000000000", "svc-c", 40000, "STATUS_CODE_UNSET", in), // zero parent: no edge + mk("default", "o1", "", "svc-a", 999999, "STATUS_CODE_ERROR", out), // outside window + mk("tenant-b", "x1", "", "svc-a", 888888, "STATUS_CODE_ERROR", in), // foreign tenant + } + if err := repo.BatchCreateSpans(spans); err != nil { + t.Fatalf("seedServiceMapFixture: %v", err) + } +} + +func sortServiceMap(m *ServiceMapMetrics) { + sort.Slice(m.Nodes, func(i, j int) bool { return m.Nodes[i].Name < m.Nodes[j].Name }) + sort.Slice(m.Edges, func(i, j int) bool { + if m.Edges[i].Source != m.Edges[j].Source { + return m.Edges[i].Source < m.Edges[j].Source + } + return m.Edges[i].Target < m.Edges[j].Target + }) +} + +// TestGetServiceMapMetrics_MatchesReferenceImplementation proves the +// SQL-aggregated implementation is output-equivalent to the old full-row +// in-Go aggregation on a fixture covering every branch. ErrorCount is the +// one intentional delta (the old path left it permanently 0 — a latent bug +// since downstream buildGraphFromDB divides ErrorCount/TotalTraces) and is +// asserted in TestGetServiceMapMetrics_NodeErrorCounts instead. +func TestGetServiceMapMetrics_MatchesReferenceImplementation(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + ctx := context.Background() + start, end := serviceMapFixtureWindow() + + want := referenceServiceMapMetrics(t, repo, ctx, start, end) + got, err := repo.GetServiceMapMetrics(ctx, start, end) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + + sortServiceMap(want) + sortServiceMap(got) + + if len(got.Nodes) != len(want.Nodes) { + t.Fatalf("node count: got %d want %d (%+v vs %+v)", len(got.Nodes), len(want.Nodes), got.Nodes, want.Nodes) + } + for i := range want.Nodes { + g, w := got.Nodes[i], want.Nodes[i] + if g.Name != w.Name || g.TotalTraces != w.TotalTraces || g.AvgLatencyMs != w.AvgLatencyMs { + t.Errorf("node %d mismatch: got %+v want %+v", i, g, w) + } + } + if len(got.Edges) != len(want.Edges) { + t.Fatalf("edge count: got %d want %d (%+v vs %+v)", len(got.Edges), len(want.Edges), got.Edges, want.Edges) + } + for i := range want.Edges { + if got.Edges[i] != want.Edges[i] { + t.Errorf("edge %d mismatch: got %+v want %+v", i, got.Edges[i], want.Edges[i]) + } + } +} + +// TestGetServiceMapMetrics_NodeErrorCounts asserts that node error counts are +// populated from span status. The old implementation never set ErrorCount +// (always 0), so this test documents the intentional fix that landed with the +// SQL aggregation. +func TestGetServiceMapMetrics_NodeErrorCounts(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + start, end := serviceMapFixtureWindow() + + got, err := repo.GetServiceMapMetrics(context.Background(), start, end) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + sortServiceMap(got) + + wantErrors := map[string]int64{"svc-a": 0, "svc-b": 1, "svc-c": 1} + if len(got.Nodes) != len(wantErrors) { + t.Fatalf("node count: got %d want %d (%+v)", len(got.Nodes), len(wantErrors), got.Nodes) + } + for _, n := range got.Nodes { + if n.ErrorCount != wantErrors[n.Name] { + t.Errorf("node %s ErrorCount = %d, want %d", n.Name, n.ErrorCount, wantErrors[n.Name]) + } + } +} + +// TestGetServiceMapMetrics_FixtureValues pins the exact expected fixture +// output so a regression in BOTH implementations (reference and SQL) cannot +// slip through the equality test unnoticed. +func TestGetServiceMapMetrics_FixtureValues(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + start, end := serviceMapFixtureWindow() + + got, err := repo.GetServiceMapMetrics(context.Background(), start, end) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + sortServiceMap(got) + + wantNodes := []ServiceMapNode{ + {Name: "svc-a", TotalTraces: 2, ErrorCount: 0, AvgLatencyMs: 55}, + {Name: "svc-b", TotalTraces: 4, ErrorCount: 1, AvgLatencyMs: 25}, + {Name: "svc-c", TotalTraces: 2, ErrorCount: 1, AvgLatencyMs: 30}, + } + wantEdges := []ServiceMapEdge{ + {Source: "svc-a", Target: "svc-b", CallCount: 2, AvgLatencyMs: 40}, + {Source: "svc-b", Target: "svc-c", CallCount: 1, AvgLatencyMs: 20}, + } + + if len(got.Nodes) != len(wantNodes) { + t.Fatalf("nodes: got %+v want %+v", got.Nodes, wantNodes) + } + for i := range wantNodes { + if got.Nodes[i] != wantNodes[i] { + t.Errorf("node %d: got %+v want %+v", i, got.Nodes[i], wantNodes[i]) + } + } + if len(got.Edges) != len(wantEdges) { + t.Fatalf("edges: got %+v want %+v", got.Edges, wantEdges) + } + for i := range wantEdges { + if got.Edges[i] != wantEdges[i] { + t.Errorf("edge %d: got %+v want %+v", i, got.Edges[i], wantEdges[i]) + } + } +} + +// TestGetServiceMapMetrics_TenantScoped verifies the foreign tenant sees only +// its own spans through the same code path. +func TestGetServiceMapMetrics_TenantScoped(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + start, end := serviceMapFixtureWindow() + ctx := WithTenantContext(context.Background(), "tenant-b") + + got, err := repo.GetServiceMapMetrics(ctx, start, end) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + if len(got.Nodes) != 1 || got.Nodes[0].Name != "svc-a" || got.Nodes[0].TotalTraces != 1 { + t.Fatalf("tenant-b nodes: %+v", got.Nodes) + } + if got.Nodes[0].ErrorCount != 1 { + t.Fatalf("tenant-b ErrorCount = %d, want 1", got.Nodes[0].ErrorCount) + } + if len(got.Edges) != 0 { + t.Fatalf("tenant-b edges: %+v", got.Edges) + } +} + +// TestGetServiceMapMetrics_ZeroWindowIncludesAll preserves the legacy +// semantics: a zero start or end skips the time filter entirely. +func TestGetServiceMapMetrics_ZeroWindowIncludesAll(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + + got, err := repo.GetServiceMapMetrics(context.Background(), time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + want := referenceServiceMapMetrics(t, repo, context.Background(), time.Time{}, time.Time{}) + sortServiceMap(got) + sortServiceMap(want) + + // The out-of-window span o1 (svc-a) must now be included: 3 svc-a spans. + var svcA *ServiceMapNode + for i := range got.Nodes { + if got.Nodes[i].Name == "svc-a" { + svcA = &got.Nodes[i] + } + } + if svcA == nil || svcA.TotalTraces != 3 { + t.Fatalf("zero window svc-a node: %+v", got.Nodes) + } + if len(got.Nodes) != len(want.Nodes) || len(got.Edges) != len(want.Edges) { + t.Fatalf("zero window mismatch vs reference: got %+v / %+v want %+v / %+v", + got.Nodes, got.Edges, want.Nodes, want.Edges) + } +} + +// TestGetServiceMapMetrics_Empty returns non-nil empty slices on a fresh DB +// (the JSON API contract expects [] rather than null). +func TestGetServiceMapMetrics_Empty(t *testing.T) { + repo := newTestRepo(t) + got, err := repo.GetServiceMapMetrics(context.Background(), time.Time{}, time.Time{}) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + if got.Nodes == nil || got.Edges == nil { + t.Fatalf("expected non-nil empty slices, got %+v", got) + } + if len(got.Nodes) != 0 || len(got.Edges) != 0 { + t.Fatalf("expected empty result, got %+v", got) + } +} + +// TestGetServiceMapMetrics_CancelledContext surfaces query errors. +func TestGetServiceMapMetrics_CancelledContext(t *testing.T) { + repo := newTestRepo(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := repo.GetServiceMapMetrics(ctx, time.Time{}, time.Time{}); err == nil { + t.Fatal("expected error on cancelled context") + } +} + +// TestGetServiceMapMetrics_EdgeQueryError exercises the second (edge-pass) +// error branch. The node aggregate runs via Scan, which does not pass through +// GORM's Query callback chain; the edge scan runs via Find, which does. A +// before-query callback therefore fires exactly once — for the edge query — +// and cancels the context just before it executes, failing only that query. +func TestGetServiceMapMetrics_EdgeQueryError(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + err := repo.db.Callback().Query().Before("gorm:query").Register("test_cancel_edge_query", func(*gorm.DB) { + cancel() + }) + if err != nil { + t.Fatalf("register callback: %v", err) + } + t.Cleanup(func() { _ = repo.db.Callback().Query().Remove("test_cancel_edge_query") }) + + if _, err := repo.GetServiceMapMetrics(ctx, time.Time{}, time.Time{}); err == nil { + t.Fatal("expected edge query error after context cancellation") + } +} + +// TestGetServiceMapMetrics_EdgeRowLimit lowers serviceMapSpanLimit so the +// warning path runs: edges are computed from the truncated scan while node +// stats (GROUP BY, unbounded) still cover every row. +func TestGetServiceMapMetrics_EdgeRowLimit(t *testing.T) { + repo := newTestRepo(t) + seedServiceMapFixture(t, repo) + start, end := serviceMapFixtureWindow() + + orig := serviceMapSpanLimit + serviceMapSpanLimit = 4 + t.Cleanup(func() { serviceMapSpanLimit = orig }) + + got, err := repo.GetServiceMapMetrics(context.Background(), start, end) + if err != nil { + t.Fatalf("GetServiceMapMetrics: %v", err) + } + // Node aggregation is not subject to the edge-scan limit. + if len(got.Nodes) != 3 { + t.Fatalf("expected all 3 nodes despite edge row limit, got %+v", got.Nodes) + } + // The edge pass saw at most 4 of the 9 in-window rows. + var calls int64 + for _, e := range got.Edges { + calls += e.CallCount + } + if calls > 3 { + t.Fatalf("edge pass exceeded the row limit: %+v", got.Edges) + } +} + +// BenchmarkGetServiceMapMetrics measures the aggregation over a few thousand +// seeded spans with realistic compressed attribute payloads — the workload +// that made the old full-row implementation take seconds on the dashboard. +func BenchmarkGetServiceMapMetrics(b *testing.B) { + db, err := NewDatabase("sqlite", ":memory:") + if err != nil { + b.Fatalf("NewDatabase: %v", err) + } + if err := AutoMigrateModels(db, "sqlite"); err != nil { + b.Fatalf("AutoMigrateModels: %v", err) + } + repo := &Repository{db: db, driver: "sqlite"} + defer repo.Close() + + const ( + numSpans = 5000 + numServices = 10 + ) + base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + spans := make([]Span, numSpans) + for i := range numSpans { + svc := fmt.Sprintf("svc-%d", i%numServices) + parent := "" + if i > 0 { + parent = fmt.Sprintf("%016x", i-1) // chain → cross-service edges + } + status := "STATUS_CODE_UNSET" + if i%20 == 0 { + status = "STATUS_CODE_ERROR" + } + spans[i] = Span{ + TenantID: "default", + TraceID: fmt.Sprintf("trace-%04d", i/50), + SpanID: fmt.Sprintf("%016x", i), + ParentSpanID: parent, + OperationName: "GET /api/resource", + StartTime: base.Add(time.Duration(i) * time.Millisecond), + EndTime: base.Add(time.Duration(i)*time.Millisecond + time.Millisecond), + Duration: int64(1000 + i%5000), + ServiceName: svc, + Status: status, + AttributesJSON: CompressedText(fmt.Sprintf( + `{"http.method":"GET","http.url":"/api/resource/%d","http.status_code":200,"net.peer.name":"%s","enduser.id":"user-%d"}`, + i, svc, i%100)), + } + } + if err := repo.BatchCreateSpans(spans); err != nil { + b.Fatalf("seed: %v", err) + } + + ctx := context.Background() + start := base.Add(-time.Minute) + end := base.Add(time.Hour) + + b.ResetTimer() + for b.Loop() { + if _, err := repo.GetServiceMapMetrics(ctx, start, end); err != nil { + b.Fatalf("GetServiceMapMetrics: %v", err) + } + } +} diff --git a/internal/storage/trace_repo.go b/internal/storage/trace_repo.go index f0ae992..846b733 100644 --- a/internal/storage/trace_repo.go +++ b/internal/storage/trace_repo.go @@ -255,72 +255,104 @@ func (r *Repository) GetTracesFiltered(ctx context.Context, start, end time.Time }, nil } -const serviceMapSpanLimit = 500_000 +// serviceMapSpanLimit caps the edge-pass span scan. A var (not const) so the +// row-limit warning path is testable without seeding 500k rows. +var serviceMapSpanLimit = 500_000 + +// serviceMapNodeRow receives the per-service GROUP BY aggregate used to build +// ServiceMapNode entries without loading span rows into Go. +type serviceMapNodeRow struct { + ServiceName string + SpanCount int64 + AvgDuration float64 + ErrorCount int64 +} + +// serviceMapSpanRow is the narrow projection scanned for the edge pass. It is +// deliberately NOT Span: AttributesJSON (CompressedText) zstd-decompresses +// per row in Scan(), which dominated the cost of the old full-row load. +type serviceMapSpanRow struct { + SpanID string + ParentSpanID string + ServiceName string + Duration int64 + Status string + StartTime time.Time +} // GetServiceMapMetrics computes topology metrics from spans scoped to the // tenant on ctx. +// +// Node stats come from a single portable GROUP BY aggregate so the database +// does the heavy lifting. Edge stats still need the parent→child resolution +// via span_id done in Go, but over the narrow serviceMapSpanRow projection so +// the compressed attributes column is never scanned. `duration * 1.0` keeps +// AVG in floating point on every dialect (SQL Server truncates AVG(bigint)). func (r *Repository) GetServiceMapMetrics(ctx context.Context, start, end time.Time) (*ServiceMapMetrics, error) { tenant := TenantFromContext(ctx) - var spans []Span - query := r.db.WithContext(ctx).Model(&Span{}).Where(sqlWhereTenantID, tenant) + nodeQuery := r.db.WithContext(ctx).Model(&Span{}). + Select("service_name, COUNT(*) as span_count, AVG(duration * 1.0) as avg_duration, "+ + "SUM(CASE WHEN status = 'STATUS_CODE_ERROR' THEN 1 ELSE 0 END) as error_count"). + Where(sqlWhereTenantID, tenant). + Where("service_name <> ''") if !start.IsZero() && !end.IsZero() { - query = query.Where("start_time BETWEEN ? AND ?", start, end) + nodeQuery = nodeQuery.Where("start_time BETWEEN ? AND ?", start, end) + } + var nodeRows []serviceMapNodeRow + if err := nodeQuery.Group("service_name").Scan(&nodeRows).Error; err != nil { + return nil, fmt.Errorf("failed to aggregate service map nodes: %w", err) } - if err := query.Limit(serviceMapSpanLimit).Find(&spans).Error; err != nil { + nodes := make([]ServiceMapNode, 0, len(nodeRows)) + for _, nr := range nodeRows { + nodes = append(nodes, ServiceMapNode{ + Name: nr.ServiceName, + TotalTraces: nr.SpanCount, + ErrorCount: nr.ErrorCount, + // AVG(duration) is microseconds; convert to ms and round to 2dp. + AvgLatencyMs: math.Round(nr.AvgDuration/1000.0*100) / 100, + }) + } + + edgeQuery := r.db.WithContext(ctx).Model(&Span{}). + Select("span_id, parent_span_id, service_name, duration, status, start_time"). + Where(sqlWhereTenantID, tenant) + if !start.IsZero() && !end.IsZero() { + edgeQuery = edgeQuery.Where("start_time BETWEEN ? AND ?", start, end) + } + var spans []serviceMapSpanRow + if err := edgeQuery.Limit(serviceMapSpanLimit).Find(&spans).Error; err != nil { return nil, fmt.Errorf("failed to fetch spans: %w", err) } if len(spans) == serviceMapSpanLimit { - slog.Warn("GetServiceMapMetrics: span query hit row limit, topology may be incomplete", "limit", serviceMapSpanLimit) + slog.Warn("GetServiceMapMetrics: edge span query hit row limit, edge topology may be incomplete", "limit", serviceMapSpanLimit) } - spanMap := make(map[string]Span) - nodeStats := make(map[string]*ServiceMapNode) - edgeStats := make(map[string]*ServiceMapEdge) - + // Parent resolution map (span_id → service name) is built over ALL rows, + // including empty service names, matching the old full-span map exactly. + serviceBySpanID := make(map[string]string, len(spans)) for _, s := range spans { - spanMap[s.SpanID] = s - - if s.ServiceName == "" { - continue - } - - if _, ok := nodeStats[s.ServiceName]; !ok { - nodeStats[s.ServiceName] = &ServiceMapNode{Name: s.ServiceName} - } - ns := nodeStats[s.ServiceName] - ns.TotalTraces++ - ns.AvgLatencyMs += float64(s.Duration) - } - - nodes := make([]ServiceMapNode, 0) - for _, ns := range nodeStats { - if ns.TotalTraces > 0 { - ns.AvgLatencyMs = ns.AvgLatencyMs / float64(ns.TotalTraces) / 1000.0 - ns.AvgLatencyMs = math.Round(ns.AvgLatencyMs*100) / 100 - } - nodes = append(nodes, *ns) + serviceBySpanID[s.SpanID] = s.ServiceName } + edgeStats := make(map[string]*ServiceMapEdge) for _, s := range spans { if s.ParentSpanID == "" || s.ParentSpanID == "0000000000000000" { continue } - parent, ok := spanMap[s.ParentSpanID] + source, ok := serviceBySpanID[s.ParentSpanID] if !ok { continue } - - source := parent.ServiceName target := s.ServiceName if source == "" || target == "" || source == target { continue } - key := fmt.Sprintf("%s->%s", source, target) + key := source + "->" + target if _, ok := edgeStats[key]; !ok { edgeStats[key] = &ServiceMapEdge{Source: source, Target: target} } @@ -329,7 +361,7 @@ func (r *Repository) GetServiceMapMetrics(ctx context.Context, start, end time.T es.AvgLatencyMs += float64(s.Duration) } - edges := make([]ServiceMapEdge, 0) + edges := make([]ServiceMapEdge, 0, len(edgeStats)) for _, es := range edgeStats { if es.CallCount > 0 { es.AvgLatencyMs = es.AvgLatencyMs / float64(es.CallCount) / 1000.0 From 17bbafc047946e1ca4eda2e4aa546b72a822bc7a Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:12:47 +0000 Subject: [PATCH 21/61] perf(api): cache /api/metrics/service-map for 30s per tenant+window Mirror the 10s system-graph cache pattern from graph_handler.go (TTLCache + X-Cache HIT/MISS header) on handleGetServiceMapMetrics with a 30s TTL. The cache key uses the raw start/end query params, so the default rolling window (no params) shares a single entry per tenant instead of being re-keyed on every request timestamp; explicit windows are cached independently. Co-Authored-By: Claude Fable 5 --- internal/api/metrics_handlers.go | 30 ++++- internal/api/service_map_cache_test.go | 166 +++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 internal/api/service_map_cache_test.go diff --git a/internal/api/metrics_handlers.go b/internal/api/metrics_handlers.go index 6b38ab6..82f472d 100644 --- a/internal/api/metrics_handlers.go +++ b/internal/api/metrics_handlers.go @@ -8,6 +8,7 @@ import ( "github.com/RandomCodeSpace/otelcontext/internal/api/views" "github.com/RandomCodeSpace/otelcontext/internal/httpconst" + "github.com/RandomCodeSpace/otelcontext/internal/storage" ) // handleGetTrafficMetrics handles GET /api/metrics/traffic @@ -99,17 +100,33 @@ func (s *Server) handleGetDashboardStats(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(views.DashboardStatsFromModel(stats)) } -// handleGetServiceMapMetrics handles GET /api/metrics/service-map +// handleGetServiceMapMetrics handles GET /api/metrics/service-map. +// Results are cached for 30s per (tenant, window) — the dashboard polls this +// endpoint and the underlying span aggregation is among the most expensive +// queries in the API surface. The key uses the raw start/end params so the +// default rolling window (no params) shares a single entry instead of being +// re-keyed on every request timestamp. func (s *Server) handleGetServiceMapMetrics(w http.ResponseWriter, r *http.Request) { + const cacheTTL = 30 * time.Second + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + cacheKey := "service_map:" + storage.TenantFromContext(r.Context()) + ":" + startStr + ":" + endStr + + if cached, ok := s.cache.Get(cacheKey); ok { + w.Header().Set(httpconst.HeaderContentType, httpconst.ContentTypeJSON) + w.Header().Set("X-Cache", "HIT") + _ = json.NewEncoder(w).Encode(cached) + return + } + end := time.Now() start := end.Add(-30 * time.Minute) - - if startStr := r.URL.Query().Get("start"); startStr != "" { + if startStr != "" { if t, err := time.Parse(time.RFC3339, startStr); err == nil { start = t } } - if endStr := r.URL.Query().Get("end"); endStr != "" { + if endStr != "" { if t, err := time.Parse(time.RFC3339, endStr); err == nil { end = t } @@ -122,8 +139,11 @@ func (s *Server) handleGetServiceMapMetrics(w http.ResponseWriter, r *http.Reque return } + resp := views.ServiceMapMetricsFromModel(metrics) + s.cache.Set(cacheKey, resp, cacheTTL) w.Header().Set(httpconst.HeaderContentType, httpconst.ContentTypeJSON) - _ = json.NewEncoder(w).Encode(views.ServiceMapMetricsFromModel(metrics)) + w.Header().Set("X-Cache", "MISS") + _ = json.NewEncoder(w).Encode(resp) } // handleGetMetricBuckets handles GET /api/metrics diff --git a/internal/api/service_map_cache_test.go b/internal/api/service_map_cache_test.go new file mode 100644 index 0000000..d4ee0d4 --- /dev/null +++ b/internal/api/service_map_cache_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/cache" + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// newServiceMapTestServer builds a Server with a real SQLite-backed repository +// and a live TTL cache — the two dependencies handleGetServiceMapMetrics uses. +func newServiceMapTestServer(t *testing.T) *Server { + t.Helper() + db, err := storage.NewDatabase("sqlite", ":memory:") + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if err := storage.AutoMigrateModels(db, "sqlite"); err != nil { + t.Fatalf("AutoMigrateModels: %v", err) + } + repo := storage.NewRepositoryFromDB(db, "sqlite") + c := cache.New() + t.Cleanup(func() { + c.Stop() + _ = repo.Close() + }) + return &Server{repo: repo, cache: c} +} + +// seedServiceMapSpans inserts a parent/child span pair for the given tenant so +// the service-map endpoint has one edge and two nodes to report. +func seedServiceMapSpans(t *testing.T, s *Server, tenant, suffix string) { + t.Helper() + now := time.Now().UTC() + spans := []storage.Span{ + { + TenantID: tenant, TraceID: "trace-" + suffix, SpanID: "parent-" + suffix, + OperationName: "op", StartTime: now, EndTime: now.Add(time.Millisecond), + Duration: 1000, ServiceName: "svc-front-" + suffix, Status: "STATUS_CODE_UNSET", + }, + { + TenantID: tenant, TraceID: "trace-" + suffix, SpanID: "child-" + suffix, + ParentSpanID: "parent-" + suffix, OperationName: "op", StartTime: now, + EndTime: now.Add(time.Millisecond), Duration: 2000, + ServiceName: "svc-back-" + suffix, Status: "STATUS_CODE_ERROR", + }, + } + if err := s.repo.BatchCreateSpans(spans); err != nil { + t.Fatalf("seed spans: %v", err) + } +} + +func getServiceMap(t *testing.T, s *Server, ctx context.Context, target string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleGetServiceMapMetrics(rr, req) + return rr +} + +// TestServiceMapHandler_CacheMissThenHit verifies the 30s result cache: the +// first request computes and stores, the second is served from cache even if +// new spans landed in between. +func TestServiceMapHandler_CacheMissThenHit(t *testing.T) { + s := newServiceMapTestServer(t) + seedServiceMapSpans(t, s, "default", "a") + ctx := context.Background() + + rr1 := getServiceMap(t, s, ctx, "/api/metrics/service-map") + if rr1.Code != http.StatusOK { + t.Fatalf("first request: %d body=%s", rr1.Code, rr1.Body.String()) + } + if got := rr1.Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("first request X-Cache = %q, want MISS", got) + } + + // New data must NOT appear within the TTL — proves the second request + // never reached the repository. + seedServiceMapSpans(t, s, "default", "b") + + rr2 := getServiceMap(t, s, ctx, "/api/metrics/service-map") + if rr2.Code != http.StatusOK { + t.Fatalf("second request: %d", rr2.Code) + } + if got := rr2.Header().Get("X-Cache"); got != "HIT" { + t.Fatalf("second request X-Cache = %q, want HIT", got) + } + if rr1.Body.String() != rr2.Body.String() { + t.Fatalf("cached body diverged:\n first=%s\nsecond=%s", rr1.Body.String(), rr2.Body.String()) + } +} + +// TestServiceMapHandler_CacheKeyPerTenant verifies two tenants never share a +// cache entry. +func TestServiceMapHandler_CacheKeyPerTenant(t *testing.T) { + s := newServiceMapTestServer(t) + seedServiceMapSpans(t, s, "default", "a") + seedServiceMapSpans(t, s, "tenant-b", "b") + + rr1 := getServiceMap(t, s, context.Background(), "/api/metrics/service-map") + if got := rr1.Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("default tenant X-Cache = %q, want MISS", got) + } + + ctxB := storage.WithTenantContext(context.Background(), "tenant-b") + rr2 := getServiceMap(t, s, ctxB, "/api/metrics/service-map") + if got := rr2.Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("tenant-b first request X-Cache = %q, want MISS (cache leaked across tenants)", got) + } + if rr1.Body.String() == rr2.Body.String() { + t.Fatalf("tenant responses identical — tenant scoping broken: %s", rr1.Body.String()) + } + + rr3 := getServiceMap(t, s, ctxB, "/api/metrics/service-map") + if got := rr3.Header().Get("X-Cache"); got != "HIT" { + t.Fatalf("tenant-b second request X-Cache = %q, want HIT", got) + } +} + +// TestServiceMapHandler_CacheKeyPerWindow verifies explicit start/end windows +// are cached independently of the default rolling window and of each other. +func TestServiceMapHandler_CacheKeyPerWindow(t *testing.T) { + s := newServiceMapTestServer(t) + seedServiceMapSpans(t, s, "default", "a") + ctx := context.Background() + + // Prime the default-window entry. + if got := getServiceMap(t, s, ctx, "/api/metrics/service-map").Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("default window X-Cache = %q, want MISS", got) + } + + win := "/api/metrics/service-map?start=2026-06-01T00:00:00Z&end=2026-06-01T01:00:00Z" + if got := getServiceMap(t, s, ctx, win).Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("explicit window first request X-Cache = %q, want MISS", got) + } + if got := getServiceMap(t, s, ctx, win).Header().Get("X-Cache"); got != "HIT" { + t.Fatalf("explicit window second request X-Cache = %q, want HIT", got) + } + + other := "/api/metrics/service-map?start=2026-06-01T00:00:00Z&end=2026-06-01T02:00:00Z" + if got := getServiceMap(t, s, ctx, other).Header().Get("X-Cache"); got != "MISS" { + t.Fatalf("different window X-Cache = %q, want MISS", got) + } +} + +// TestServiceMapHandler_DBError500 verifies repository failures surface as 500 +// and are never cached. +func TestServiceMapHandler_DBError500(t *testing.T) { + s := newServiceMapTestServer(t) + sqlDB, err := s.repo.DB().DB() + if err != nil { + t.Fatalf("unwrap sql.DB: %v", err) + } + if err := sqlDB.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + rr := getServiceMap(t, s, context.Background(), "/api/metrics/service-map") + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d body=%s", rr.Code, rr.Body.String()) + } +} From 99d84279c01b237f3bd817290e7f988e6ce5cf6d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:12:57 +0000 Subject: [PATCH 22/61] build(ui): add query/router/radix/uplot deps + vendored Inter/JetBrains Mono subsets @tanstack/react-query@5, @tanstack/react-virtual@3, wouter@3, cmdk, Radix dialog/tooltip/tabs/dropdown-menu, uplot (all MIT/ISC) and the OFL latin woff2 subsets (Inter variable 47.1KB + JetBrains Mono 400 20.7KB = 67.8KB, under the 90KB font budget) vendored into ui/public/fonts with their licenses. npm audit --audit-level=moderate reports 0 vulnerabilities. Co-Authored-By: Claude Fable 5 --- ui/package-lock.json | 1293 ++++++++++++++++- ui/package.json | 14 +- ui/public/fonts/LICENSE-inter | 93 ++ ui/public/fonts/LICENSE-jetbrains-mono | 93 ++ ui/public/fonts/inter-latin-wght-normal.woff2 | Bin 0 -> 48256 bytes .../jetbrains-mono-latin-400-normal.woff2 | Bin 0 -> 21168 bytes 6 files changed, 1417 insertions(+), 76 deletions(-) create mode 100644 ui/public/fonts/LICENSE-inter create mode 100644 ui/public/fonts/LICENSE-jetbrains-mono create mode 100644 ui/public/fonts/inter-latin-wght-normal.woff2 create mode 100644 ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 diff --git a/ui/package-lock.json b/ui/package-lock.json index 23f7b0d..fdb0300 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,13 +8,24 @@ "name": "otelcontext-ui", "version": "0.0.0", "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@ossrandom/design-system": "^0.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-tooltip": "^1.2.9", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.14.2", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cytoscape": "^3.33.2", "cytoscape-cose-bilkent": "^4.1.0", "lucide-react": "^0.469.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "uplot": "^1.6.32", + "wouter": "^3.10.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -25,6 +36,7 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -227,9 +239,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -237,9 +249,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -271,13 +283,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -331,19 +343,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -677,6 +699,62 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -841,15 +919,670 @@ } } }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", + "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", + "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", + "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.17.tgz", + "integrity": "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.17.tgz", + "integrity": "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", + "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", + "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", + "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.9.tgz", + "integrity": "sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", + "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", @@ -1122,6 +1855,59 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1284,7 +2070,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1294,7 +2080,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -1569,17 +2355,48 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1588,13 +2405,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1615,9 +2432,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -1628,13 +2445,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -1642,14 +2459,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1658,9 +2475,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -1668,13 +2485,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1733,6 +2550,18 @@ "node": ">=8" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1753,6 +2582,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1873,6 +2721,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1929,7 +2793,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -2019,6 +2883,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2394,6 +3264,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2420,6 +3299,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2450,6 +3339,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2517,6 +3413,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2969,6 +3904,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3002,6 +3978,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3271,6 +4253,75 @@ "license": "MIT", "peer": true }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3285,6 +4336,15 @@ "node": ">=8" } }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3432,6 +4492,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3546,9 +4619,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -3649,6 +4720,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3659,6 +4736,58 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -3738,19 +4867,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3778,12 +4907,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3918,6 +5047,20 @@ "node": ">=0.10.0" } }, + "node_modules/wouter": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.10.0.tgz", + "integrity": "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==", + "license": "Unlicense", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 17bfa21..903c5d7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,13 +12,24 @@ "test:watch": "vitest" }, "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@ossrandom/design-system": "^0.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-tooltip": "^1.2.9", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.14.2", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cytoscape": "^3.33.2", "cytoscape-cose-bilkent": "^4.1.0", "lucide-react": "^0.469.0", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "uplot": "^1.6.32", + "wouter": "^3.10.0" }, "overrides": { "brace-expansion": "5.0.6" @@ -32,6 +43,7 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/ui/public/fonts/LICENSE-inter b/ui/public/fonts/LICENSE-inter new file mode 100644 index 0000000..40589da --- /dev/null +++ b/ui/public/fonts/LICENSE-inter @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/public/fonts/LICENSE-jetbrains-mono b/ui/public/fonts/LICENSE-jetbrains-mono new file mode 100644 index 0000000..8f7ed67 --- /dev/null +++ b/ui/public/fonts/LICENSE-jetbrains-mono @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/public/fonts/inter-latin-wght-normal.woff2 b/ui/public/fonts/inter-latin-wght-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d15208de03cd1ad7c5199f0a0ce915fe841e4722 GIT binary patch literal 48256 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∾Na#*TBTU(!jF?9zmR-!C*+RdvGrd0&{`| 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 T=ND-DucNQ`tADQ-AuI&|D5jKI literal 0 HcmV?d00001 From 5ae9fefd9333ce6a7508691a048bca47b18bbe9e Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:13:48 +0000 Subject: [PATCH 23/61] perf(graphrag): incremental DB rebuild via per-tenant high-water-mark (P2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebuildFromDBForTenant tracked nothing between ticks and re-read the full trailing 1h of spans per tenant every 60s. Each tenant slice now records the max start_time merged (lastRebuildMax); subsequent ticks query start_time > max(since, HWM-5min) — the 5min overlap re-merges late arrivals. A fresh slice (first build, post-eviction) has HWM 0 and takes the full window, so P1.5 eviction stays self-healing. The 50k row LIMIT is kept and now logs a warning when hit. Co-Authored-By: Claude Fable 5 --- internal/graphrag/rebuild_incremental_test.go | 144 ++++++++++++++++++ internal/graphrag/refresh.go | 30 +++- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 internal/graphrag/rebuild_incremental_test.go diff --git a/internal/graphrag/rebuild_incremental_test.go b/internal/graphrag/rebuild_incremental_test.go new file mode 100644 index 0000000..a895c8e --- /dev/null +++ b/internal/graphrag/rebuild_incremental_test.go @@ -0,0 +1,144 @@ +package graphrag + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// seedSpan inserts one span row for the default tenant. +func seedSpan(t *testing.T, repo *storage.Repository, service, spanID string, start time.Time) { + t.Helper() + sp := storage.Span{ + TenantID: storage.DefaultTenantID, + TraceID: "trace-" + spanID, + SpanID: spanID, + OperationName: "/op", + ServiceName: service, + Status: "STATUS_CODE_OK", + StartTime: start, + EndTime: start.Add(time.Millisecond), + Duration: 1000, + } + if err := repo.DB().Create(&sp).Error; err != nil { + t.Fatalf("seed span %s: %v", spanID, err) + } +} + +// TestRebuild_IncrementalWindow proves the per-tenant high-water-mark: the +// second rebuild tick only re-reads spans newer than HWM minus the overlap, +// so a row inserted between ticks with an older start_time (late backfill +// outside the overlap) is not merged, while fresh rows are. +func TestRebuild_IncrementalWindow(t *testing.T) { + repo := newTestRepo(t) + g := New(repo, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + now := time.Now() + ctx := context.Background() + + // Tick 1: full window (fresh store, HWM == 0) reads both rows. + seedSpan(t, repo, "orders", "s-a", now.Add(-30*time.Minute)) + seedSpan(t, repo, "orders", "s-a2", now.Add(-10*time.Minute)) + g.rebuildAllTenantsFromDB(ctx) + + stores := g.storesForTenant(storage.DefaultTenantID) + svc, ok := stores.service.GetService("orders") + if !ok || svc.CallCount != 2 { + t.Fatalf("tick 1: orders CallCount = %v, want 2 (full window)", svc) + } + hwm := stores.lastRebuildMax.Load() + if hwm == 0 { + t.Fatalf("tick 1 did not advance the high-water-mark") + } + + // Between ticks: one backfilled row older than HWM-overlap (must be + // skipped) and one fresh row (must be merged). + seedSpan(t, repo, "ghost", "s-ghost", now.Add(-20*time.Minute)) + seedSpan(t, repo, "fresh-svc", "s-fresh", now) + + // Tick 2: incremental window = (HWM - 5min, now] = (now-15min, now]. + g.rebuildAllTenantsFromDB(ctx) + + if _, ok := stores.service.GetService("ghost"); ok { + t.Errorf("tick 2 re-read the full window — ghost (start_time < HWM-5min) was merged") + } + if _, ok := stores.service.GetService("fresh-svc"); !ok { + t.Errorf("tick 2 missed the fresh span — incremental window too narrow") + } + if got := stores.lastRebuildMax.Load(); got <= hwm { + t.Errorf("HWM did not advance on tick 2: %d -> %d", hwm, got) + } +} + +// TestRebuild_HWMNeverRegresses proves a tick that reads only older rows +// (possible when the overlap re-reads the tail) cannot move the +// high-water-mark backwards. +func TestRebuild_HWMNeverRegresses(t *testing.T) { + repo := newTestRepo(t) + g := New(repo, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + now := time.Now() + ctx := context.Background() + + seedSpan(t, repo, "orders", "s-1", now.Add(-2*time.Minute)) + g.rebuildAllTenantsFromDB(ctx) + stores := g.storesForTenant(storage.DefaultTenantID) + hwm := stores.lastRebuildMax.Load() + if hwm == 0 { + t.Fatalf("HWM not set on first rebuild") + } + + // Second tick re-reads the same row via the overlap; HWM must hold. + g.rebuildAllTenantsFromDB(ctx) + if got := stores.lastRebuildMax.Load(); got != hwm { + t.Fatalf("HWM changed without newer rows: %d -> %d", hwm, got) + } +} + +// TestRebuild_FullWindowAfterEviction proves the self-healing contract: +// a tenant evicted and re-discovered gets a fresh slice (HWM 0) and the +// next rebuild takes the full trailing window again. +func TestRebuild_FullWindowAfterEviction(t *testing.T) { + repo := newTestRepo(t) + g := New(repo, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + now := time.Now() + ctx := context.Background() + tctx := storage.WithTenantContext(ctx, "tenant-x") + + for i := 0; i < 3; i++ { + sp := storage.Span{ + TenantID: "tenant-x", + TraceID: fmt.Sprintf("t-%d", i), + SpanID: fmt.Sprintf("s-%d", i), + ServiceName: "orders", + Status: "STATUS_CODE_OK", + StartTime: now.Add(-30 * time.Minute), + EndTime: now.Add(-30 * time.Minute).Add(time.Millisecond), + } + if err := repo.DB().Create(&sp).Error; err != nil { + t.Fatalf("seed: %v", err) + } + } + g.rebuildAllTenantsFromDB(ctx) + if len(g.ServiceMap(tctx, 0)) == 0 { + t.Fatalf("tenant-x not built on first rebuild") + } + + // Evict, then rebuild: the fresh slice must take the full window and + // recover the 30min-old spans. + g.storesForTenant("tenant-x").lastAccess.Store(staleNanos()) + if g.evictIdleTenants() != 1 { + t.Fatalf("eviction did not fire") + } + g.rebuildAllTenantsFromDB(ctx) + if len(g.ServiceMap(tctx, 0)) == 0 { + t.Fatalf("evicted tenant not rebuilt with full window") + } +} diff --git a/internal/graphrag/refresh.go b/internal/graphrag/refresh.go index 0143db8..395387b 100644 --- a/internal/graphrag/refresh.go +++ b/internal/graphrag/refresh.go @@ -15,6 +15,13 @@ const ( // maxMetricsPerTenant caps each tenant's SignalStore metric map; past // the cap the oldest-LastSeen series are evicted first. maxMetricsPerTenant = 2000 + // rebuildRowLimit caps how many span rows a single per-tenant rebuild + // pass loads. Hitting it is logged — topology lags until the next tick. + rebuildRowLimit = 50000 + // rebuildOverlap is re-read behind the high-water-mark on each + // incremental rebuild so late-arriving spans with slightly older + // start_time values are still merged. + rebuildOverlap = 5 * time.Minute ) // refreshLoop periodically rebuilds/merges from DB and prunes stale data. @@ -202,18 +209,33 @@ func (g *GraphRAG) rebuildFromDBForTenant(_ context.Context, tenant string, sinc // clock, or dormant tenants would never reach GRAPHRAG_TENANT_IDLE_TTL. stores := g.tenantStoresNoTouch(tenant) + // Incremental rebuild: after the first pass, only re-read spans newer + // than the tenant's high-water-mark minus a small overlap instead of the + // full trailing window — at 120 services the full-window re-read every + // 60s dominated DB load. A fresh slice (first build, post-eviction) has + // HWM 0 and takes the full window. + if hwm := stores.lastRebuildMax.Load(); hwm != 0 { + if t := time.Unix(0, hwm).Add(-rebuildOverlap); t.After(since) { + since = t + } + } + var rows []spanRow err := g.repo.DB(). Table("spans"). Select("span_id, parent_span_id, service_name, operation_name, duration, trace_id, status, start_time"). Where("start_time > ? AND tenant_id = ?", since, tenant). Order("start_time ASC"). - Limit(50000). + Limit(rebuildRowLimit). Find(&rows).Error if err != nil { slog.Error("GraphRAG: failed to rebuild from DB", "tenant", tenant, "error", err) return } + if len(rows) == rebuildRowLimit { + slog.Warn("GraphRAG rebuild hit the row limit — topology lags until the next tick", + "tenant", tenant, "limit", rebuildRowLimit) + } if len(rows) == 0 { return @@ -242,6 +264,12 @@ func (g *GraphRAG) rebuildFromDBForTenant(_ context.Context, tenant string, sinc } } + // Advance the high-water-mark to the newest start_time merged. Rows are + // ordered ASC, so the last row carries the max; never move backwards. + if hwm := rows[len(rows)-1].StartTime.UnixNano(); hwm > stores.lastRebuildMax.Load() { + stores.lastRebuildMax.Store(hwm) + } + slog.Debug("GraphRAG rebuilt from DB", "tenant", tenant, "spans", len(rows), From b7090a3f97817e00d46d34c89a9588d45b8d196f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:13:50 +0000 Subject: [PATCH 24/61] test(retention): cover drainQuery error path and cancelled-context maintenance drainQuery must surface query errors; a cancelled context drives both SQLite maintenance error branches (PRAGMA optimize + vacuum step) and proves the overlap guard is released so the next tick still runs. Co-Authored-By: Claude Fable 5 --- internal/storage/retention_test.go | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/storage/retention_test.go b/internal/storage/retention_test.go index f3a8185..7d49b5a 100644 --- a/internal/storage/retention_test.go +++ b/internal/storage/retention_test.go @@ -230,6 +230,39 @@ func TestRetentionScheduler_MaintenanceFullVacuumWhenEnabled(t *testing.T) { } } +func TestDrainQuery_InvalidStatementReturnsError(t *testing.T) { + repo := newTestRepo(t) + sqlDB, err := repo.db.DB() + if err != nil { + t.Fatalf("raw sql.DB: %v", err) + } + if err := drainQuery(context.Background(), sqlDB, "PRAGMA definitely_not_a_pragma("); err == nil { + t.Fatal("drainQuery must surface query errors") + } +} + +// TestRetentionScheduler_MaintenanceCancelledContext drives both SQLite +// maintenance error branches (PRAGMA optimize + vacuum step) via an +// already-cancelled context: the scheduler must log-and-continue, release the +// overlap guard, and stay usable for the next tick. +func TestRetentionScheduler_MaintenanceCancelledContext(t *testing.T) { + repo := newFileTestRepo(t) + r := NewRetentionScheduler(repo, 7, 10_000, 0) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r.runMaintenance(ctx) + + if r.running.Load() { + t.Fatal("running flag must be released after a failed maintenance pass") + } + // A subsequent healthy pass must work. + r.runMaintenance(context.Background()) + if r.running.Load() { + t.Fatal("running flag must be released after the recovery pass") + } +} + func TestRetentionScheduler_NoDataNoError(t *testing.T) { repo := newTestRepo(t) r := NewRetentionScheduler(repo, 7, 10_000, 5*time.Millisecond) From 179996c2637435cb2971325f219144f10fb25eb1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:13:59 +0000 Subject: [PATCH 25/61] perf(api): ETag/304 + 10s TTL cache on hot polled endpoints /api/system/graph already had a 10s tenant-scoped cache but re-encoded the cached struct on every hit. It now stores the rendered JSON plus a sha256-derived strong ETag (hashed once per cache fill) and honours If-None-Match with a bodyless 304. /api/metrics/dashboard and /api/stats get the same pattern via a shared cachedJSON helper: - dashboard: key scoped by (tenant, raw query) so explicit start/end/service_name windows never share an entry; queries over 256 bytes bypass the cache to bound key cardinality. - stats: key scoped by tenant; the UI footer polls this and the COUNT(*) scans behind it are not free on a multi-GB SQLite file. Steady-state polling becomes a map lookup + hash compare instead of a SQLite query + JSON encode; clients echoing If-None-Match transfer no body at all. Also drops the unreachable /ws,/v1,/metrics skip loop in gzipEligible (the /api/ prefix gate subsumes it; the configurable MCP path keeps its explicit exclusion). Co-Authored-By: Claude Fable 5 --- internal/api/admin_handlers.go | 22 ++- internal/api/compress.go | 8 +- internal/api/compress_test.go | 65 +++++++++ internal/api/etag.go | 53 ++++++++ internal/api/etag_cache_test.go | 226 +++++++++++++++++++++++++++++++ internal/api/graph_handler.go | 23 ++-- internal/api/metrics_handlers.go | 29 +++- 7 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 internal/api/etag.go create mode 100644 internal/api/etag_cache_test.go diff --git a/internal/api/admin_handlers.go b/internal/api/admin_handlers.go index a4f4f67..b61aa7f 100644 --- a/internal/api/admin_handlers.go +++ b/internal/api/admin_handlers.go @@ -10,18 +10,34 @@ import ( "time" "github.com/RandomCodeSpace/otelcontext/internal/httpconst" + "github.com/RandomCodeSpace/otelcontext/internal/storage" ) -// handleGetStats handles GET /api/stats +// handleGetStats handles GET /api/stats. +// The rendered JSON is cached for 10s per tenant with an ETag — same +// pattern as handleGetSystemGraph. The UI footer polls this endpoint and +// the COUNT(*) scans behind it are not free on a multi-GB SQLite file. func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { + cacheKey := "db_stats:" + storage.TenantFromContext(r.Context()) + if cached, ok := s.cache.Get(cacheKey); ok { + cached.(*cachedJSON).write(w, r, "HIT") + return + } + stats, err := s.repo.GetStats(r.Context()) if err != nil { slog.Error("Failed to get DB stats", "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.Header().Set(httpconst.HeaderContentType, httpconst.ContentTypeJSON) - _ = json.NewEncoder(w).Encode(stats) + + cj, err := newCachedJSON(stats) + if err != nil { + http.Error(w, "failed to encode DB stats", http.StatusInternalServerError) + return + } + s.cache.Set(cacheKey, cj, hotPollCacheTTL) + cj.write(w, r, "MISS") } // handlePurge handles DELETE /api/admin/purge diff --git a/internal/api/compress.go b/internal/api/compress.go index 04a5cca..34ed74a 100644 --- a/internal/api/compress.go +++ b/internal/api/compress.go @@ -55,6 +55,9 @@ func GzipMiddleware(mcpPath string) func(http.Handler) http.Handler { } // gzipEligible reports whether the request may have its response gzipped. +// The /api/ prefix gate already excludes /ws*, /v1/*, and /metrics* — those +// prefixes cannot co-exist with /api/ — so only the MCP path (which an +// operator could configure under /api/) needs an explicit check. func gzipEligible(r *http.Request, mcpPath string) bool { if r.Method != http.MethodGet { return false @@ -63,11 +66,6 @@ func gzipEligible(r *http.Request, mcpPath string) bool { if !strings.HasPrefix(p, "/api/") { return false } - for _, skip := range []string{"/ws", "/v1/", "/metrics"} { - if strings.HasPrefix(p, skip) { - return false - } - } if p == mcpPath || strings.HasPrefix(p, mcpPath+"/") { return false } diff --git a/internal/api/compress_test.go b/internal/api/compress_test.go index 632fa20..ddfd29c 100644 --- a/internal/api/compress_test.go +++ b/internal/api/compress_test.go @@ -175,6 +175,71 @@ func TestGzipMiddleware_FlushSupported(t *testing.T) { } } +// TestGzipMiddleware_MCPPathExcluded covers the one skip that the /api/ +// prefix gate cannot subsume: an operator nesting the MCP endpoint under +// /api/. SSE frames must never be buffered inside a gzip stream. +func TestGzipMiddleware_MCPPathExcluded(t *testing.T) { + payload := strings.Repeat("event: ping\n\n", 200) + h := GzipMiddleware("/api/mcp")(jsonEcho(payload)) + for _, path := range []string{"/api/mcp", "/api/mcp/session"} { + rec := gzipGet(t, h, path, true) + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Errorf("%s: Content-Encoding = %q, want empty (MCP/SSE excluded)", path, got) + } + } + // Sibling API paths still compress. + rec := gzipGet(t, h, "/api/stats", true) + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("/api/stats: Content-Encoding = %q, want gzip", got) + } +} + +// TestGzipMiddleware_EmptyMCPPathDefaults covers the "" → "/mcp" fallback. +func TestGzipMiddleware_EmptyMCPPathDefaults(t *testing.T) { + h := GzipMiddleware("")(jsonEcho(strings.Repeat("a", 2048))) + rec := gzipGet(t, h, "/api/x", true) + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want gzip", got) + } +} + +func TestGzipMiddleware_DoubleWriteHeaderIgnored(t *testing.T) { + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + w.WriteHeader(http.StatusOK) // must be a no-op, like net/http + _, _ = w.Write([]byte("tea")) + })) + rec := gzipGet(t, h, "/api/tea", true) + if rec.Code != http.StatusTeapot { + t.Fatalf("want 418 (first WriteHeader wins), got %d", rec.Code) + } + if got := gunzip(t, rec.Body); got != "tea" { + t.Errorf("body = %q", got) + } +} + +func TestGzipMiddleware_FlushBeforeWrite(t *testing.T) { + h := GzipMiddleware("/mcp")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.(http.Flusher).Flush() // implicit 200 + gzip engagement + _, _ = w.Write([]byte("after-flush")) + })) + rec := gzipGet(t, h, "/api/early-flush", true) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if got := gunzip(t, rec.Body); got != "after-flush" { + t.Errorf("body = %q", got) + } +} + +func TestGzipResponseWriter_Unwrap(t *testing.T) { + rec := httptest.NewRecorder() + gw := &gzipResponseWriter{ResponseWriter: rec} + if gw.Unwrap() != rec { + t.Errorf("Unwrap should return the wrapped writer") + } +} + // TestGzipMiddleware_PoolReuseConcurrent hammers the middleware from many // goroutines to prove the sync.Pool recycling is race-free and never // cross-wires response bodies. diff --git a/internal/api/etag.go b/internal/api/etag.go new file mode 100644 index 0000000..be0508e --- /dev/null +++ b/internal/api/etag.go @@ -0,0 +1,53 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/httpconst" +) + +// hotPollCacheTTL is how long the rendered payloads of the hot polled +// endpoints (/api/system/graph, /api/metrics/dashboard, /api/stats) stay +// cached. 10s matches the UI poll cadence — steady-state polling becomes a +// map lookup plus an ETag compare instead of a SQLite query + JSON encode. +const hotPollCacheTTL = 10 * time.Second + +// maxCacheKeyQueryLen bounds cache-key cardinality for endpoints whose key +// includes the raw query string: anything longer skips the cache rather +// than letting a hostile client grow the map. +const maxCacheKeyQueryLen = 256 + +// cachedJSON is a fully rendered JSON response body plus its strong ETag. +// Marshalling and hashing happen once per cache fill; clients that echo +// If-None-Match then get a 304 with no body at all. +type cachedJSON struct { + body []byte + etag string +} + +func newCachedJSON(v any) (*cachedJSON, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + sum := sha256.Sum256(b) + return &cachedJSON{body: b, etag: `"` + hex.EncodeToString(sum[:8]) + `"`}, nil +} + +// write emits the cached payload, honouring If-None-Match with a 304. +// xCache is the cache-disposition header value ("HIT" or "MISS"). +func (c *cachedJSON) write(w http.ResponseWriter, r *http.Request, xCache string) { + h := w.Header() + h.Set("ETag", c.etag) + h.Set("X-Cache", xCache) + if httpconst.ETagMatch(r.Header.Get("If-None-Match"), c.etag) { + w.WriteHeader(http.StatusNotModified) + return + } + h.Set(httpconst.HeaderContentType, httpconst.ContentTypeJSON) + _, _ = w.Write(c.body) //nolint:gosec // G705 false positive: body is json.Marshal output of server-built values, served as application/json +} diff --git a/internal/api/etag_cache_test.go b/internal/api/etag_cache_test.go new file mode 100644 index 0000000..260e3cd --- /dev/null +++ b/internal/api/etag_cache_test.go @@ -0,0 +1,226 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/RandomCodeSpace/otelcontext/internal/cache" + "github.com/RandomCodeSpace/otelcontext/internal/storage" +) + +// newCachedTestServer builds a Server backed by a fresh in-memory SQLite +// repo with the TTL cache wired — the shape the cached hot-poll handlers +// (system graph, dashboard stats, DB stats) need. +func newCachedTestServer(t *testing.T) *Server { + t.Helper() + db, err := storage.NewDatabase("sqlite", ":memory:") + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if err := storage.AutoMigrateModels(db, "sqlite"); err != nil { + t.Fatalf("AutoMigrateModels: %v", err) + } + repo := storage.NewRepositoryFromDB(db, "sqlite") + c := cache.New() + t.Cleanup(func() { + c.Stop() + _ = repo.Close() + }) + return &Server{repo: repo, cache: c} +} + +// pollCacheFlow drives the shared MISS → HIT → 304 assertion sequence for a +// cached+ETagged endpoint. +func pollCacheFlow(t *testing.T, handler http.HandlerFunc, path string) { + t.Helper() + + do := func(inm string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + if inm != "" { + req.Header.Set("If-None-Match", inm) + } + rec := httptest.NewRecorder() + handler(rec, req) + return rec + } + + first := do("") + if first.Code != http.StatusOK { + t.Fatalf("first: want 200, got %d body=%q", first.Code, first.Body.String()) + } + if got := first.Header().Get("X-Cache"); got != "MISS" { + t.Errorf("first: X-Cache = %q, want MISS", got) + } + etag := first.Header().Get("ETag") + if etag == "" || !strings.HasPrefix(etag, `"`) { + t.Fatalf("first: ETag = %q, want quoted non-empty", etag) + } + if ct := first.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("first: Content-Type = %q", ct) + } + + second := do("") + if got := second.Header().Get("X-Cache"); got != "HIT" { + t.Errorf("second: X-Cache = %q, want HIT (within 10s TTL)", got) + } + if got := second.Header().Get("ETag"); got != etag { + t.Errorf("second: ETag = %q, want %q", got, etag) + } + if second.Body.String() != first.Body.String() { + t.Errorf("second: cached body differs from first") + } + + third := do(etag) + if third.Code != http.StatusNotModified { + t.Fatalf("third: want 304 with If-None-Match, got %d", third.Code) + } + if third.Body.Len() != 0 { + t.Errorf("third: 304 body should be empty, got %q", third.Body.String()) + } + if got := third.Header().Get("ETag"); got != etag { + t.Errorf("third: 304 ETag = %q, want %q", got, etag) + } +} + +func TestHandleGetStats_CacheAndETag(t *testing.T) { + s := newCachedTestServer(t) + pollCacheFlow(t, s.handleGetStats, "/api/stats") +} + +func TestHandleGetDashboardStats_CacheAndETag(t *testing.T) { + s := newCachedTestServer(t) + pollCacheFlow(t, s.handleGetDashboardStats, "/api/metrics/dashboard") +} + +func TestHandleGetSystemGraph_CacheAndETag(t *testing.T) { + // graph and graphRAG are nil → the handler exercises the DB fallback + // path against the empty repo, which still yields a valid response. + s := newCachedTestServer(t) + pollCacheFlow(t, s.handleGetSystemGraph, "/api/system/graph") +} + +// TestHandleGetDashboardStats_QueryScopedKey proves distinct query strings +// never share a cache entry. +func TestHandleGetDashboardStats_QueryScopedKey(t *testing.T) { + s := newCachedTestServer(t) + + do := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + s.handleGetDashboardStats(rec, req) + return rec + } + + if got := do("/api/metrics/dashboard?service_name=a").Header().Get("X-Cache"); got != "MISS" { + t.Errorf("first ?service_name=a: X-Cache = %q, want MISS", got) + } + if got := do("/api/metrics/dashboard?service_name=b").Header().Get("X-Cache"); got != "MISS" { + t.Errorf("first ?service_name=b: X-Cache = %q, want MISS (different query must not share entry)", got) + } + if got := do("/api/metrics/dashboard?service_name=a").Header().Get("X-Cache"); got != "HIT" { + t.Errorf("second ?service_name=a: X-Cache = %q, want HIT", got) + } +} + +// TestHandleGetDashboardStats_LongQueryBypassesCache guards the cache-key +// cardinality bound: pathological query strings skip the cache entirely +// instead of growing the map. +func TestHandleGetDashboardStats_LongQueryBypassesCache(t *testing.T) { + s := newCachedTestServer(t) + path := "/api/metrics/dashboard?service_name=" + strings.Repeat("x", 300) + + for i := range 2 { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + s.handleGetDashboardStats(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("request %d: want 200, got %d", i, rec.Code) + } + if got := rec.Header().Get("X-Cache"); got == "HIT" { + t.Errorf("request %d: oversized query must bypass the cache, got X-Cache HIT", i) + } + } +} + +// TestHandleGetDashboardStats_ExplicitTimeRange exercises the start/end +// query parsing alongside the cache: a parameterised window is served and +// cached under its own key. +func TestHandleGetDashboardStats_ExplicitTimeRange(t *testing.T) { + s := newCachedTestServer(t) + path := "/api/metrics/dashboard?start=2026-06-11T00:00:00Z&end=2026-06-11T01:00:00Z" + + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + s.handleGetDashboardStats(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d body=%q", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("X-Cache"); got != "MISS" { + t.Errorf("first: X-Cache = %q, want MISS", got) + } + + rec2 := httptest.NewRecorder() + s.handleGetDashboardStats(rec2, httptest.NewRequest(http.MethodGet, path, nil)) + if got := rec2.Header().Get("X-Cache"); got != "HIT" { + t.Errorf("second: X-Cache = %q, want HIT", got) + } +} + +// Repo-error paths: a closed DB makes every repository call fail, which +// must surface as a 500 (graph handler: its DB fallback returns nil). +func TestCachedHandlers_DBErrorPaths(t *testing.T) { + tests := []struct { + name string + path string + handler func(s *Server) http.HandlerFunc + }{ + {"stats", "/api/stats", func(s *Server) http.HandlerFunc { return s.handleGetStats }}, + {"dashboard", "/api/metrics/dashboard", func(s *Server) http.HandlerFunc { return s.handleGetDashboardStats }}, + {"system graph", "/api/system/graph", func(s *Server) http.HandlerFunc { return s.handleGetSystemGraph }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newCachedTestServer(t) + _ = s.repo.Close() // force every query to error + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + tt.handler(s)(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("want 500 on closed DB, got %d", rec.Code) + } + }) + } +} + +func TestNewCachedJSON_MarshalError(t *testing.T) { + if _, err := newCachedJSON(make(chan int)); err == nil { + t.Fatal("want error for unmarshalable value") + } +} + +// TestHandleGetStats_TenantScopedKey proves two tenants never share a +// cached /api/stats payload. +func TestHandleGetStats_TenantScopedKey(t *testing.T) { + s := newCachedTestServer(t) + + do := func(tenant string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/stats", nil) + req = req.WithContext(storage.WithTenantContext(req.Context(), tenant)) + rec := httptest.NewRecorder() + s.handleGetStats(rec, req) + return rec + } + + if got := do("tenant-a").Header().Get("X-Cache"); got != "MISS" { + t.Errorf("tenant-a first: X-Cache = %q, want MISS", got) + } + if got := do("tenant-b").Header().Get("X-Cache"); got != "MISS" { + t.Errorf("tenant-b first: X-Cache = %q, want MISS (tenant isolation)", got) + } + if got := do("tenant-a").Header().Get("X-Cache"); got != "HIT" { + t.Errorf("tenant-a second: X-Cache = %q, want HIT", got) + } +} diff --git a/internal/api/graph_handler.go b/internal/api/graph_handler.go index 1ee9826..f7bc3eb 100644 --- a/internal/api/graph_handler.go +++ b/internal/api/graph_handler.go @@ -2,7 +2,6 @@ package api import ( "context" - "encoding/json" "log/slog" "math" "net/http" @@ -65,17 +64,16 @@ var OtelContextStartTime = time.Now() // handleGetSystemGraph handles GET /api/system/graph. // When the in-memory graph has been populated it returns instantly from memory. // Falls back to a DB query only when the graph has never been built yet. -// Results are cached for 10s per tenant — the cache key is scoped by tenant -// so two tenants hitting this endpoint never share a response. +// The rendered JSON is cached for 10s per tenant — the cache key is scoped +// by tenant so two tenants never share a response — and carries an ETag +// hashed once per cache fill, so a polling client that echoes If-None-Match +// gets a bodyless 304. func (s *Server) handleGetSystemGraph(w http.ResponseWriter, r *http.Request) { - const cacheTTL = 10 * time.Second ctx := r.Context() cacheKey := "system_graph:" + storage.TenantFromContext(ctx) if cached, ok := s.cache.Get(cacheKey); ok { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Cache", "HIT") - _ = json.NewEncoder(w).Encode(cached) + cached.(*cachedJSON).write(w, r, "HIT") return } @@ -89,10 +87,13 @@ func (s *Server) handleGetSystemGraph(w http.ResponseWriter, r *http.Request) { } } - s.cache.Set(cacheKey, resp, cacheTTL) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Cache", "MISS") - _ = json.NewEncoder(w).Encode(resp) + cj, err := newCachedJSON(resp) + if err != nil { + http.Error(w, "failed to encode system graph", http.StatusInternalServerError) + return + } + s.cache.Set(cacheKey, cj, hotPollCacheTTL) + cj.write(w, r, "MISS") } // buildGraphFromMemory converts the in-memory graph snapshot to the API response. diff --git a/internal/api/metrics_handlers.go b/internal/api/metrics_handlers.go index 6b38ab6..3749d2a 100644 --- a/internal/api/metrics_handlers.go +++ b/internal/api/metrics_handlers.go @@ -8,6 +8,7 @@ import ( "github.com/RandomCodeSpace/otelcontext/internal/api/views" "github.com/RandomCodeSpace/otelcontext/internal/httpconst" + "github.com/RandomCodeSpace/otelcontext/internal/storage" ) // handleGetTrafficMetrics handles GET /api/metrics/traffic @@ -69,8 +70,23 @@ func (s *Server) handleGetLatencyHeatmap(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(points) } -// handleGetDashboardStats handles GET /api/metrics/dashboard +// handleGetDashboardStats handles GET /api/metrics/dashboard. +// The rendered JSON is cached for 10s per (tenant, query) with an ETag — +// same pattern as handleGetSystemGraph — so steady-state dashboard polling +// becomes a hash compare instead of a SQLite aggregate + JSON encode. The +// key includes the raw query string so explicit start/end/service_name +// windows never share an entry; oversized queries skip the cache (see +// maxCacheKeyQueryLen). func (s *Server) handleGetDashboardStats(w http.ResponseWriter, r *http.Request) { + var cacheKey string + if len(r.URL.RawQuery) <= maxCacheKeyQueryLen { + cacheKey = "dashboard_stats:" + storage.TenantFromContext(r.Context()) + "?" + r.URL.RawQuery + if cached, ok := s.cache.Get(cacheKey); ok { + cached.(*cachedJSON).write(w, r, "HIT") + return + } + } + // Default to last 30 minutes if not specified end := time.Now() start := end.Add(-30 * time.Minute) @@ -95,8 +111,15 @@ func (s *Server) handleGetDashboardStats(w http.ResponseWriter, r *http.Request) return } - w.Header().Set(httpconst.HeaderContentType, httpconst.ContentTypeJSON) - _ = json.NewEncoder(w).Encode(views.DashboardStatsFromModel(stats)) + cj, err := newCachedJSON(views.DashboardStatsFromModel(stats)) + if err != nil { + http.Error(w, "failed to encode dashboard stats", http.StatusInternalServerError) + return + } + if cacheKey != "" { + s.cache.Set(cacheKey, cj, hotPollCacheTTL) + } + cj.write(w, r, "MISS") } // handleGetServiceMapMetrics handles GET /api/metrics/service-map From d0b337b52edb52e6fd9b8ca2d98e8ac61e70bb49 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:15:35 +0000 Subject: [PATCH 26/61] perf(graphrag): gate the 10s anomaly scan on tenant ingest activity (P2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each event path (processSpan/processLog/processMetric) stamps the tenant's lastEventAt; detectAnomalies records its own start time and skips tenants whose lastEventAt predates the previous tick — no events means their service/metric stats cannot have changed, so re-walking them only burned CPU and refreshed anomaly timestamps spuriously. The first scan after startup always runs so DB-rebuilt state is examined at least once. Co-Authored-By: Claude Fable 5 --- internal/graphrag/anomaly.go | 8 ++ internal/graphrag/anomaly_gating_test.go | 112 +++++++++++++++++++++++ internal/graphrag/builder.go | 8 ++ 3 files changed, 128 insertions(+) create mode 100644 internal/graphrag/anomaly_gating_test.go diff --git a/internal/graphrag/anomaly.go b/internal/graphrag/anomaly.go index 05f3bef..13e5b3e 100644 --- a/internal/graphrag/anomaly.go +++ b/internal/graphrag/anomaly.go @@ -12,8 +12,16 @@ import ( // tenant slice we walk the ServiceStore and SignalStore under their own // locks and emit anomalies into that tenant's AnomalyStore. func (g *GraphRAG) detectAnomalies(ctx context.Context) { + prevScan := g.lastAnomalyScan.Swap(time.Now().UnixNano()) tenants := g.snapshotTenants() for tenant, stores := range tenants { + // Gate: a tenant with no span/log/metric processed since the + // previous scan cannot have changed stats — skip the whole walk. + // The first scan after startup (prevScan == 0) always runs so + // DB-rebuilt state is examined at least once. + if prevScan != 0 && stores.lastEventAt.Load() < prevScan { + continue + } tctx := storage.WithTenantContext(ctx, tenant) g.detectAnomaliesForTenant(tctx, tenant, stores) } diff --git a/internal/graphrag/anomaly_gating_test.go b/internal/graphrag/anomaly_gating_test.go new file mode 100644 index 0000000..37d672d --- /dev/null +++ b/internal/graphrag/anomaly_gating_test.go @@ -0,0 +1,112 @@ +package graphrag + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" + "github.com/RandomCodeSpace/otelcontext/internal/tsdb" +) + +// feedErrorSpan pushes one ERROR span through processSpan synchronously so +// the default tenant's ServiceStore error rate trips the spike detector. +func feedErrorSpan(g *GraphRAG, spanID string) { + g.processSpan(&spanEvent{ + Span: storage.Span{ + TraceID: "trace-gate", + SpanID: spanID, + OperationName: "/op", + ServiceName: "orders", + StartTime: time.Now(), + }, + TraceID: "trace-gate", + Status: "STATUS_CODE_ERROR", + Tenant: storage.DefaultTenantID, + }) +} + +// deleteAnomaly removes one anomaly node so a later scan provably re-creates +// it (or provably does not, when the tenant is gated). +func deleteAnomaly(stores *tenantStores, id string) { + stores.anomalies.mu.Lock() + delete(stores.anomalies.Anomalies, id) + stores.anomalies.mu.Unlock() +} + +// TestDetectAnomalies_SkipsTenantsWithoutNewEvents proves the scan gate: +// a tenant with no span/log/metric processed since the previous tick is +// skipped entirely, and resumes being scanned as soon as an event lands. +func TestDetectAnomalies_SkipsTenantsWithoutNewEvents(t *testing.T) { + g := New(nil, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + ctx := context.Background() + + for i := 0; i < 10; i++ { + feedErrorSpan(g, fmt.Sprintf("s-%d", i)) + } + + // Scan 1 (first ever): always runs, detects the error spike. + g.detectAnomalies(ctx) + stores := g.storesForTenant(storage.DefaultTenantID) + if _, ok := stores.anomalies.Anomalies["anom_orders_err"]; !ok { + t.Fatalf("scan 1 did not detect the error spike") + } + + // Scan 2: no events since scan 1 — the tenant must be skipped, so a + // deleted anomaly stays gone. + deleteAnomaly(stores, "anom_orders_err") + g.detectAnomalies(ctx) + if _, ok := stores.anomalies.Anomalies["anom_orders_err"]; ok { + t.Fatalf("scan 2 re-created the anomaly — idle tenant was not skipped") + } + + // Scan 3: a fresh event re-arms the tenant and detection resumes. + feedErrorSpan(g, "s-rearm") + g.detectAnomalies(ctx) + if _, ok := stores.anomalies.Anomalies["anom_orders_err"]; !ok { + t.Fatalf("scan 3 skipped an active tenant — gate stuck") + } +} + +// TestProcessEvents_StampLastEventAt proves all three event paths arm the +// anomaly-scan gate. +func TestProcessEvents_StampLastEventAt(t *testing.T) { + g := New(nil, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + cases := []struct { + name string + tenant string + fire func(tenant string) + }{ + {"span", "tenant-span", func(tn string) { + g.processSpan(&spanEvent{ + Span: storage.Span{TraceID: "t", SpanID: "s", ServiceName: "svc", StartTime: time.Now()}, + TraceID: "t", Status: "STATUS_CODE_UNSET", Tenant: tn, + }) + }}, + {"log", "tenant-log", func(tn string) { + g.processLog(&logEvent{ + Log: storage.Log{ServiceName: "svc", Body: "boom failed id=1", Severity: "ERROR", Timestamp: time.Now()}, + Tenant: tn, + }) + }}, + {"metric", "tenant-metric", func(tn string) { + g.processMetric(&metricEvent{ + Metric: tsdb.RawMetric{Name: "cpu", ServiceName: "svc", Value: 1.0, Timestamp: time.Now()}, + Tenant: tn, + }) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.fire(tc.tenant) + st := g.storesForTenant(tc.tenant) + if st.lastEventAt.Load() == 0 { + t.Fatalf("%s event did not stamp lastEventAt", tc.name) + } + }) + } +} diff --git a/internal/graphrag/builder.go b/internal/graphrag/builder.go index c43f3c4..7bd7ec4 100644 --- a/internal/graphrag/builder.go +++ b/internal/graphrag/builder.go @@ -138,6 +138,11 @@ type GraphRAG struct { // invInserts counts cooldown-allowed PersistInvestigation calls. // Incremented BEFORE the DB write — see InvestigationInsertCount. invInserts atomic.Int64 + + // lastAnomalyScan is the unix-nano start time of the previous + // detectAnomalies pass. Tenants whose lastEventAt predates it are + // skipped — no events means their stats cannot have changed. + lastAnomalyScan atomic.Int64 } // SetMetrics wires the Prometheus registry so GraphRAG event drops are @@ -477,6 +482,7 @@ func (g *GraphRAG) processSpan(ev *spanEvent) { } stores := g.storesForTenant(ev.Tenant) + stores.lastEventAt.Store(time.Now().UnixNano()) // 1. Upsert ServiceNode stores.service.UpsertService(span.ServiceName, durationMs, isError, span.StartTime) @@ -522,6 +528,7 @@ func (g *GraphRAG) processLog(ev *logEvent) { } stores := g.storesForTenant(ev.Tenant) + stores.lastEventAt.Store(time.Now().UnixNano()) // Drain-based clustering (replaces hash+TF-IDF clustering). The Drain // miner is shared across tenants — its template tokens describe log shape, @@ -545,6 +552,7 @@ func (g *GraphRAG) processMetric(ev *metricEvent) { return } stores := g.storesForTenant(ev.Tenant) + stores.lastEventAt.Store(time.Now().UnixNano()) stores.signals.UpsertMetric(m.Name, m.ServiceName, m.Value, m.Timestamp) } From 4918c2adbb445dae75787df49342dd99405e4a8b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:15:39 +0000 Subject: [PATCH 27/61] fix(storage): gofmt factory_test table, drop unused nolint directive Co-Authored-By: Claude Fable 5 --- internal/storage/factory_test.go | 4 ++-- internal/storage/retention.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/storage/factory_test.go b/internal/storage/factory_test.go index ca7a26b..b8ff4e0 100644 --- a/internal/storage/factory_test.go +++ b/internal/storage/factory_test.go @@ -188,8 +188,8 @@ func TestNewDatabase_SQLitePragmas_BudgetScaledRoundTrip(t *testing.T) { pragma string want int64 }{ - {"synchronous", 1}, // NORMAL - {"temp_store", 2}, // MEMORY + {"synchronous", 1}, // NORMAL + {"temp_store", 2}, // MEMORY {"wal_autocheckpoint", 10000}, {"journal_size_limit", 67108864}, {"busy_timeout", 5000}, diff --git a/internal/storage/retention.go b/internal/storage/retention.go index ef7af68..ca910cd 100644 --- a/internal/storage/retention.go +++ b/internal/storage/retention.go @@ -92,7 +92,7 @@ func sqliteVacuumStatement(fullVacuum bool) string { // PRAGMA incremental_vacuum performs its work per step (one freed page per // row), so the cursor must be walked to completion for the reclaim to happen. func drainQuery(ctx context.Context, db *sql.DB, query string) error { - rows, err := db.QueryContext(ctx, query) //nolint:sqlclosecheck // closed below; no rows.Close() lint seam for drain loops + rows, err := db.QueryContext(ctx, query) if err != nil { return err } From 65a83b321d70014eef989e30d2eda72bb5737cc1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:17:18 +0000 Subject: [PATCH 28/61] refactor(httpconst): hoist Content-Encoding header name to shared const Sonar S1192 flagged the literal duplicated three times in the UI asset server; the gzip middleware used it twice more. Same treatment as the existing HeaderContentType. Co-Authored-By: Claude Fable 5 --- internal/api/compress.go | 4 ++-- internal/httpconst/httpconst.go | 4 ++++ internal/ui/ui.go | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/api/compress.go b/internal/api/compress.go index 34ed74a..c41556a 100644 --- a/internal/api/compress.go +++ b/internal/api/compress.go @@ -89,8 +89,8 @@ func (w *gzipResponseWriter) WriteHeader(code int) { } w.wroteHeader = true h := w.Header() - if code != http.StatusNoContent && code != http.StatusNotModified && h.Get("Content-Encoding") == "" { - h.Set("Content-Encoding", "gzip") + if code != http.StatusNoContent && code != http.StatusNotModified && h.Get(httpconst.HeaderContentEncoding) == "" { + h.Set(httpconst.HeaderContentEncoding, "gzip") h.Del("Content-Length") gz := gzipWriterPool.Get().(*gzip.Writer) gz.Reset(w.ResponseWriter) diff --git a/internal/httpconst/httpconst.go b/internal/httpconst/httpconst.go index 9d28c1f..42cd107 100644 --- a/internal/httpconst/httpconst.go +++ b/internal/httpconst/httpconst.go @@ -7,6 +7,10 @@ const ( // HeaderContentType is the canonical HTTP Content-Type header name. HeaderContentType = "Content-Type" + // HeaderContentEncoding is the canonical HTTP Content-Encoding header + // name, shared by the UI asset server and the API gzip middleware. + HeaderContentEncoding = "Content-Encoding" + // ContentTypeJSON is the application/json content type used by every JSON // response on the API and MCP surface. ContentTypeJSON = "application/json" diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 3b93b99..836171d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -186,10 +186,10 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) { body := h.indexPlain switch { case h.indexBr != nil && httpconst.AcceptsEncoding(r, "br"): - hdr.Set("Content-Encoding", "br") + hdr.Set(httpconst.HeaderContentEncoding, "br") body = h.indexBr case h.indexGz != nil && httpconst.AcceptsEncoding(r, "gzip"): - hdr.Set("Content-Encoding", "gzip") + hdr.Set(httpconst.HeaderContentEncoding, "gzip") body = h.indexGz } writeBody(w, r, body, "text/html; charset=utf-8") @@ -209,7 +209,7 @@ func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, name str hdr.Set("Cache-Control", "public, max-age=31536000, immutable") hdr.Set("Vary", "Accept-Encoding") if encoding != "" { - hdr.Set("Content-Encoding", encoding) + hdr.Set(httpconst.HeaderContentEncoding, encoding) } ctype := mime.TypeByExtension(path.Ext(name)) if ctype == "" { From 7ea6c2f2c958d4ac7dd85c0562eb8438c78b5558 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:18:13 +0000 Subject: [PATCH 29/61] =?UTF-8?q?feat(ui):=20data-layer=20foundation=20?= =?UTF-8?q?=E2=80=94=20formatters,=20fetch=20wrapper,=20query=20client,=20?= =?UTF-8?q?WS=20singleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/format.ts: central percent/duration/count/size formatters fixing the two audit bugs (error_rate 0.042 -> "4.2%", health_score 0.73 -> "73%") with explicit ratio/percent/auto units. - lib/apiFetch.ts: the one fetch wrapper — AbortSignal passthrough, JSON parse, ApiError normalization (status 0 = network), aborts re-thrown untouched. - lib/queryClient.ts: staleTime 10s (matches server graph TTL), gcTime 5min, refetchIntervalInBackground:false, retry 2 with jittered exponential backoff. - lib/wsManager.ts: module-level /ws singleton porting the backoff/heartbeat/visibility logic out of useWebSocket, adding +20% reconnect jitter, a 5000-cap log ring buffer, a 250ms-coalesced version counter, and useSyncExternalStore subscribe/snapshot pairs. Fixes a latent hook bug: the dead-connection watchdog was re-armed on every ping, so its deadline slid forever and never fired. 46 tests (fake timers + mock WebSocket). Co-Authored-By: Claude Fable 5 --- ui/src/lib/__tests__/apiFetch.test.ts | 83 ++++++ ui/src/lib/__tests__/format.test.ts | 96 +++++++ ui/src/lib/__tests__/queryClient.test.ts | 52 ++++ ui/src/lib/__tests__/wsManager.test.ts | 334 +++++++++++++++++++++++ ui/src/lib/apiFetch.ts | 66 +++++ ui/src/lib/format.ts | 76 ++++++ ui/src/lib/queryClient.ts | 36 +++ ui/src/lib/wsManager.ts | 332 ++++++++++++++++++++++ 8 files changed, 1075 insertions(+) create mode 100644 ui/src/lib/__tests__/apiFetch.test.ts create mode 100644 ui/src/lib/__tests__/format.test.ts create mode 100644 ui/src/lib/__tests__/queryClient.test.ts create mode 100644 ui/src/lib/__tests__/wsManager.test.ts create mode 100644 ui/src/lib/apiFetch.ts create mode 100644 ui/src/lib/format.ts create mode 100644 ui/src/lib/queryClient.ts create mode 100644 ui/src/lib/wsManager.ts diff --git a/ui/src/lib/__tests__/apiFetch.test.ts b/ui/src/lib/__tests__/apiFetch.test.ts new file mode 100644 index 0000000..0e4751c --- /dev/null +++ b/ui/src/lib/__tests__/apiFetch.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError, apiFetch, apiFetchWithResponse } from '../apiFetch' + +const fetchMock = vi.fn() + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() +}) + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + ...init, + }) +} + +describe('apiFetch', () => { + it('returns parsed JSON on success', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })) + await expect(apiFetch<{ ok: boolean }>('/api/x')).resolves.toEqual({ + ok: true, + }) + expect(fetchMock).toHaveBeenCalledWith('/api/x', expect.any(Object)) + }) + + it('forwards the AbortSignal to fetch', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})) + const controller = new AbortController() + await apiFetch('/api/x', { signal: controller.signal }) + const init = fetchMock.mock.calls[0][1] + expect(init?.signal).toBe(controller.signal) + }) + + it('throws ApiError carrying the HTTP status on non-2xx', async () => { + fetchMock.mockResolvedValueOnce( + new Response('{"error":"boom"}', { status: 503 }), + ) + const err = await apiFetch('/api/x').catch((e: unknown) => e) + expect(err).toBeInstanceOf(ApiError) + expect((err as ApiError).status).toBe(503) + expect((err as ApiError).message).toContain('503') + }) + + it('normalizes network failures into ApiError with status 0', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')) + const err = await apiFetch('/api/x').catch((e: unknown) => e) + expect(err).toBeInstanceOf(ApiError) + expect((err as ApiError).status).toBe(0) + expect((err as ApiError).message).toBe('Failed to fetch') + }) + + it('re-throws aborts untouched so callers/Query can recognize them', async () => { + const abort = new DOMException('Aborted', 'AbortError') + fetchMock.mockRejectedValueOnce(abort) + await expect(apiFetch('/api/x')).rejects.toBe(abort) + }) + + it('throws ApiError when the body is not valid JSON', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })) + const err = await apiFetch('/api/x').catch((e: unknown) => e) + expect(err).toBeInstanceOf(ApiError) + expect((err as ApiError).status).toBe(200) + }) +}) + +describe('apiFetchWithResponse', () => { + it('exposes the Response alongside the parsed body', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ n: 1 }, { headers: { 'X-Cache': 'HIT' } }), + ) + const { data, response } = await apiFetchWithResponse<{ n: number }>( + '/api/system/graph', + ) + expect(data).toEqual({ n: 1 }) + expect(response.headers.get('X-Cache')).toBe('HIT') + }) +}) diff --git a/ui/src/lib/__tests__/format.test.ts b/ui/src/lib/__tests__/format.test.ts new file mode 100644 index 0000000..7d3b79b --- /dev/null +++ b/ui/src/lib/__tests__/format.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + formatCount, + formatMb, + formatMs, + formatPercent, +} from '../format' + +describe('formatPercent', () => { + // The two audit bugs this module exists to fix, verbatim: + it('renders a 0–1 error-rate ratio as a percentage (audit bug #1)', () => { + expect(formatPercent(0.042)).toBe('4.2%') + }) + + it('renders a 0–1 health score without a stray decimal (audit bug #2)', () => { + expect(formatPercent(0.73)).toBe('73%') + }) + + it('trims trailing zeros but keeps meaningful decimals', () => { + expect(formatPercent(1)).toBe('100%') + expect(formatPercent(0)).toBe('0%') + expect(formatPercent(0.005)).toBe('0.5%') + expect(formatPercent(0.9999, 1)).toBe('100%') + }) + + it('respects an explicit digits argument', () => { + expect(formatPercent(0.04217, 2)).toBe('4.22%') + expect(formatPercent(0.73, 0)).toBe('73%') + }) + + it('treats values already scaled to percent via unit "percent"', () => { + // DashboardStats.error_rate arrives pre-multiplied by 100 server-side. + expect(formatPercent(4.2, 1, 'percent')).toBe('4.2%') + expect(formatPercent(0.5, 1, 'percent')).toBe('0.5%') + }) + + it('unit "auto" applies the codebase ≤1 heuristic for loose fields', () => { + expect(formatPercent(0.042, 1, 'auto')).toBe('4.2%') + expect(formatPercent(4.2, 1, 'auto')).toBe('4.2%') + }) + + it('returns the em dash for non-finite input', () => { + expect(formatPercent(Number.NaN)).toBe('—') + expect(formatPercent(Number.POSITIVE_INFINITY)).toBe('—') + }) +}) + +describe('formatMs', () => { + it('keeps sub-second values in milliseconds', () => { + expect(formatMs(230)).toBe('230ms') + expect(formatMs(0)).toBe('0ms') + expect(formatMs(0.4)).toBe('0.4ms') + }) + + it('rolls up to seconds and minutes', () => { + expect(formatMs(1500)).toBe('1.5s') + expect(formatMs(60_000)).toBe('1m') + expect(formatMs(90_000)).toBe('1.5m') + }) + + it('returns the em dash for non-finite input', () => { + expect(formatMs(Number.NaN)).toBe('—') + }) +}) + +describe('formatCount', () => { + it('abbreviates thousands and millions', () => { + expect(formatCount(950)).toBe('950') + expect(formatCount(1_200)).toBe('1.2K') + expect(formatCount(3_400_000)).toBe('3.4M') + }) + + it('trims trailing zeros on round thousands', () => { + expect(formatCount(2_000)).toBe('2K') + }) + + it('returns the em dash for non-finite input', () => { + expect(formatCount(Number.NaN)).toBe('—') + }) +}) + +describe('formatMb', () => { + it('keeps small sizes in MB and rolls up to GB', () => { + expect(formatMb(840)).toBe('840MB') + expect(formatMb(1_228.8)).toBe('1.2GB') + }) + + it('shows one decimal under 10MB', () => { + expect(formatMb(3.4)).toBe('3.4MB') + }) + + it('returns the em dash for non-finite input', () => { + expect(formatMb(Number.NaN)).toBe('—') + expect(formatMb(undefined)).toBe('—') + }) +}) diff --git a/ui/src/lib/__tests__/queryClient.test.ts b/ui/src/lib/__tests__/queryClient.test.ts new file mode 100644 index 0000000..2f33ab9 --- /dev/null +++ b/ui/src/lib/__tests__/queryClient.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { createQueryClient, retryDelayWithJitter } from '../queryClient' + +describe('createQueryClient', () => { + it('aligns staleTime to the server-side 10s cache TTL', () => { + const qc = createQueryClient() + const defaults = qc.getDefaultOptions().queries + expect(defaults?.staleTime).toBe(10_000) + }) + + it('garbage-collects after 5 minutes', () => { + const qc = createQueryClient() + expect(qc.getDefaultOptions().queries?.gcTime).toBe(5 * 60_000) + }) + + it('stops polling in hidden tabs (refetchIntervalInBackground false)', () => { + const qc = createQueryClient() + expect(qc.getDefaultOptions().queries?.refetchIntervalInBackground).toBe( + false, + ) + }) + + it('retries transient failures a bounded number of times', () => { + const qc = createQueryClient() + expect(qc.getDefaultOptions().queries?.retry).toBe(2) + }) +}) + +describe('retryDelayWithJitter', () => { + it('grows exponentially with the attempt index', () => { + // Jitter adds at most 20%, so attempt boundaries cannot overlap. + const d0 = retryDelayWithJitter(0) + const d2 = retryDelayWithJitter(2) + expect(d0).toBeGreaterThanOrEqual(1000) + expect(d0).toBeLessThanOrEqual(1200) + expect(d2).toBeGreaterThanOrEqual(4000) + expect(d2).toBeLessThanOrEqual(4800) + }) + + it('caps the base delay at 30s', () => { + const d = retryDelayWithJitter(10) + expect(d).toBeGreaterThanOrEqual(30_000) + expect(d).toBeLessThanOrEqual(36_000) + }) + + it('is jittered (not deterministic across calls)', () => { + const samples = new Set( + Array.from({ length: 32 }, () => retryDelayWithJitter(3)), + ) + expect(samples.size).toBeGreaterThan(1) + }) +}) diff --git a/ui/src/lib/__tests__/wsManager.test.ts b/ui/src/lib/__tests__/wsManager.test.ts new file mode 100644 index 0000000..c8c3532 --- /dev/null +++ b/ui/src/lib/__tests__/wsManager.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LogEntry } from '@/types/api' +import { WsManager } from '../wsManager' + +// Local WebSocket mock (same shape as the one in useWebSocket.test.ts — +// kept file-local so neither suite depends on shared mutable test state). +class MockWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + + static readonly instances: MockWebSocket[] = [] + + readyState = MockWebSocket.CONNECTING + url: string + onopen: ((ev: Event) => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + onclose: ((ev: CloseEvent) => void) | null = null + send = vi.fn<(data: string) => void>() + close = vi.fn<() => void>(() => { + this.readyState = MockWebSocket.CLOSED + queueMicrotask(() => this.onclose?.(new CloseEvent('close'))) + }) + + constructor(url: string) { + this.url = url + MockWebSocket.instances.push(this) + } + + simulateOpen() { + this.readyState = MockWebSocket.OPEN + this.onopen?.(new Event('open')) + } + + simulateClose() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.(new CloseEvent('close')) + } + + simulateMessage(data: unknown) { + this.onmessage?.( + new MessageEvent('message', { data: JSON.stringify(data) }), + ) + } +} + +const sockets = () => MockWebSocket.instances +const latest = () => MockWebSocket.instances[MockWebSocket.instances.length - 1] + +function logEntry(id: number): LogEntry { + return { + id, + trace_id: '', + span_id: '', + severity: 'INFO', + body: `log ${id}`, + service_name: 'svc', + attributes_json: '', + timestamp: '2026-06-11T00:00:00Z', + } +} + +function setVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }) + document.dispatchEvent(new Event('visibilitychange')) +} + +let manager: WsManager | null = null + +beforeEach(() => { + MockWebSocket.instances.length = 0 + vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket) + vi.useFakeTimers() +}) + +afterEach(() => { + manager?.stop() + manager = null + vi.useRealTimers() + vi.unstubAllGlobals() + setVisibility('visible') +}) + +describe('WsManager connection lifecycle', () => { + it('connects to /ws and reports connected on open', () => { + manager = new WsManager() + manager.start() + + expect(sockets()).toHaveLength(1) + expect(latest().url).toMatch(/\/ws$/) + expect(manager.getStatusSnapshot().status).toBe('connecting') + + latest().simulateOpen() + expect(manager.getStatusSnapshot()).toEqual({ + status: 'connected', + attempt: 0, + }) + }) + + it('start() is idempotent — one socket per manager', () => { + manager = new WsManager() + manager.start() + manager.start() + expect(sockets()).toHaveLength(1) + }) + + it('schedules an exponential reconnect after close (jitter floor)', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + manager = new WsManager() + manager.start() + latest().simulateOpen() + + latest().simulateClose() + expect(manager.getStatusSnapshot().status).toBe('reconnecting') + expect(manager.getStatusSnapshot().attempt).toBe(1) + + vi.advanceTimersByTime(99) + expect(sockets()).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(sockets()).toHaveLength(2) + + // Second failure → 200ms. + latest().simulateClose() + expect(manager.getStatusSnapshot().attempt).toBe(2) + vi.advanceTimersByTime(199) + expect(sockets()).toHaveLength(2) + vi.advanceTimersByTime(1) + expect(sockets()).toHaveLength(3) + }) + + it('adds up to 20% jitter to the backoff delay', () => { + vi.spyOn(Math, 'random').mockReturnValue(1) + manager = new WsManager() + manager.start() + latest().simulateClose() + + // delay = 100 + 100 * 0.2 = 120ms at the jitter ceiling. + vi.advanceTimersByTime(119) + expect(sockets()).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(sockets()).toHaveLength(2) + }) + + it('caps the backoff base at maxBackoffMs', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + manager = new WsManager({ initialBackoffMs: 100, maxBackoffMs: 300 }) + manager.start() + + // Attempts: 100, 200, then capped at 300. + latest().simulateClose() + vi.advanceTimersByTime(100) + latest().simulateClose() + vi.advanceTimersByTime(200) + latest().simulateClose() + vi.advanceTimersByTime(299) + expect(sockets()).toHaveLength(3) + vi.advanceTimersByTime(1) + expect(sockets()).toHaveLength(4) + }) + + it('reconnects immediately with reset attempts when the tab becomes visible', () => { + vi.spyOn(Math, 'random').mockReturnValue(0) + manager = new WsManager() + manager.start() + + // Drive into a long backoff. + latest().simulateClose() + vi.advanceTimersByTime(100) + latest().simulateClose() + expect(manager.getStatusSnapshot().attempt).toBe(2) + + setVisibility('hidden') + setVisibility('visible') + // Visibility recovery connects synchronously (no backoff wait). + expect(sockets()).toHaveLength(3) + latest().simulateOpen() + expect(manager.getStatusSnapshot()).toEqual({ + status: 'connected', + attempt: 0, + }) + }) + + it('stop() closes the socket and halts reconnects', () => { + manager = new WsManager() + manager.start() + const socket = latest() + manager.stop() + + expect(socket.close).toHaveBeenCalled() + expect(manager.getStatusSnapshot().status).toBe('disconnected') + + vi.advanceTimersByTime(60_000) + expect(sockets()).toHaveLength(1) + }) +}) + +describe('WsManager heartbeat', () => { + it('pings every 30s and recycles a connection that goes silent', () => { + manager = new WsManager() + manager.start() + const socket = latest() + socket.simulateOpen() + + vi.advanceTimersByTime(30_000) + expect(socket.send).toHaveBeenCalledTimes(1) + expect(JSON.parse(socket.send.mock.calls[0][0])).toEqual({ type: 'ping' }) + + // No inbound traffic for 35s after the ping → watchdog closes the socket. + vi.advanceTimersByTime(35_000) + expect(socket.close).toHaveBeenCalled() + }) + + it('any inbound message clears the dead-connection watchdog', () => { + manager = new WsManager() + manager.start() + const socket = latest() + socket.simulateOpen() + + vi.advanceTimersByTime(30_000) // ping at t30, watchdog armed for t65 + socket.simulateMessage({ type: 'pong' }) + vi.advanceTimersByTime(34_000) // t64 — past the t65-armed watchdog? no: cleared + expect(socket.close).not.toHaveBeenCalled() + }) +}) + +describe('WsManager log ring buffer', () => { + it('appends pushed log batches and bumps the version', () => { + manager = new WsManager() + manager.start() + latest().simulateOpen() + + const v0 = manager.getLogsVersion() + latest().simulateMessage({ type: 'logs', data: [logEntry(1), logEntry(2)] }) + expect(manager.getLogs()).toHaveLength(2) + expect(manager.getLogsVersion()).toBeGreaterThan(v0) + }) + + it('caps the buffer at logCapacity, dropping oldest first', () => { + manager = new WsManager({ logCapacity: 5 }) + manager.start() + latest().simulateOpen() + + latest().simulateMessage({ + type: 'logs', + data: [1, 2, 3, 4].map(logEntry), + }) + latest().simulateMessage({ type: 'logs', data: [5, 6, 7].map(logEntry) }) + + const ids = manager.getLogs().map((l) => l.id) + expect(ids).toEqual([3, 4, 5, 6, 7]) + }) + + it('defaults the cap to 5000 entries', () => { + manager = new WsManager() + manager.start() + latest().simulateOpen() + + latest().simulateMessage({ + type: 'logs', + data: Array.from({ length: 5001 }, (_, i) => logEntry(i)), + }) + expect(manager.getLogs()).toHaveLength(5000) + expect(manager.getLogs()[0].id).toBe(1) // entry 0 evicted + }) + + it('bumps the version at most once per 250ms window', () => { + manager = new WsManager() + manager.start() + latest().simulateOpen() + + const notify = vi.fn() + manager.subscribeLogs(notify) + + latest().simulateMessage({ type: 'logs', data: [logEntry(1)] }) + expect(notify).toHaveBeenCalledTimes(1) // first bump is immediate + + latest().simulateMessage({ type: 'logs', data: [logEntry(2)] }) + latest().simulateMessage({ type: 'logs', data: [logEntry(3)] }) + expect(notify).toHaveBeenCalledTimes(1) // coalesced + + vi.advanceTimersByTime(250) + expect(notify).toHaveBeenCalledTimes(2) // one trailing bump for both + expect(manager.getLogs()).toHaveLength(3) + }) + + it('ignores malformed and non-log frames', () => { + manager = new WsManager() + manager.start() + latest().simulateOpen() + + latest().onmessage?.( + new MessageEvent('message', { data: 'not json' }), + ) + latest().simulateMessage({ type: 'logs', data: 'not an array' }) + latest().simulateMessage({ type: 'metrics', data: [] }) + expect(manager.getLogs()).toHaveLength(0) + }) +}) + +describe('WsManager external-store contract', () => { + it('returns a stable snapshot reference between changes', () => { + manager = new WsManager() + manager.start() + const a = manager.getStatusSnapshot() + const b = manager.getStatusSnapshot() + expect(a).toBe(b) + + latest().simulateOpen() + const c = manager.getStatusSnapshot() + expect(c).not.toBe(a) + expect(manager.getStatusSnapshot()).toBe(c) + }) + + it('notifies status subscribers and honors unsubscribe', () => { + manager = new WsManager() + const seen: string[] = [] + const unsub = manager.subscribeStatus(() => { + seen.push(manager!.getStatusSnapshot().status) + }) + manager.start() + latest().simulateOpen() + expect(seen).toContain('connected') + + const count = seen.length + unsub() + latest().simulateClose() + expect(seen).toHaveLength(count) + }) +}) diff --git a/ui/src/lib/apiFetch.ts b/ui/src/lib/apiFetch.ts new file mode 100644 index 0000000..8349a30 --- /dev/null +++ b/ui/src/lib/apiFetch.ts @@ -0,0 +1,66 @@ +// The one fetch wrapper for every REST call in the UI. TanStack Query hands +// each queryFn an AbortSignal; passing it through here is what cancels +// in-flight requests on unmount/refetch (the audit's leak finding). +// +// Error normalization contract: +// - non-2xx → ApiError { status: } +// - network failure → ApiError { status: 0 } +// - unparseable body → ApiError { status: } +// - AbortError → re-thrown untouched so Query can ignore it + +export class ApiError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +export interface ApiFetchOptions { + signal?: AbortSignal + headers?: Record +} + +function isAbort(err: unknown): boolean { + return err instanceof DOMException && err.name === 'AbortError' +} + +/** Fetch + parse JSON, returning the Response for header access (X-Cache). */ +export async function apiFetchWithResponse( + path: string, + options: ApiFetchOptions = {}, +): Promise<{ data: T; response: Response }> { + let response: Response + try { + response = await fetch(path, { + signal: options.signal, + headers: options.headers, + }) + } catch (err: unknown) { + if (isAbort(err)) throw err + const message = err instanceof Error ? err.message : 'network error' + throw new ApiError(message, 0) + } + if (!response.ok) { + throw new ApiError(`HTTP ${response.status} for ${path}`, response.status) + } + let data: T + try { + data = (await response.json()) as T + } catch (err: unknown) { + if (isAbort(err)) throw err + throw new ApiError(`invalid JSON from ${path}`, response.status) + } + return { data, response } +} + +/** Fetch + parse JSON. The default entry point for queryFns. */ +export async function apiFetch( + path: string, + options: ApiFetchOptions = {}, +): Promise { + const { data } = await apiFetchWithResponse(path, options) + return data +} diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..9ee1cfc --- /dev/null +++ b/ui/src/lib/format.ts @@ -0,0 +1,76 @@ +// Central formatters for every numeric surface in the UI. All output is +// tabular-nums-friendly: plain ASCII digits, one unit suffix, no locale +// grouping — so columns of values line up in the mono/tabular font stacks. +// +// This module is the single fix point for the two audit formatting bugs: +// - error_rate 0.042 (a 0–1 ratio) must render "4.2%", not "0.04%" +// - health_score 0.73 (a 0–1 ratio) must render "73%", not "0.73%" + +/** Placeholder for absent/invalid numbers. */ +const DASH = '—' + +/** + * Unit of the raw value passed to {@link formatPercent}: + * - `ratio` — 0–1 (graph node `error_rate`, `health_score`). The default. + * - `percent` — already ×100 server-side (`DashboardStats.error_rate`). + * - `auto` — loose fields with inconsistent producers: ≤1 is treated as a + * ratio, >1 as a percent (the long-standing codebase heuristic). + */ +export type PercentUnit = 'ratio' | 'percent' | 'auto' + +/** toFixed + trailing-zero trim: 73.0 → "73", 4.20 → "4.2". */ +function trimFixed(value: number, digits: number): string { + return value + .toFixed(digits) + .replace(/\.0+$/, '') + .replace(/(\.\d*?)0+$/, '$1') +} + +/** + * Format a percentage. `formatPercent(0.042)` → "4.2%", + * `formatPercent(0.73)` → "73%". + */ +export function formatPercent( + value: number, + digits = 1, + unit: PercentUnit = 'ratio', +): string { + if (!Number.isFinite(value)) return DASH + let pct: number + if (unit === 'percent') { + pct = value + } else if (unit === 'auto') { + pct = value <= 1 ? value * 100 : value + } else { + pct = value * 100 + } + return `${trimFixed(pct, digits)}%` +} + +/** + * Format a duration given in milliseconds: "230ms", "1.5s", "1.5m". + * Sub-millisecond values keep one decimal ("0.4ms"). + */ +export function formatMs(ms: number): string { + if (!Number.isFinite(ms)) return DASH + if (ms < 1) return `${trimFixed(ms, 1)}ms` + if (ms < 1000) return `${trimFixed(ms, 0)}ms` + if (ms < 60_000) return `${trimFixed(ms / 1000, 1)}s` + return `${trimFixed(ms / 60_000, 1)}m` +} + +/** Abbreviate a count: 950 → "950", 1200 → "1.2K", 3400000 → "3.4M". */ +export function formatCount(n: number): string { + if (!Number.isFinite(n)) return DASH + if (Math.abs(n) >= 1_000_000) return `${trimFixed(n / 1_000_000, 1)}M` + if (Math.abs(n) >= 1_000) return `${trimFixed(n / 1_000, 1)}K` + return trimFixed(n, 0) +} + +/** Format a size given in megabytes: "3.4MB", "840MB", "1.2GB". */ +export function formatMb(mb: number | undefined | null): string { + if (typeof mb !== 'number' || !Number.isFinite(mb)) return DASH + if (mb >= 1024) return `${trimFixed(mb / 1024, 1)}GB` + if (mb < 10) return `${trimFixed(mb, 1)}MB` + return `${trimFixed(mb, 0)}MB` +} diff --git a/ui/src/lib/queryClient.ts b/ui/src/lib/queryClient.ts new file mode 100644 index 0000000..48aac74 --- /dev/null +++ b/ui/src/lib/queryClient.ts @@ -0,0 +1,36 @@ +import { QueryClient } from '@tanstack/react-query' + +// TanStack Query is the only server-state store in the UI. The defaults here +// are deliberate backend-pressure relief for the SQLite memory incident: +// - staleTime 10s matches the server's /api/system/graph TTL cache, so a +// remount inside that window is a cache read, not a SQLite query. +// - refetchIntervalInBackground: false — hidden tabs stop polling entirely. +// - bounded retries with jittered exponential backoff so N clients +// recovering from a restart don't thundering-herd the server. + +const RETRY_BASE_MS = 1000 +const RETRY_MAX_MS = 30_000 + +/** Exponential backoff with up to +20% jitter, capped at 30s. */ +export function retryDelayWithJitter(attemptIndex: number): number { + const base = Math.min(RETRY_BASE_MS * 2 ** attemptIndex, RETRY_MAX_MS) + return base + Math.random() * base * 0.2 +} + +/** Build the app QueryClient. Exported as a factory for test isolation. */ +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10_000, + gcTime: 5 * 60_000, + refetchIntervalInBackground: false, + retry: 2, + retryDelay: retryDelayWithJitter, + }, + }, + }) +} + +/** The app-lifetime singleton used by main.tsx. */ +export const queryClient = createQueryClient() diff --git a/ui/src/lib/wsManager.ts b/ui/src/lib/wsManager.ts new file mode 100644 index 0000000..70c9527 --- /dev/null +++ b/ui/src/lib/wsManager.ts @@ -0,0 +1,332 @@ +import type { LogEntry } from '@/types/api' + +// Module-level WebSocket singleton for the /ws hub stream. Ports the +// hardened lifecycle from hooks/useWebSocket.ts (exponential backoff, +// 30s ping heartbeat + 35s dead-connection watchdog, visibility/online +// recovery) out of React entirely, and adds what the rewrite needs: +// - jittered backoff: delay += Math.random() * delay * 0.2, so a fleet +// of clients recovering from a restart doesn't reconnect in lockstep; +// - a bounded ring buffer (cap 5000) for pushed log batches — the UI +// must never mirror the backend's unbounded-growth incident; +// - a version counter bumped at most once per 250ms so log consumers +// re-render per tick, not per frame; +// - useSyncExternalStore-compatible subscribe/getSnapshot pairs for +// both connection status and the log buffer. + +interface HubBatch { + type: string + data: unknown +} + +export type WsStatus = + | 'connecting' + | 'connected' + | 'disconnected' + | 'reconnecting' + +/** Immutable status snapshot — replaced (never mutated) on change. */ +export interface WsStatusSnapshot { + status: WsStatus + /** Reconnect attempts since the last successful open (0 when connected). */ + attempt: number +} + +export interface WsManagerOptions { + /** WebSocket path appended to the page origin. Default "/ws". */ + path?: string + initialBackoffMs?: number + maxBackoffMs?: number + heartbeatIntervalMs?: number + heartbeatTimeoutMs?: number + /** Log ring buffer capacity. Default 5000. */ + logCapacity?: number + /** Minimum spacing between log version bumps. Default 250ms. */ + versionIntervalMs?: number +} + +const DEFAULTS: Required = { + path: '/ws', + initialBackoffMs: 100, + maxBackoffMs: 10_000, + heartbeatIntervalMs: 30_000, + heartbeatTimeoutMs: 35_000, + logCapacity: 5000, + versionIntervalMs: 250, +} + +export class WsManager { + private readonly opts: Required + + private socket: WebSocket | null = null + private started = false + private snapshot: WsStatusSnapshot = { status: 'disconnected', attempt: 0 } + + private reconnectTimer: ReturnType | null = null + private heartbeatInterval: ReturnType | null = null + private heartbeatTimeout: ReturnType | null = null + + private readonly statusSubs = new Set<() => void>() + private readonly logSubs = new Set<() => void>() + + private readonly logBuf: LogEntry[] = [] + private logVersion = 0 + private lastBumpAt = 0 + private versionTimer: ReturnType | null = null + + constructor(options: WsManagerOptions = {}) { + this.opts = { ...DEFAULTS, ...options } + } + + // ---- public lifecycle ---------------------------------------------------- + + /** Open the connection and attach recovery listeners. Idempotent. */ + start(): void { + if (this.started) return + this.started = true + document.addEventListener('visibilitychange', this.handleVisibility) + window.addEventListener('online', this.handleOnline) + this.connect() + } + + /** Full teardown: close socket, clear timers, detach listeners. */ + stop(): void { + if (!this.started) return + this.started = false + document.removeEventListener('visibilitychange', this.handleVisibility) + window.removeEventListener('online', this.handleOnline) + this.clearReconnectTimer() + this.clearHeartbeat() + if (this.versionTimer !== null) { + clearTimeout(this.versionTimer) + this.versionTimer = null + } + this.detachAndClose() + this.setState('disconnected', 0) + } + + // ---- useSyncExternalStore surface (status) -------------------------------- + + readonly subscribeStatus = (cb: () => void): (() => void) => { + this.statusSubs.add(cb) + return () => this.statusSubs.delete(cb) + } + + readonly getStatusSnapshot = (): WsStatusSnapshot => this.snapshot + + // ---- useSyncExternalStore surface (logs) ---------------------------------- + + readonly subscribeLogs = (cb: () => void): (() => void) => { + this.logSubs.add(cb) + return () => this.logSubs.delete(cb) + } + + /** Monotonic version, bumped at most once per versionIntervalMs. */ + readonly getLogsVersion = (): number => this.logVersion + + /** + * Current ring buffer contents, oldest first. The array is live between + * version bumps — copy if you need a stable view across ticks. + */ + readonly getLogs = (): readonly LogEntry[] => this.logBuf + + // ---- connection internals -------------------------------------------------- + + private connect(): void { + if (!this.started) return + this.clearReconnectTimer() + this.clearHeartbeat() + this.detachAndClose() + + this.setState( + this.snapshot.attempt === 0 ? 'connecting' : 'reconnecting', + ) + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + let ws: WebSocket + try { + ws = new WebSocket( + `${protocol}//${window.location.host}${this.opts.path}`, + ) + } catch { + this.scheduleReconnect() + return + } + this.socket = ws + + ws.onopen = () => { + if (!this.started) return + this.setState('connected', 0) + this.startHeartbeat() + } + + ws.onmessage = (event: MessageEvent) => { + // Any inbound frame proves liveness — clear the watchdog. + if (this.heartbeatTimeout !== null) { + clearTimeout(this.heartbeatTimeout) + this.heartbeatTimeout = null + } + let payload: HubBatch + try { + payload = JSON.parse(event.data) as HubBatch + } catch { + return // non-JSON frames (incl. server ping echoes) are ignored + } + if (payload.type === 'logs' && Array.isArray(payload.data)) { + this.appendLogs(payload.data as LogEntry[]) + } + } + + ws.onerror = () => { + // onclose always follows — it owns reconnect scheduling. + } + + ws.onclose = () => { + if (!this.started) return + if (this.socket === ws) this.socket = null + this.clearHeartbeat() + this.setState('disconnected') + this.scheduleReconnect() + } + } + + private scheduleReconnect(): void { + if (!this.started) return + this.clearReconnectTimer() + const attempt = this.snapshot.attempt + let delay = Math.min( + this.opts.initialBackoffMs * 2 ** attempt, + this.opts.maxBackoffMs, + ) + delay += Math.random() * delay * 0.2 // de-synchronize fleet reconnects + this.setState('reconnecting', attempt + 1) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + } + + private startHeartbeat(): void { + this.clearHeartbeat() + this.heartbeatInterval = setInterval(() => { + const ws = this.socket + if (!ws || ws.readyState !== WebSocket.OPEN) return + try { + ws.send(JSON.stringify({ type: 'ping' })) + } catch { + return // close/error handlers will drive recovery + } + // Arm the watchdog only if none is pending: the oldest unanswered + // ping's deadline must hold. (The useWebSocket hook re-armed it on + // every ping, which let the deadline slide forever at a 30s ping / + // 35s timeout — dead connections were never detected.) + if (this.heartbeatTimeout !== null) return + this.heartbeatTimeout = setTimeout(() => { + this.heartbeatTimeout = null + // Nothing heard since the ping — treat the connection as dead. + try { + this.socket?.close() + } catch { + // noop + } + }, this.opts.heartbeatTimeoutMs) + }, this.opts.heartbeatIntervalMs) + } + + private clearHeartbeat(): void { + if (this.heartbeatInterval !== null) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + if (this.heartbeatTimeout !== null) { + clearTimeout(this.heartbeatTimeout) + this.heartbeatTimeout = null + } + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + /** Detach handlers before closing so the old socket can't re-schedule. */ + private detachAndClose(): void { + const existing = this.socket + if (!existing) return + existing.onopen = null + existing.onmessage = null + existing.onerror = null + existing.onclose = null + try { + existing.close() + } catch { + // noop + } + this.socket = null + } + + private setState(status: WsStatus, attempt = this.snapshot.attempt): void { + if (this.snapshot.status === status && this.snapshot.attempt === attempt) { + return + } + this.snapshot = { status, attempt } + for (const cb of this.statusSubs) cb() + } + + // ---- recovery listeners ---------------------------------------------------- + + private readonly handleVisibility = (): void => { + if (document.visibilityState !== 'visible') return + const ws = this.socket + const dead = + !ws || + ws.readyState === WebSocket.CLOSED || + ws.readyState === WebSocket.CLOSING + if (dead) { + this.setState(this.snapshot.status, 0) // fresh backoff ladder + this.connect() + } + } + + private readonly handleOnline = (): void => { + this.setState(this.snapshot.status, 0) + this.connect() + } + + // ---- log buffer internals ---------------------------------------------------- + + private appendLogs(batch: LogEntry[]): void { + for (const entry of batch) this.logBuf.push(entry) + const overflow = this.logBuf.length - this.opts.logCapacity + if (overflow > 0) this.logBuf.splice(0, overflow) + this.requestVersionBump() + } + + private requestVersionBump(): void { + if (this.versionTimer !== null) return // trailing bump already queued + const elapsed = Date.now() - this.lastBumpAt + if (elapsed >= this.opts.versionIntervalMs) { + this.bumpVersion() + return + } + this.versionTimer = setTimeout(() => { + this.versionTimer = null + this.bumpVersion() + }, this.opts.versionIntervalMs - elapsed) + } + + private bumpVersion(): void { + this.lastBumpAt = Date.now() + this.logVersion += 1 + for (const cb of this.logSubs) cb() + } +} + +/** App-lifetime singleton. Created lazily so tests can use fresh instances. */ +let singleton: WsManager | null = null + +export function getWsManager(): WsManager { + singleton ??= new WsManager() + return singleton +} From 945518d134a3a5f6ce697a354507189d73e331c1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:20:26 +0000 Subject: [PATCH 30/61] test(graphrag): cover refresh-tick wiring, row-limit branch, metric paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a real refreshLoop tick (20ms cadence) to prove trace prune, signal prune and idle-tenant eviction fire from the loop itself, seeds exactly rebuildRowLimit rows to exercise the limit-hit branch, and wires a shared telemetry.Metrics (sync.Once — promauto panics on duplicate registration) so the Prometheus increments in evictIdleTenants and recordEventDrop are executed. Co-Authored-By: Claude Fable 5 --- internal/graphrag/refresh_loop_test.go | 104 +++++++++++++++++++++++++ internal/graphrag/span_cap_test.go | 1 + 2 files changed, 105 insertions(+) create mode 100644 internal/graphrag/refresh_loop_test.go diff --git a/internal/graphrag/refresh_loop_test.go b/internal/graphrag/refresh_loop_test.go new file mode 100644 index 0000000..7871bea --- /dev/null +++ b/internal/graphrag/refresh_loop_test.go @@ -0,0 +1,104 @@ +package graphrag + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/RandomCodeSpace/otelcontext/internal/storage" + "github.com/RandomCodeSpace/otelcontext/internal/telemetry" +) + +var ( + testMetricsOnce sync.Once + testMetrics *telemetry.Metrics +) + +// sharedTestMetrics returns a process-wide telemetry.Metrics. promauto +// registers on the default Prometheus registry and panics on duplicate +// registration, so every graphrag test shares this single instance. +func sharedTestMetrics() *telemetry.Metrics { + testMetricsOnce.Do(func() { testMetrics = telemetry.New() }) + return testMetrics +} + +// TestRefreshLoop_TickPrunesAndEvicts drives a real refreshLoop tick with a +// tiny RefreshEvery and proves the tick wiring end-to-end: expired spans +// pruned (TraceStore.Prune), stale metrics pruned (SignalStore.Prune) and +// idle tenants evicted — with Prometheus metrics wired so the counter +// branches are exercised too. +func TestRefreshLoop_TickPrunesAndEvicts(t *testing.T) { + repo := newTestRepo(t) + cfg := DefaultConfig() + cfg.RefreshEvery = 20 * time.Millisecond + cfg.TraceTTL = time.Minute + g := New(repo, nil, nil, cfg) + g.SetMetrics(sharedTestMetrics()) + t.Cleanup(g.Stop) + + now := time.Now() + def := g.storesForTenant(storage.DefaultTenantID) + def.traces.UpsertSpan(mkSpanNode("s-old", now.Add(-2*time.Minute))) // past TraceTTL + def.signals.UpsertMetric("cpu", "svc", 1.0, now.Add(-48*time.Hour)) // past signalRetention + g.storesForTenant("tenant-idle").lastAccess.Store(staleNanos()) // past TenantIdleTTL + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.refreshLoop(ctx) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + def.traces.mu.RLock() + _, spanAlive := def.traces.Spans["s-old"] + def.traces.mu.RUnlock() + def.signals.mu.RLock() + _, metricAlive := def.signals.Metrics["cpu|svc"] + def.signals.mu.RUnlock() + g.tenantsMu.RLock() + _, tenantAlive := g.tenants["tenant-idle"] + g.tenantsMu.RUnlock() + if !spanAlive && !metricAlive && !tenantAlive { + if g.TenantsEvictedCount() == 0 { + t.Fatalf("eviction happened but the counter did not tick") + } + return // all tick effects observed + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("refreshLoop tick did not prune spans/metrics or evict the idle tenant within 2s") +} + +// TestRebuild_RowLimitMergesLimitedWindow seeds exactly rebuildRowLimit rows +// so the limit-hit branch fires (warning logged); the observable contract is +// that the pass still merges the limited window instead of bailing. +func TestRebuild_RowLimitMergesLimitedWindow(t *testing.T) { + repo := newTestRepo(t) + g := New(repo, nil, nil, DefaultConfig()) + t.Cleanup(g.Stop) + + now := time.Now() + spans := make([]storage.Span, rebuildRowLimit) + for i := range spans { + spans[i] = storage.Span{ + TenantID: storage.DefaultTenantID, + TraceID: "t-bulk", + SpanID: fmt.Sprintf("s-%d", i), + ServiceName: "svc", + Status: "STATUS_CODE_OK", + StartTime: now, + EndTime: now, + } + } + // Batch size bounded by SQLite's SQL-variable limit (rows × columns). + if err := repo.DB().CreateInBatches(spans, 500).Error; err != nil { + t.Fatalf("bulk seed: %v", err) + } + + g.rebuildAllTenantsFromDB(context.Background()) + + if _, ok := g.storesForTenant(storage.DefaultTenantID).service.GetService("svc"); !ok { + t.Fatalf("rebuild with row-limit hit did not merge any services") + } +} diff --git a/internal/graphrag/span_cap_test.go b/internal/graphrag/span_cap_test.go index 56a6a4a..7341902 100644 --- a/internal/graphrag/span_cap_test.go +++ b/internal/graphrag/span_cap_test.go @@ -75,6 +75,7 @@ func TestProcessSpan_CapDropRecorded(t *testing.T) { cfg := DefaultConfig() cfg.MaxSpansPerTenant = 2 g := New(nil, nil, nil, cfg) + g.SetMetrics(sharedTestMetrics()) // exercise the Prometheus drop path too t.Cleanup(g.Stop) now := time.Now() From 58a938a750a62eacc11d671ed8aff69c9b908982 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:21:26 +0000 Subject: [PATCH 31/61] style(config): gofmt alignment in driver-defaults test table Co-Authored-By: Claude Fable 5 --- internal/config/driver_defaults_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/config/driver_defaults_test.go b/internal/config/driver_defaults_test.go index 2b60b58..c15f7ec 100644 --- a/internal/config/driver_defaults_test.go +++ b/internal/config/driver_defaults_test.go @@ -48,17 +48,17 @@ func clearSQLiteEnv(t *testing.T) { func postgresDefaultsConfig(driver string) *Config { return &Config{ DBDriver: driver, - DBMaxOpenConns: 50, // Postgres default - DBMaxIdleConns: 10, // Postgres default - IngestPipelineWorkers: 8, // Postgres default - IngestPipelineQueueSize: 50000, // Postgres default - MetricMaxCardinality: 10000, // Postgres default - StoreMinSeverity: "", // same-as-ingest default - SamplingRate: 1.0, // keep-all default - GRPCMaxConcurrentStreams: 1000, // Postgres default + DBMaxOpenConns: 50, // Postgres default + DBMaxIdleConns: 10, // Postgres default + IngestPipelineWorkers: 8, // Postgres default + IngestPipelineQueueSize: 50000, // Postgres default + MetricMaxCardinality: 10000, // Postgres default + StoreMinSeverity: "", // same-as-ingest default + SamplingRate: 1.0, // keep-all default + GRPCMaxConcurrentStreams: 1000, // Postgres default GraphRAGEventQueueSize: 100000, // Postgres default - LogFTSEnabled: false, // FTS5 opt-in default - GraphRAGTraceTTL: "1h", // Postgres default + LogFTSEnabled: false, // FTS5 opt-in default + GraphRAGTraceTTL: "1h", // Postgres default } } From 83eda2ad498261d83f18221ed2f9d1d861e3f018 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:23:47 +0000 Subject: [PATCH 32/61] feat(ui): design tokens, system-aware theme, TanStack Query adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles/tokens.css: hand-written token sheet per the triage-UI design spec — dark :root + [data-theme=light] block, status hues darkened for AA in light mode, canonical 360/768/1024/1440 breakpoints documented, vendored @font-face (swap), global :focus-visible ring, tabular-nums, prefers-reduced-motion zeroing the duration tokens, prefers-contrast strengthening hairlines. - useTheme: keeps the oc-theme key (zero migration) but now honors prefers-color-scheme while unset (live), and only persists on an explicit toggle instead of pinning dark on first visit. - useSystemGraph/useDashboard: ported to TanStack Query behind the legacy return shapes, so ServicesView/DashboardView are untouched while gaining dedup, AbortSignal cancellation and hidden-tab polling pause. main.tsx wraps the tree in QueryClientProvider; index.html preloads the two woff2 subsets. Initial bundle: 75.74KB gz JS + 10.04KB gz CSS (budget 160KB/24KB). Co-Authored-By: Claude Fable 5 --- ui/index.html | 14 ++ ui/src/hooks/__tests__/queryAdapters.test.tsx | 138 ++++++++++++++++++ ui/src/hooks/__tests__/useTheme.test.ts | 109 ++++++++++++++ ui/src/hooks/useDashboard.ts | 58 ++++---- ui/src/hooks/useSystemGraph.ts | 55 +++---- ui/src/hooks/useTheme.ts | 49 +++++-- ui/src/main.tsx | 19 ++- ui/src/styles/tokens.css | 137 +++++++++++++++++ 8 files changed, 507 insertions(+), 72 deletions(-) create mode 100644 ui/src/hooks/__tests__/queryAdapters.test.tsx create mode 100644 ui/src/hooks/__tests__/useTheme.test.ts create mode 100644 ui/src/styles/tokens.css diff --git a/ui/index.html b/ui/index.html index 73f8024..b466614 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,6 +3,20 @@ + + OtelContext diff --git a/ui/src/hooks/__tests__/queryAdapters.test.tsx b/ui/src/hooks/__tests__/queryAdapters.test.tsx new file mode 100644 index 0000000..e3cfdc3 --- /dev/null +++ b/ui/src/hooks/__tests__/queryAdapters.test.tsx @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useSystemGraph } from '../useSystemGraph' +import { useDashboard } from '../useDashboard' + +const fetchMock = vi.fn() + +function makeWrapper() { + // retry: false so error paths settle immediately under test. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function jsonResponse(body: unknown, headers?: Record) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json', ...headers }, + }) +} + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() +}) + +const GRAPH = { + timestamp: '2026-06-11T00:00:00Z', + system: { + total_services: 1, + healthy: 1, + degraded: 0, + critical: 0, + overall_health_score: 0.94, + total_error_rate: 0.01, + avg_latency_ms: 12, + uptime_seconds: 60, + }, + nodes: [], + edges: [], +} + +describe('useSystemGraph (TanStack Query adapter)', () => { + it('keeps the legacy return shape: graph, cache, loading, error, reload', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(GRAPH, { 'X-Cache': 'HIT' })) + const { result } = renderHook(() => useSystemGraph(0), { + wrapper: makeWrapper(), + }) + + expect(result.current.loading).toBe(true) + expect(result.current.graph).toBeNull() + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.graph).toEqual(GRAPH) + expect(result.current.cache).toBe('HIT') + expect(result.current.error).toBeNull() + expect(typeof result.current.reload).toBe('function') + expect(fetchMock).toHaveBeenCalledWith( + '/api/system/graph', + expect.any(Object), + ) + }) + + it('surfaces HTTP failures as an error string', async () => { + fetchMock.mockResolvedValueOnce(new Response('down', { status: 503 })) + const { result } = renderHook(() => useSystemGraph(0), { + wrapper: makeWrapper(), + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.graph).toBeNull() + expect(result.current.error).toContain('503') + }) +}) + +describe('useDashboard (TanStack Query adapter)', () => { + const DASH = { + total_traces: 10, + total_logs: 20, + total_errors: 1, + avg_latency_ms: 5, + error_rate: 4.2, + active_services: 3, + p99_latency_ms: 230, + top_failing_services: [], + } + const STATS = { db_size_mb: 1228.8 } + + function routeFetches() { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/api/metrics/dashboard')) { + return Promise.resolve(jsonResponse(DASH)) + } + if (url.includes('/api/stats')) { + return Promise.resolve(jsonResponse(STATS)) + } + return Promise.resolve(new Response('not found', { status: 404 })) + }) + } + + it('keeps the legacy return shape: dashboard, stats, loading, error, reload', async () => { + routeFetches() + const { result } = renderHook(() => useDashboard(0), { + wrapper: makeWrapper(), + }) + + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.dashboard).toEqual(DASH) + expect(result.current.stats).toEqual(STATS) + expect(result.current.error).toBeNull() + }) + + it('reports an error when either endpoint fails', async () => { + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/api/metrics/dashboard')) { + return Promise.resolve(jsonResponse(DASH)) + } + return Promise.resolve(new Response('boom', { status: 500 })) + }) + const { result } = renderHook(() => useDashboard(0), { + wrapper: makeWrapper(), + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.dashboard).toEqual(DASH) + expect(result.current.error).toContain('500') + }) +}) diff --git a/ui/src/hooks/__tests__/useTheme.test.ts b/ui/src/hooks/__tests__/useTheme.test.ts new file mode 100644 index 0000000..b02a88d --- /dev/null +++ b/ui/src/hooks/__tests__/useTheme.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useTheme } from '../useTheme' + +// matchMedia mock with controllable prefers-color-scheme and change events. +type Listener = () => void + +function mockMatchMedia(prefersLight: boolean) { + const listeners = new Set() + const mql = { + get matches() { + return state.prefersLight + }, + media: '(prefers-color-scheme: light)', + addEventListener: (_: string, cb: Listener) => listeners.add(cb), + removeEventListener: (_: string, cb: Listener) => listeners.delete(cb), + } + const state = { + prefersLight, + set(next: boolean) { + state.prefersLight = next + listeners.forEach((cb) => cb()) + }, + } + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => { + // The hook only queries prefers-color-scheme; reuse the same mql. + void query + return mql as unknown as MediaQueryList + }), + ) + return state +} + +beforeEach(() => { + window.localStorage.clear() + document.documentElement.removeAttribute('data-theme') +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useTheme', () => { + it('defaults to dark when nothing is stored and the system prefers dark', () => { + mockMatchMedia(false) + const { result } = renderHook(() => useTheme()) + expect(result.current.theme).toBe('dark') + expect(document.documentElement.getAttribute('data-theme')).toBe('dark') + }) + + it('honors prefers-color-scheme: light when no preference is stored', () => { + mockMatchMedia(true) + const { result } = renderHook(() => useTheme()) + expect(result.current.theme).toBe('light') + }) + + it('does not write localStorage until the user explicitly toggles', () => { + mockMatchMedia(true) + renderHook(() => useTheme()) + expect(window.localStorage.getItem('oc-theme')).toBeNull() + }) + + it('a stored preference wins over the system scheme', () => { + mockMatchMedia(true) + window.localStorage.setItem('oc-theme', 'dark') + const { result } = renderHook(() => useTheme()) + expect(result.current.theme).toBe('dark') + }) + + it('toggle persists to the existing oc-theme key and updates ', () => { + mockMatchMedia(false) + const { result } = renderHook(() => useTheme()) + act(() => result.current.toggle()) + expect(result.current.theme).toBe('light') + expect(window.localStorage.getItem('oc-theme')).toBe('light') + expect(document.documentElement.getAttribute('data-theme')).toBe('light') + }) + + it('follows live system scheme changes while unset', () => { + const media = mockMatchMedia(false) + const { result } = renderHook(() => useTheme()) + expect(result.current.theme).toBe('dark') + act(() => media.set(true)) + expect(result.current.theme).toBe('light') + }) + + it('ignores system scheme changes once a preference is stored', () => { + const media = mockMatchMedia(false) + window.localStorage.setItem('oc-theme', 'dark') + const { result } = renderHook(() => useTheme()) + act(() => media.set(true)) + expect(result.current.theme).toBe('dark') + }) + + it('survives blocked localStorage (private mode)', () => { + mockMatchMedia(false) + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('blocked') + }) + const { result } = renderHook(() => useTheme()) + act(() => result.current.toggle()) + expect(result.current.theme).toBe('light') + setItem.mockRestore() + }) +}) diff --git a/ui/src/hooks/useDashboard.ts b/ui/src/hooks/useDashboard.ts index d90d19b..a40fc94 100644 --- a/ui/src/hooks/useDashboard.ts +++ b/ui/src/hooks/useDashboard.ts @@ -1,35 +1,37 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '../lib/apiFetch'; import type { DashboardStats, RepoStats } from '../types/api'; +// TanStack Query adapter. Keeps the legacy return shape +// ({ dashboard, stats, loading, error, reload }) for existing consumers. +// The two endpoints become independent cache entries, so any other +// surface (e.g. the Pulse bar) polling the same keys shares one request. export function useDashboard(pollInterval = 30_000) { - const [dashboard, setDashboard] = useState(null); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const timerRef = useRef>(undefined); + const refetchInterval = pollInterval > 0 ? pollInterval : false; - const load = useCallback(async () => { - try { - const [dRes, sRes] = await Promise.all([ - fetch('/api/metrics/dashboard'), - fetch('/api/stats'), - ]); - if (!dRes.ok || !sRes.ok) throw new Error('fetch failed'); - setDashboard((await dRes.json()) as DashboardStats); - setStats((await sRes.json()) as RepoStats); - setError(null); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'fetch failed'); - } finally { - setLoading(false); - } - }, []); + const dash = useQuery({ + queryKey: ['metrics-dashboard'], + queryFn: ({ signal }) => + apiFetch('/api/metrics/dashboard', { signal }), + refetchInterval, + }); + const stats = useQuery({ + queryKey: ['stats'], + queryFn: ({ signal }) => apiFetch('/api/stats', { signal }), + refetchInterval, + }); - useEffect(() => { - load(); - timerRef.current = setInterval(load, pollInterval); - return () => clearInterval(timerRef.current); - }, [load, pollInterval]); + const reload = useCallback(() => { + void dash.refetch(); + void stats.refetch(); + }, [dash.refetch, stats.refetch]); - return { dashboard, stats, loading, error, reload: load }; + return { + dashboard: dash.data ?? null, + stats: stats.data ?? null, + loading: dash.isPending || stats.isPending, + error: dash.error?.message ?? stats.error?.message ?? null, + reload, + }; } diff --git a/ui/src/hooks/useSystemGraph.ts b/ui/src/hooks/useSystemGraph.ts index e4427c4..006b0d9 100644 --- a/ui/src/hooks/useSystemGraph.ts +++ b/ui/src/hooks/useSystemGraph.ts @@ -1,32 +1,35 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiFetchWithResponse } from '../lib/apiFetch'; import type { SystemGraphResponse } from '../types/api'; +// TanStack Query adapter. Keeps the legacy return shape +// ({ graph, cache, loading, error, reload }) so existing consumers +// (ServicesView, DashboardView) are untouched, while gaining request +// dedup, AbortSignal cancellation, and hidden-tab polling pause from +// the shared query client. export function useSystemGraph(pollInterval = 60_000) { - const [graph, setGraph] = useState(null); - const [cache, setCache] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const timerRef = useRef>(undefined); + const { data, isPending, error, refetch } = useQuery({ + queryKey: ['system-graph'], + queryFn: async ({ signal }) => { + const { data: graph, response } = + await apiFetchWithResponse('/api/system/graph', { + signal, + }); + return { graph, cache: response.headers.get('X-Cache') ?? '' }; + }, + refetchInterval: pollInterval > 0 ? pollInterval : false, + }); - const load = useCallback(async () => { - try { - const res = await fetch('/api/system/graph'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - setCache(res.headers.get('X-Cache') ?? ''); - setGraph((await res.json()) as SystemGraphResponse); - setError(null); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'fetch failed'); - } finally { - setLoading(false); - } - }, []); + const reload = useCallback(() => { + void refetch(); + }, [refetch]); - useEffect(() => { - load(); - timerRef.current = setInterval(load, pollInterval); - return () => clearInterval(timerRef.current); - }, [load, pollInterval]); - - return { graph, cache, loading, error, reload: load }; + return { + graph: data?.graph ?? null, + cache: data?.cache ?? '', + loading: isPending, + error: error ? error.message : null, + reload, + }; } diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts index a53f649..513cae5 100644 --- a/ui/src/hooks/useTheme.ts +++ b/ui/src/hooks/useTheme.ts @@ -1,35 +1,60 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' export type Theme = 'dark' | 'light' +// Existing storage key kept verbatim — zero migration for current users. const KEY = 'oc-theme' +const LIGHT_QUERY = '(prefers-color-scheme: light)' -function readInitial(): Theme { - if (typeof window === 'undefined') return 'dark' +function readStored(): Theme | null { + if (typeof window === 'undefined') return null try { const v = window.localStorage.getItem(KEY) - return v === 'light' || v === 'dark' ? v : 'dark' + return v === 'light' || v === 'dark' ? v : null } catch { - return 'dark' + return null } } +function systemTheme(): Theme { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return 'dark' // dark-first product default + } + return window.matchMedia(LIGHT_QUERY).matches ? 'light' : 'dark' +} + /** - * Single source of truth for theme. Held once at the app root and fed into the - * design-system ThemeProvider via `mode={theme}` so the provider can no longer - * clobber a persisted preference on mount. + * Single source of truth for theme. Stored preference (oc-theme) wins; + * when unset, prefers-color-scheme is honored — live, so an OS scheme + * flip retints the UI until the user explicitly toggles. Persisting only + * happens on an explicit toggle, never on mount. */ export function useTheme() { - const [theme, setTheme] = useState(readInitial) + const [stored, setStored] = useState(readStored) + const [system, setSystem] = useState(systemTheme) + const theme: Theme = stored ?? system + + // Track the OS scheme while no explicit preference exists. + useEffect(() => { + if (typeof window.matchMedia !== 'function') return + const mql = window.matchMedia(LIGHT_QUERY) + const update = () => setSystem(mql.matches ? 'light' : 'dark') + mql.addEventListener('change', update) + return () => mql.removeEventListener('change', update) + }, []) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) + }, [theme]) + + const toggle = useCallback(() => { + const next: Theme = theme === 'dark' ? 'light' : 'dark' + setStored(next) try { - window.localStorage.setItem(KEY, theme) + window.localStorage.setItem(KEY, next) } catch { - /* storage blocked (private mode) — attribute already set */ + /* storage blocked (private mode) — state still flips */ } }, [theme]) - const toggle = () => setTheme((value) => (value === 'dark' ? 'light' : 'dark')) return { theme, toggle } } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 1beb0d9..8d1cb2e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,23 +1,30 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import { ThemeProvider, ToastRegion } from '@ossrandom/design-system' import '@ossrandom/design-system/styles.css' import './styles/global.css' +import './styles/tokens.css' import App from './App' import { ErrorBoundary } from './components/ErrorBoundary' import { useTheme } from './hooks/useTheme' +import { queryClient } from './lib/queryClient' function Root() { // Theme is owned here (single source) and fed to ThemeProvider via `mode`, // so the provider no longer overwrites a persisted preference on mount. + // (The design-system provider remains only while legacy views still use + // DS components — it goes away with the C7 design-system removal.) const { theme, toggle } = useTheme() return ( - - - - - - + + + + + + + + ) } diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css new file mode 100644 index 0000000..34fdc57 --- /dev/null +++ b/ui/src/styles/tokens.css @@ -0,0 +1,137 @@ +/* + * OtelContext design tokens — the single hand-written token sheet. + * Dark-first; light theme via [data-theme="light"] on (owned by + * useTheme, which honors prefers-color-scheme when no preference is stored, + * so there is deliberately no @media (prefers-color-scheme) block here). + * + * Canonical breakpoints (CSS-only; custom properties cannot drive @media, + * so every CSS Module repeats these literals — change them here first): + * xs 360–767 bottom tab bar, sheets, card lists + * md 768–1023 icon rail 56px + * lg 1024–1439 icon rail, docked inspector + * xl 1440+ labeled rail 200px + */ + +/* ---- vendored fonts (OFL, ui/public/fonts, air-gap safe) ---- */ +@font-face { + font-family: 'InterVariable'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2-variations'); +} + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2'); +} + +:root { + /* dark (default) — near-black blue-cast surfaces, hairlines not shadows */ + --bg-base: #0b0d10; + --bg-raised: #12151b; + --bg-overlay: #171b23; + --bg-inset: #07090c; + --stroke-1: #1e232d; + --stroke-2: #2a3140; + --text-1: #e8ecf2; /* 13.9:1 on base */ + --text-2: #9aa3b2; /* 7.1:1 on base */ + --text-3: #646d7c; /* 4.6:1 on base */ + --accent: #5ca8ff; /* the ONLY interactive hue */ + --accent-muted: #5ca8ff26; + --ok: #34d399; + --warn: #fbbf24; + --crit: #f87171; + --unknown: #6b7280; + --ok-bg: #34d39914; + --warn-bg: #fbbf2414; + --crit-bg: #f8717114; + --font-sans: 'InterVariable', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + --text-2xs: 11px; + --text-xs: 12px; + --text-sm: 12.5px; + --text-base: 13.5px; + --text-lg: 15px; + --text-xl: 18px; + --text-2xl: 22px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --radius-1: 4px; + --radius-2: 6px; + --radius-3: 10px; /* full radius only on status dots */ + --ease: cubic-bezier(0.2, 0, 0, 1); + --dur-1: 80ms; + --dur-2: 140ms; + --dur-3: 240ms; + /* single elevation level — sheets/overlays only */ + --shadow-sheet: 0 8px 32px rgb(0 0 0 / 0.45); + color-scheme: dark; +} + +[data-theme='light'] { + --bg-base: #f7f8fa; + --bg-raised: #fff; + --bg-overlay: #fff; + --bg-inset: #edeff3; + --stroke-1: #e3e6eb; + --stroke-2: #ccd2db; + --text-1: #171b23; + --text-2: #49515e; + --text-3: #687284; + --accent: #1d6fe0; /* darkened for ≥4.5:1 on white */ + --accent-muted: #1d6fe01f; + --ok: #0e9f6e; /* status hues darkened for ≥4.5:1 */ + --warn: #b45309; + --crit: #dc2626; + --unknown: #6b7280; + --ok-bg: #0e9f6e14; + --warn-bg: #b4530914; + --crit-bg: #dc262614; + --shadow-sheet: 0 8px 32px rgb(23 27 35 / 0.16); + color-scheme: light; +} + +/* Numerals always line up — tabular-nums everywhere, inherited. */ +:root { + font-variant-numeric: tabular-nums; +} + +/* Every interactive element gets a visible keyboard ring. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Reduced motion: zero every duration token; modules key animation + * and transition times off --dur-* so this single block disables both. */ +@media (prefers-reduced-motion: reduce) { + :root { + --dur-1: 0ms; + --dur-2: 0ms; + --dur-3: 0ms; + } +} + +/* Higher-contrast request: strengthen hairlines and the faintest text. */ +@media (prefers-contrast: more) { + :root { + --stroke-1: #3a4150; + --stroke-2: #4a5264; + --text-3: #9aa3b2; + } + + [data-theme='light'] { + --stroke-1: #b8bfc9; + --stroke-2: #98a1ae; + --text-3: #49515e; + } +} From d97133714bdeb7d3681b83f1a565e4e234d04e40 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:26:01 +0000 Subject: [PATCH 33/61] refactor(graphrag): extract incrementalSince to keep rebuild complexity in budget The HWM window computation pushed rebuildFromDBForTenant past the S3776 cognitive-complexity threshold; lifting it into a named helper restores the budget and gives the incremental-window rule a single documented home. Co-Authored-By: Claude Fable 5 --- internal/graphrag/refresh.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/graphrag/refresh.go b/internal/graphrag/refresh.go index 395387b..11ef375 100644 --- a/internal/graphrag/refresh.go +++ b/internal/graphrag/refresh.go @@ -190,6 +190,20 @@ func (g *GraphRAG) rebuildAllTenantsFromDB(ctx context.Context) { } } +// incrementalSince narrows the rebuild window for a tenant that already has +// a high-water-mark: only spans newer than HWM minus rebuildOverlap need +// re-reading — at 120 services the full-window re-read every 60s dominated +// DB load. A fresh slice (first build, post-eviction) has HWM 0 and keeps +// the full trailing window. +func incrementalSince(stores *tenantStores, since time.Time) time.Time { + if hwm := stores.lastRebuildMax.Load(); hwm != 0 { + if t := time.Unix(0, hwm).Add(-rebuildOverlap); t.After(since) { + return t + } + } + return since +} + // rebuildFromDBForTenant loads recent span data for a single tenant and // merges it into that tenant's slice of the graph. Catches data from before // callbacks started (e.g., restart recovery). @@ -209,16 +223,7 @@ func (g *GraphRAG) rebuildFromDBForTenant(_ context.Context, tenant string, sinc // clock, or dormant tenants would never reach GRAPHRAG_TENANT_IDLE_TTL. stores := g.tenantStoresNoTouch(tenant) - // Incremental rebuild: after the first pass, only re-read spans newer - // than the tenant's high-water-mark minus a small overlap instead of the - // full trailing window — at 120 services the full-window re-read every - // 60s dominated DB load. A fresh slice (first build, post-eviction) has - // HWM 0 and takes the full window. - if hwm := stores.lastRebuildMax.Load(); hwm != 0 { - if t := time.Unix(0, hwm).Add(-rebuildOverlap); t.After(since) { - since = t - } - } + since = incrementalSince(stores, since) var rows []spanRow err := g.repo.DB(). From 18d97d01ab5048e7a72b2a3ee3afe25745993985 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:40:37 +0000 Subject: [PATCH 34/61] =?UTF-8?q?feat(ui):=20responsive=20shell=20?= =?UTF-8?q?=E2=80=94=20pulse=20bar,=20adaptive=20nav,=20live=20dot,=20conn?= =?UTF-8?q?ect=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the design-system AppShell/TopNav with the triage-UI shell: - Shell: CSS-only responsive nav — bottom tab bar <768px (44px+ targets, safe-area inset), 56px icon rail with Radix tooltips from 768px, labeled 200px rail at 1440px+. display:none keeps the hidden variant out of the a11y tree. Starts the /ws singleton. - PulseBar: always-on system pulse (health % / degraded / critical / err % / p99 / DB size) from the shared query cache; DB segment tints --warn above 2GB (operator honesty about the SQLite incident); collapses to '· N issues' on xs. - LiveDot: 3-state indicator via useSyncExternalStore on the wsManager status store — ok / warn pulsing with attempt count / crit offline. - ConnectPopover: Radix dropdown with copyable MCP URL, OTLP gRPC and OTLP HTTP endpoints derived from the page origin (recomposes what TopNav/SettingsDrawer never surfaced). - App: wouter routing — ServicesView at /map, dashboard + MCP console keep their views at /dashboard and /mcp; '/' and unknown paths redirect to /map until the Triage home lands. TopNav deleted; OtelView moved next to its only consumer (DashboardView). - vitest.config: '@' alias (was type-only before, now runtime) + v8 coverage config; test-setup grows guarded jsdom stubs for the platform APIs Radix touches; eslint scripts/ override (node globals). 26 new component/routing tests; all new files >=85% line coverage. Co-Authored-By: Claude Fable 5 --- ui/eslint.config.js | 7 + ui/src/App.tsx | 58 +++---- ui/src/__tests__/App.test.tsx | 92 +++++++++++ ui/src/components/dashboard/DashboardView.tsx | 5 +- ui/src/components/nav/TopNav.tsx | 62 ------- .../shell/ConnectPopover.module.css | 103 ++++++++++++ ui/src/components/shell/ConnectPopover.tsx | 85 ++++++++++ ui/src/components/shell/LiveDot.module.css | 32 ++++ ui/src/components/shell/LiveDot.tsx | 47 ++++++ ui/src/components/shell/PulseBar.module.css | 128 +++++++++++++++ ui/src/components/shell/PulseBar.tsx | 151 ++++++++++++++++++ ui/src/components/shell/Shell.module.css | 140 ++++++++++++++++ ui/src/components/shell/Shell.tsx | 100 ++++++++++++ .../shell/__tests__/ConnectPopover.test.tsx | 71 ++++++++ .../shell/__tests__/LiveDot.test.tsx | 86 ++++++++++ .../shell/__tests__/PulseBar.test.tsx | 148 +++++++++++++++++ .../components/shell/__tests__/Shell.test.tsx | 111 +++++++++++++ ui/src/hooks/__tests__/queryAdapters.test.tsx | 26 +++ ui/src/hooks/useDashboard.ts | 10 +- ui/src/test-setup.ts | 20 +++ ui/vitest.config.ts | 11 ++ 21 files changed, 1398 insertions(+), 95 deletions(-) create mode 100644 ui/src/__tests__/App.test.tsx delete mode 100644 ui/src/components/nav/TopNav.tsx create mode 100644 ui/src/components/shell/ConnectPopover.module.css create mode 100644 ui/src/components/shell/ConnectPopover.tsx create mode 100644 ui/src/components/shell/LiveDot.module.css create mode 100644 ui/src/components/shell/LiveDot.tsx create mode 100644 ui/src/components/shell/PulseBar.module.css create mode 100644 ui/src/components/shell/PulseBar.tsx create mode 100644 ui/src/components/shell/Shell.module.css create mode 100644 ui/src/components/shell/Shell.tsx create mode 100644 ui/src/components/shell/__tests__/ConnectPopover.test.tsx create mode 100644 ui/src/components/shell/__tests__/LiveDot.test.tsx create mode 100644 ui/src/components/shell/__tests__/PulseBar.test.tsx create mode 100644 ui/src/components/shell/__tests__/Shell.test.tsx diff --git a/ui/eslint.config.js b/ui/eslint.config.js index c8eb9fd..9a4a797 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -8,6 +8,13 @@ export default tseslint.config( { ignores: ['dist', 'node_modules'] }, js.configs.recommended, ...tseslint.configs.recommended, + { + // Build/CI scripts run under Node, not the browser. + files: ['scripts/**/*.mjs'], + languageOptions: { + globals: globals.node, + }, + }, { files: ['**/*.{ts,tsx}'], languageOptions: { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 47898ff..ea14d94 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,46 +1,46 @@ -import { lazy, Suspense, useState } from 'react' -import { AppShell, Spin } from '@ossrandom/design-system' -import TopNav, { type OtelView } from './components/nav/TopNav' -import DashboardView from './components/dashboard/DashboardView' -import { useWebSocket } from './hooks/useWebSocket' +import { lazy, Suspense } from 'react' +import { Redirect, Route, Switch, useLocation } from 'wouter' +import { Spin } from '@ossrandom/design-system' +import Shell from './components/shell/Shell' +import type { OtelView } from './components/dashboard/DashboardView' import type { Theme } from './hooks/useTheme' -// Dashboard is the default view and loads eagerly. The Service Map pulls in -// cytoscape (~434 KB) and the MCP console is a large secondary surface — both -// are code-split so they don't weigh down the initial dashboard-first load. +// All routes are code-split: /map pulls in cytoscape (~434 KB) and the +// other views carry design-system surfaces the shell itself doesn't need. const ServicesView = lazy(() => import('./components/observability/ServicesView')) +const DashboardView = lazy(() => import('./components/dashboard/DashboardView')) const MCPConsoleView = lazy(() => import('./components/mcp/MCPConsoleView')) +// Legacy view ids (DashboardView's onNavigate) → router paths. +const VIEW_PATHS: Record = { + dashboard: '/dashboard', + services: '/map', + mcp: '/mcp', +} + interface AppProps { theme: Theme onToggleTheme: () => void } export default function App({ theme, onToggleTheme }: Readonly) { - const [view, setView] = useState('dashboard') - - // WebSocket retained purely as the live/offline source for the header badge; - // the pushed log batches are intentionally discarded. - const ws = useWebSocket(() => undefined) - const wsConnected = ws.status === 'connected' + const [, navigate] = useLocation() return ( - - } - > + }> - {view === 'dashboard' && } - {view === 'services' && } - {view === 'mcp' && } + + + + navigate(VIEW_PATHS[view])} /> + + + {/* "/" and anything unknown → /map until the Triage home lands. */} + + + + - + ) } diff --git a/ui/src/__tests__/App.test.tsx b/ui/src/__tests__/App.test.tsx new file mode 100644 index 0000000..65d0757 --- /dev/null +++ b/ui/src/__tests__/App.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Router } from 'wouter' +import { memoryLocation } from 'wouter/memory-location' +import App from '../App' + +class StubWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + readyState = StubWebSocket.CONNECTING + onopen: ((ev: Event) => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + onclose: ((ev: CloseEvent) => void) | null = null + send = vi.fn() + close = vi.fn() + constructor(public url: string) {} +} + +const fetchMock = vi.fn((input) => { + const url = String(input) + if (url.includes('/api/system/graph')) { + return Promise.resolve( + new Response( + JSON.stringify({ + timestamp: '2026-06-11T00:00:00Z', + system: { + total_services: 0, + healthy: 0, + degraded: 0, + critical: 0, + overall_health_score: 1, + total_error_rate: 0, + avg_latency_ms: 0, + uptime_seconds: 1, + }, + nodes: [], + edges: [], + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('{}', { status: 200 })) +}) + +beforeEach(() => { + vi.stubGlobal('WebSocket', StubWebSocket as unknown as typeof WebSocket) + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function renderApp(path: string) { + const memory = memoryLocation({ path, record: true }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + {}} /> + + , + ) + return memory +} + +describe('App routing', () => { + it('redirects / to /map', async () => { + const memory = renderApp('/') + await waitFor(() => expect(memory.history).toContain('/map')) + }) + + it('redirects unknown paths to /map', async () => { + const memory = renderApp('/nonsense') + await waitFor(() => expect(memory.history).toContain('/map')) + }) + + it('mounts the existing ServicesView at /map', async () => { + renderApp('/map') + // Lazy chunk + empty graph → the view's empty state proves the mount. + expect( + await screen.findByText(/no services discovered yet/i), + ).toBeInTheDocument() + }) +}) diff --git a/ui/src/components/dashboard/DashboardView.tsx b/ui/src/components/dashboard/DashboardView.tsx index 65523a5..6da4e84 100644 --- a/ui/src/components/dashboard/DashboardView.tsx +++ b/ui/src/components/dashboard/DashboardView.tsx @@ -16,7 +16,6 @@ import { import type { TableColumn, TimelineItem } from '@ossrandom/design-system' import { RadialGauge } from '@ossrandom/design-system/charts' import Truncate from '../common/Truncate' -import type { OtelView } from '../nav/TopNav' import type { ServiceError } from '../../types/api' import { fmt } from '../../lib/utils' import { BP } from '../../lib/breakpoints' @@ -35,6 +34,10 @@ import { severityToTone, } from './dashTypes' +// Legacy view ids, kept for the onNavigate contract. Routing itself moved +// to wouter (App maps these to paths). Previously lived in nav/TopNav. +export type OtelView = 'dashboard' | 'services' | 'mcp' + interface DashboardViewProps { onNavigate: (view: OtelView) => void } diff --git a/ui/src/components/nav/TopNav.tsx b/ui/src/components/nav/TopNav.tsx deleted file mode 100644 index 76a418c..0000000 --- a/ui/src/components/nav/TopNav.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Badge, IconButton, Space, Tabs } from '@ossrandom/design-system' -import { Moon, Sun } from 'lucide-react' -import type { Theme } from '../../hooks/useTheme' - -export type OtelView = 'dashboard' | 'services' | 'mcp' - -interface TopNavProps { - view: OtelView - onNavigate: (view: OtelView) => void - wsConnected: boolean - theme: Theme - onToggleTheme: () => void -} - -const TABS = [ - { key: 'dashboard' as const, label: 'Dashboard' }, - { key: 'services' as const, label: 'Service Map' }, - { key: 'mcp' as const, label: 'MCP Trial' }, -] - -export default function TopNav({ - view, - onNavigate, - wsConnected, - theme, - onToggleTheme, -}: Readonly) { - return ( - - OtelContext - -
- -
- - - - {wsConnected ? 'live' : 'offline'} - - : } - aria-label="Toggle theme" - variant="ghost" - size="sm" - shape="circle" - onClick={onToggleTheme} - /> - -
- ) -} diff --git a/ui/src/components/shell/ConnectPopover.module.css b/ui/src/components/shell/ConnectPopover.module.css new file mode 100644 index 0000000..e924510 --- /dev/null +++ b/ui/src/components/shell/ConnectPopover.module.css @@ -0,0 +1,103 @@ +.trigger { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 28px; + padding: 0 var(--space-2); + border: 1px solid var(--stroke-1); + border-radius: var(--radius-1); + background: transparent; + color: var(--text-2); + font: inherit; + font-size: var(--text-xs); + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +} + +@media (hover: hover) { + .trigger:hover { + background: var(--bg-raised); + color: var(--text-1); + } +} + +@media (pointer: coarse) { + .trigger { + min-height: 44px; + min-width: 44px; + justify-content: center; + } +} + +/* xs: icon only — the label costs pulse-bar room the summary needs */ +.triggerLabel { + display: none; +} + +@media (min-width: 768px) { + .triggerLabel { + display: inline; + } +} + +.content { + z-index: 50; + min-width: 280px; + max-width: min(420px, calc(100vw - 16px)); + padding: var(--space-1); + border: 1px solid var(--stroke-2); + border-radius: var(--radius-2); + background: var(--bg-overlay); + box-shadow: var(--shadow-sheet); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 32px; + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-1); + color: var(--text-2); + font-size: var(--text-xs); + cursor: pointer; + outline: none; +} + +.item[data-highlighted] { + background: var(--accent-muted); + color: var(--text-1); +} + +@media (pointer: coarse) { + .item { + min-height: 44px; + } +} + +.itemLabel { + flex: none; + min-width: 72px; + color: var(--text-1); +} + +.itemValue { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: var(--text-2xs); + color: var(--text-2); +} + +.copyIcon { + flex: none; + color: var(--text-3); +} + +.copiedIcon { + flex: none; + color: var(--ok); +} diff --git a/ui/src/components/shell/ConnectPopover.tsx b/ui/src/components/shell/ConnectPopover.tsx new file mode 100644 index 0000000..2baab23 --- /dev/null +++ b/ui/src/components/shell/ConnectPopover.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from 'react' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { Cable, Check, Copy } from 'lucide-react' +import styles from './ConnectPopover.module.css' + +interface Endpoint { + key: string + label: string + value: string +} + +// Derived from the page origin, so the copied values work wherever the +// operator is browsing from (recomposes what TopNav/SettingsDrawer never +// surfaced: the actual connect strings for agents and OTLP exporters). +function endpoints(): Endpoint[] { + const { origin, hostname } = window.location + return [ + { key: 'mcp', label: 'MCP URL', value: `${origin}/mcp` }, + { key: 'grpc', label: 'OTLP gRPC', value: `${hostname}:4317` }, + { key: 'http', label: 'OTLP HTTP', value: `${origin}/v1/` }, + ] +} + +/** Pulse-bar popover with copyable MCP/OTLP endpoints. */ +export default function ConnectPopover() { + const [copied, setCopied] = useState(null) + const resetTimer = useRef | null>(null) + + useEffect( + () => () => { + if (resetTimer.current !== null) clearTimeout(resetTimer.current) + }, + [], + ) + + const copy = async (ep: Endpoint) => { + try { + await navigator.clipboard.writeText(ep.value) + setCopied(ep.key) + if (resetTimer.current !== null) clearTimeout(resetTimer.current) + resetTimer.current = setTimeout(() => setCopied(null), 1500) + } catch { + /* clipboard unavailable (http origin / permissions) — value stays visible to select */ + } + } + + return ( + + + + + + + {endpoints().map((ep) => ( + { + event.preventDefault() + void copy(ep) + }} + > + {ep.label} + {ep.value} + {copied === ep.key ? ( + + ))} + + + + ) +} diff --git a/ui/src/components/shell/LiveDot.module.css b/ui/src/components/shell/LiveDot.module.css new file mode 100644 index 0000000..dbb6b0f --- /dev/null +++ b/ui/src/components/shell/LiveDot.module.css @@ -0,0 +1,32 @@ +.dot { + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; /* full radius reserved for status dots */ +} + +.ok { + background: var(--ok); +} + +.warn { + background: var(--warn); + animation: pulse 1.2s var(--ease) infinite; +} + +.crit { + background: var(--crit); +} + +@keyframes pulse { + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .warn { + animation: none; + } +} diff --git a/ui/src/components/shell/LiveDot.tsx b/ui/src/components/shell/LiveDot.tsx new file mode 100644 index 0000000..ca7acd8 --- /dev/null +++ b/ui/src/components/shell/LiveDot.tsx @@ -0,0 +1,47 @@ +import { useSyncExternalStore } from 'react' +import { getWsManager, type WsManager } from '@/lib/wsManager' +import styles from './LiveDot.module.css' + +interface LiveDotProps { + /** Injectable for tests; defaults to the app singleton. */ + manager?: WsManager +} + +/** + * Three-state live indicator driven by the wsManager status store: + * ok (connected) / warn pulsing (connecting/reconnecting, attempt count + * in the accessible name) / crit (offline). + */ +export default function LiveDot({ manager }: Readonly) { + const ws = manager ?? getWsManager() + const snap = useSyncExternalStore(ws.subscribeStatus, ws.getStatusSnapshot) + + let tone: 'ok' | 'warn' | 'crit' + let label: string + switch (snap.status) { + case 'connected': + tone = 'ok' + label = 'live' + break + case 'connecting': + tone = 'warn' + label = 'connecting' + break + case 'reconnecting': + tone = 'warn' + label = `reconnecting (attempt ${snap.attempt})` + break + default: + tone = 'crit' + label = 'offline' + } + + return ( + + ) +} diff --git a/ui/src/components/shell/PulseBar.module.css b/ui/src/components/shell/PulseBar.module.css new file mode 100644 index 0000000..63043ab --- /dev/null +++ b/ui/src/components/shell/PulseBar.module.css @@ -0,0 +1,128 @@ +.bar { + display: flex; + align-items: center; + gap: var(--space-3); + height: 44px; + padding: 0 var(--space-3); + border-bottom: 1px solid var(--stroke-1); + background: var(--bg-base); +} + +.brand { + flex: none; + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.01em; + color: var(--text-1); + white-space: nowrap; +} + +.summary { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 1; + min-width: 0; + justify-content: center; + font-size: var(--text-xs); + color: var(--text-2); + white-space: nowrap; +} + +.sysDot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.ok { + background: var(--ok); +} + +.warn { + background: var(--warn); +} + +.crit { + background: var(--crit); +} + +.unknown { + background: var(--unknown); +} + +/* Full summary on md+, compact "N issues" on xs — CSS-only swap. */ +.summaryFull { + display: none; + align-items: center; + gap: var(--space-2); + overflow: hidden; + text-overflow: ellipsis; +} + +.summaryCompact { + display: inline; +} + +@media (min-width: 768px) { + .summaryFull { + display: flex; + } + + .summaryCompact { + display: none; + } +} + +.sep { + color: var(--text-3); +} + +.warnText { + color: var(--warn); +} + +.critText { + color: var(--crit); +} + +.placeholder { + color: var(--text-3); +} + +.actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex: none; +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--stroke-1); + border-radius: var(--radius-1); + background: transparent; + color: var(--text-2); + cursor: pointer; + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +} + +@media (hover: hover) { + .iconButton:hover { + background: var(--bg-raised); + color: var(--text-1); + } +} + +@media (pointer: coarse) { + .iconButton { + width: 44px; + height: 44px; + } +} diff --git a/ui/src/components/shell/PulseBar.tsx b/ui/src/components/shell/PulseBar.tsx new file mode 100644 index 0000000..bb35379 --- /dev/null +++ b/ui/src/components/shell/PulseBar.tsx @@ -0,0 +1,151 @@ +import type { ReactNode } from 'react' +import { Moon, Sun } from 'lucide-react' +import { useSystemGraph } from '@/hooks/useSystemGraph' +import { useDashboard } from '@/hooks/useDashboard' +import { formatMb, formatMs, formatPercent } from '@/lib/format' +import type { RepoStats, SystemSummary } from '@/types/api' +import type { Theme } from '@/hooks/useTheme' +import LiveDot from './LiveDot' +import ConnectPopover from './ConnectPopover' +import styles from './PulseBar.module.css' + +// Operator honesty about the SQLite growth incident: tint the DB segment +// as a warning once the database crosses this size. +export const DB_SIZE_WARN_MB = 2048 + +interface PulseBarProps { + theme?: Theme + onToggleTheme?: () => void + /** Injectable for tests; defaults to the singleton-backed LiveDot. */ + liveSlot?: ReactNode +} + +type Tone = 'ok' | 'warn' | 'crit' | 'unknown' + +function systemTone(s: SystemSummary): Tone { + if (s.critical > 0) return 'crit' + if (s.degraded > 0) return 'warn' + return 'ok' +} + +// /api/stats is loosely shaped (driver-dependent key casing) — accept both +// db_size_mb and DBSizeMB, as numbers or numeric strings. +function dbSizeMb(stats: RepoStats | null): number | null { + const raw = + stats?.db_size_mb ?? (stats as Record | null)?.DBSizeMB + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (typeof raw === 'string') { + const n = Number.parseFloat(raw) + return Number.isFinite(n) ? n : null + } + return null +} + +function Sep() { + return ( + + ) +} + +/** + * System Pulse bar — always-on header: health %, degraded/critical counts, + * error rate, p99 and DB size, plus the live dot, Connect popover and theme + * toggle. Collapses to "● N issues" on xs (CSS-only). + */ +export default function PulseBar({ + theme = 'dark', + onToggleTheme, + liveSlot, +}: Readonly) { + const { graph } = useSystemGraph() + const { dashboard, stats } = useDashboard() + + const summary = graph?.system ?? null + const dbMb = dbSizeMb(stats) + const issues = summary ? summary.degraded + summary.critical : 0 + + return ( +
+ OtelContext + +
+ {summary ? ( + <> +
+ +
+ {liveSlot === undefined ? : liveSlot} + + {onToggleTheme && ( + + )} +
+
+ ) +} diff --git a/ui/src/components/shell/Shell.module.css b/ui/src/components/shell/Shell.module.css new file mode 100644 index 0000000..201febc --- /dev/null +++ b/ui/src/components/shell/Shell.module.css @@ -0,0 +1,140 @@ +/* Breakpoints per styles/tokens.css: xs <768, md 768+, xl 1440+. */ + +.shell { + display: flex; + flex-direction: column; + min-height: 100dvh; + background: var(--bg-base); + color: var(--text-1); + font-family: var(--font-sans); + font-size: var(--text-base); +} + +.body { + display: flex; + flex: 1; + min-height: 0; +} + +.main { + flex: 1; + min-width: 0; + padding: var(--space-3); + /* xs: keep content clear of the fixed bottom tab bar */ + padding-bottom: calc(56px + var(--space-3) + env(safe-area-inset-bottom)); +} + +@media (min-width: 768px) { + .main { + padding: var(--space-4); + } +} + +/* ---- icon rail (md+) ---- */ +.rail { + display: none; +} + +@media (min-width: 768px) { + .rail { + display: flex; + flex: none; + flex-direction: column; + gap: var(--space-1); + width: 56px; + padding: var(--space-2) var(--space-1); + border-right: 1px solid var(--stroke-1); + } +} + +@media (min-width: 1440px) { + .rail { + width: 200px; + padding: var(--space-2); + } +} + +/* ---- bottom tab bar (xs only) ---- */ +.tabbar { + position: fixed; + inset: auto 0 0 0; + z-index: 40; + display: flex; + height: calc(56px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + border-top: 1px solid var(--stroke-1); + background: var(--bg-raised); +} + +@media (min-width: 768px) { + .tabbar { + display: none; + } +} + +/* ---- nav items (shared) ---- */ +.navItem { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + min-height: 44px; /* ≥44px touch target on every pointer type */ + border-radius: var(--radius-2); + color: var(--text-3); + text-decoration: none; + font-size: var(--text-2xs); + transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease); +} + +@media (hover: hover) { + .navItem:hover { + background: var(--bg-raised); + color: var(--text-1); + } +} + +.navItemActive { + background: var(--accent-muted); + color: var(--accent); +} + +/* Tab bar items share the row equally, icon stacked over label. */ +.tabbar .navItem { + flex: 1; + flex-direction: column; + gap: 2px; +} + +/* Rail: icon-only until xl, where labels appear inline. */ +.rail .navLabel { + display: none; +} + +@media (min-width: 1440px) { + .rail .navItem { + justify-content: flex-start; + padding: 0 var(--space-3); + font-size: var(--text-sm); + } + + .rail .navLabel { + display: inline; + } +} + +.tooltip { + z-index: 50; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--stroke-2); + border-radius: var(--radius-1); + background: var(--bg-overlay); + color: var(--text-1); + font-size: var(--text-2xs); +} + +/* Labeled rail makes tooltips redundant at xl. */ +@media (min-width: 1440px) { + .tooltip { + display: none; + } +} diff --git a/ui/src/components/shell/Shell.tsx b/ui/src/components/shell/Shell.tsx new file mode 100644 index 0000000..50306a2 --- /dev/null +++ b/ui/src/components/shell/Shell.tsx @@ -0,0 +1,100 @@ +import { useEffect, type ComponentType, type ReactNode } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' +import { Link, useRoute } from 'wouter' +import { LayoutDashboard, Network, Terminal } from 'lucide-react' +import { getWsManager } from '@/lib/wsManager' +import type { Theme } from '@/hooks/useTheme' +import PulseBar from './PulseBar' +import styles from './Shell.module.css' + +interface NavEntry { + href: string + label: string + Icon: ComponentType<{ size?: number | string; 'aria-hidden'?: boolean }> +} + +const NAV_ITEMS: readonly NavEntry[] = [ + { href: '/map', label: 'Service Map', Icon: Network }, + { href: '/dashboard', label: 'Dashboard', Icon: LayoutDashboard }, + { href: '/mcp', label: 'MCP Console', Icon: Terminal }, +] + +function NavLink({ + entry, + variant, +}: Readonly<{ entry: NavEntry; variant: 'rail' | 'tab' }>) { + const [active] = useRoute(entry.href) + const { href, label, Icon } = entry + + const link = ( + + + {label} + + ) + + // Rail icons lose their label below xl — tooltip carries it instead. + if (variant === 'rail') { + return ( + + {link} + + + {label} + + + + ) + } + return link +} + +interface ShellProps { + theme: Theme + onToggleTheme: () => void + children: ReactNode +} + +/** + * Responsive app shell: System Pulse bar on top; navigation as a bottom + * tab bar below 768px, a 56px icon rail from 768px, and a labeled 200px + * rail from 1440px — all CSS-only breakpoints (see styles/tokens.css). + * Hidden variants use display:none, so only one nav is in the a11y tree. + */ +export default function Shell({ + theme, + onToggleTheme, + children, +}: Readonly) { + // The /ws singleton lives for the app lifetime — started once here, + // never stopped (start() is idempotent under StrictMode remounts). + useEffect(() => { + getWsManager().start() + }, []) + + return ( + +
+ +
+ +
{children}
+
+ +
+
+ ) +} diff --git a/ui/src/components/shell/__tests__/ConnectPopover.test.tsx b/ui/src/components/shell/__tests__/ConnectPopover.test.tsx new file mode 100644 index 0000000..b5a0505 --- /dev/null +++ b/ui/src/components/shell/__tests__/ConnectPopover.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ConnectPopover from '../ConnectPopover' + +// userEvent.setup() installs its own navigator.clipboard stub, so the spy +// must wrap that stub (defining our own mock first would be overwritten). +function setup() { + const user = userEvent.setup() + const writeText = vi + .spyOn(navigator.clipboard, 'writeText') + .mockResolvedValue(undefined) + return { user, writeText } +} + +describe('ConnectPopover', () => { + it('opens a menu listing the three connect endpoints', async () => { + const { user } = setup() + render() + + await user.click(screen.getByRole('button', { name: /connect/i })) + + expect(await screen.findByText(/MCP URL/i)).toBeInTheDocument() + expect(screen.getByText(/OTLP gRPC/i)).toBeInTheDocument() + expect(screen.getByText(/OTLP HTTP/i)).toBeInTheDocument() + }) + + it('copies the MCP URL derived from the page origin', async () => { + const { user, writeText } = setup() + render() + + await user.click(screen.getByRole('button', { name: /connect/i })) + await user.click(await screen.findByRole('menuitem', { name: /MCP URL/i })) + + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/mcp`) + }) + + it('copies the OTLP gRPC host:port', async () => { + const { user, writeText } = setup() + render() + + await user.click(screen.getByRole('button', { name: /connect/i })) + await user.click( + await screen.findByRole('menuitem', { name: /OTLP gRPC/i }), + ) + + expect(writeText).toHaveBeenCalledWith(`${window.location.hostname}:4317`) + }) + + it('copies the OTLP HTTP base URL', async () => { + const { user, writeText } = setup() + render() + + await user.click(screen.getByRole('button', { name: /connect/i })) + await user.click( + await screen.findByRole('menuitem', { name: /OTLP HTTP/i }), + ) + + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/v1/`) + }) + + it('keeps the menu open after a copy so several values can be taken', async () => { + const { user } = setup() + render() + + await user.click(screen.getByRole('button', { name: /connect/i })) + await user.click(await screen.findByRole('menuitem', { name: /MCP URL/i })) + + expect(screen.getByRole('menuitem', { name: /OTLP gRPC/i })).toBeInTheDocument() + }) +}) diff --git a/ui/src/components/shell/__tests__/LiveDot.test.tsx b/ui/src/components/shell/__tests__/LiveDot.test.tsx new file mode 100644 index 0000000..2e022ab --- /dev/null +++ b/ui/src/components/shell/__tests__/LiveDot.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import { WsManager } from '@/lib/wsManager' +import LiveDot from '../LiveDot' + +class MockWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly instances: MockWebSocket[] = [] + + readyState = MockWebSocket.CONNECTING + onopen: ((ev: Event) => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + onclose: ((ev: CloseEvent) => void) | null = null + send = vi.fn() + close = vi.fn() + + constructor(public url: string) { + MockWebSocket.instances.push(this) + } + + simulateOpen() { + this.readyState = MockWebSocket.OPEN + this.onopen?.(new Event('open')) + } + + simulateClose() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.(new CloseEvent('close')) + } +} + +const latest = () => MockWebSocket.instances[MockWebSocket.instances.length - 1] + +let manager: WsManager + +beforeEach(() => { + MockWebSocket.instances.length = 0 + vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket) + vi.useFakeTimers() + manager = new WsManager() +}) + +afterEach(() => { + manager.stop() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('LiveDot', () => { + it('shows the warn/connecting state before the first open', () => { + manager.start() + render() + const dot = screen.getByRole('status') + expect(dot).toHaveAccessibleName('connecting') + }) + + it('shows the ok/live state once connected', () => { + manager.start() + render() + act(() => latest().simulateOpen()) + expect(screen.getByRole('status')).toHaveAccessibleName('live') + }) + + it('shows reconnecting with the attempt count after a drop', () => { + manager.start() + render() + act(() => { + latest().simulateOpen() + latest().simulateClose() + }) + expect(screen.getByRole('status')).toHaveAccessibleName( + 'reconnecting (attempt 1)', + ) + }) + + it('shows offline when the manager is stopped', () => { + manager.start() + render() + act(() => manager.stop()) + expect(screen.getByRole('status')).toHaveAccessibleName('offline') + }) +}) diff --git a/ui/src/components/shell/__tests__/PulseBar.test.tsx b/ui/src/components/shell/__tests__/PulseBar.test.tsx new file mode 100644 index 0000000..19b502e --- /dev/null +++ b/ui/src/components/shell/__tests__/PulseBar.test.tsx @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import PulseBar from '../PulseBar' + +const fetchMock = vi.fn() + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function routeFetches(overrides?: { + summary?: Partial> + dashboard?: Partial> + stats?: Record +}) { + const summary = { + total_services: 12, + healthy: 9, + degraded: 2, + critical: 1, + overall_health_score: 0.94, + total_error_rate: 0.042, + avg_latency_ms: 18, + uptime_seconds: 3600, + ...overrides?.summary, + } + const dashboard = { + total_traces: 100, + total_logs: 500, + total_errors: 4, + avg_latency_ms: 18, + error_rate: 4.2, // /api/metrics/dashboard sends percent, not ratio + active_services: 12, + p99_latency_ms: 230, + top_failing_services: [], + ...overrides?.dashboard, + } + const stats = overrides?.stats ?? { db_size_mb: 1228.8 } + + fetchMock.mockImplementation((input) => { + const url = String(input) + if (url.includes('/api/system/graph')) { + return Promise.resolve( + jsonResponse({ + timestamp: '2026-06-11T00:00:00Z', + system: summary, + nodes: [], + edges: [], + }), + ) + } + if (url.includes('/api/metrics/dashboard')) { + return Promise.resolve(jsonResponse(dashboard)) + } + if (url.includes('/api/stats')) { + return Promise.resolve(jsonResponse(stats)) + } + return Promise.resolve(new Response('nf', { status: 404 })) + }) +} + +function renderPulseBar() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return render(, { wrapper }) +} + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() +}) + +describe('PulseBar', () => { + it('renders health, error rate, p99 and DB size with correct formatting', async () => { + routeFetches() + renderPulseBar() + + await waitFor(() => + expect(screen.getByText(/94% healthy/)).toBeInTheDocument(), + ) + // err 4.2% — already-percent field must NOT be re-scaled. + expect(screen.getByText(/err 4\.2%/)).toBeInTheDocument() + expect(screen.getByText(/p99 230ms/)).toBeInTheDocument() + expect(screen.getByText(/DB 1\.2GB/)).toBeInTheDocument() + }) + + it('lists degraded and critical counts only when non-zero', async () => { + routeFetches() + renderPulseBar() + await waitFor(() => + expect(screen.getByText(/2 degraded/)).toBeInTheDocument(), + ) + expect(screen.getByText(/1 critical/)).toBeInTheDocument() + }) + + it('omits zero degraded/critical segments', async () => { + routeFetches({ summary: { degraded: 0, critical: 0 } }) + renderPulseBar() + await waitFor(() => + expect(screen.getByText(/94% healthy/)).toBeInTheDocument(), + ) + expect(screen.queryByText(/degraded/)).not.toBeInTheDocument() + expect(screen.queryByText(/critical/)).not.toBeInTheDocument() + }) + + it('shows the compact issue count for the xs layout', async () => { + routeFetches() + renderPulseBar() + await waitFor(() => expect(screen.getByText(/3 issues/)).toBeInTheDocument()) + }) + + it('shows "all clear" in the compact slot when nothing is degraded', async () => { + routeFetches({ summary: { degraded: 0, critical: 0 } }) + renderPulseBar() + await waitFor(() => + expect(screen.getByText(/all clear/)).toBeInTheDocument(), + ) + }) + + it('omits the DB segment when stats carry no db_size_mb', async () => { + routeFetches({ stats: {} }) + renderPulseBar() + await waitFor(() => + expect(screen.getByText(/94% healthy/)).toBeInTheDocument(), + ) + expect(screen.queryByText(/DB /)).not.toBeInTheDocument() + }) + + it('tints the DB segment as a warning above the size threshold', async () => { + routeFetches({ stats: { db_size_mb: 4096 } }) + renderPulseBar() + const db = await screen.findByText(/DB 4GB/) + expect(db.className).toContain('warn') + }) +}) diff --git a/ui/src/components/shell/__tests__/Shell.test.tsx b/ui/src/components/shell/__tests__/Shell.test.tsx new file mode 100644 index 0000000..fc39561 --- /dev/null +++ b/ui/src/components/shell/__tests__/Shell.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Router } from 'wouter' +import { memoryLocation } from 'wouter/memory-location' +import type { ReactNode } from 'react' +import Shell from '../Shell' + +class StubWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + readyState = StubWebSocket.CONNECTING + onopen: ((ev: Event) => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + onclose: ((ev: CloseEvent) => void) | null = null + send = vi.fn() + close = vi.fn() + constructor(public url: string) {} +} + +const fetchMock = vi.fn(() => + Promise.resolve(new Response('{}', { status: 200 })), +) + +function renderShell(path = '/map', onToggleTheme = vi.fn()) { + const { hook } = memoryLocation({ path }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const utils = render( + +
page
+
, + { wrapper }, + ) + return { ...utils, onToggleTheme } +} + +beforeEach(() => { + vi.stubGlobal('WebSocket', StubWebSocket as unknown as typeof WebSocket) + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Shell', () => { + it('renders the pulse banner, navigation and main content', () => { + renderShell() + expect(screen.getByRole('banner')).toBeInTheDocument() + expect(screen.getAllByRole('navigation').length).toBeGreaterThanOrEqual(2) + expect(screen.getByRole('main')).toContainElement( + screen.getByTestId('page-content'), + ) + }) + + it('exposes both nav variants (rail + bottom tabs) with all destinations', () => { + renderShell() + // Each destination appears twice: once in the rail, once in the tab bar. + for (const name of [/service map/i, /dashboard/i, /mcp console/i]) { + expect(screen.getAllByRole('link', { name })).toHaveLength(2) + } + }) + + it('marks the active route with aria-current', () => { + renderShell('/dashboard') + const active = screen + .getAllByRole('link') + .filter((a) => a.getAttribute('aria-current') === 'page') + expect(active).toHaveLength(2) // rail + tab bar + active.forEach((a) => expect(a).toHaveAttribute('href', '/dashboard')) + }) + + it('wires the theme toggle through to the callback', async () => { + const user = userEvent.setup() + const { onToggleTheme } = renderShell() + await user.click( + screen.getByRole('button', { name: /switch to light theme/i }), + ) + expect(onToggleTheme).toHaveBeenCalledTimes(1) + }) + + it('labels the toggle for the opposite theme when light is active', () => { + const { hook } = memoryLocation({ path: '/map' }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + {}}> +
+ + + , + ) + expect( + screen.getByRole('button', { name: /switch to dark theme/i }), + ).toBeInTheDocument() + }) +}) diff --git a/ui/src/hooks/__tests__/queryAdapters.test.tsx b/ui/src/hooks/__tests__/queryAdapters.test.tsx index e3cfdc3..83e52e2 100644 --- a/ui/src/hooks/__tests__/queryAdapters.test.tsx +++ b/ui/src/hooks/__tests__/queryAdapters.test.tsx @@ -79,6 +79,19 @@ describe('useSystemGraph (TanStack Query adapter)', () => { expect(result.current.graph).toBeNull() expect(result.current.error).toContain('503') }) + + it('reload() triggers a refetch', async () => { + fetchMock.mockResolvedValue(jsonResponse(GRAPH, { 'X-Cache': 'MISS' })) + const { result } = renderHook(() => useSystemGraph(0), { + wrapper: makeWrapper(), + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + const calls = fetchMock.mock.calls.length + result.current.reload() + await waitFor(() => + expect(fetchMock.mock.calls.length).toBeGreaterThan(calls), + ) + }) }) describe('useDashboard (TanStack Query adapter)', () => { @@ -135,4 +148,17 @@ describe('useDashboard (TanStack Query adapter)', () => { expect(result.current.dashboard).toEqual(DASH) expect(result.current.error).toContain('500') }) + + it('reload() refetches both endpoints', async () => { + routeFetches() + const { result } = renderHook(() => useDashboard(0), { + wrapper: makeWrapper(), + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + const calls = fetchMock.mock.calls.length + result.current.reload() + await waitFor(() => + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(calls + 2), + ) + }) }) diff --git a/ui/src/hooks/useDashboard.ts b/ui/src/hooks/useDashboard.ts index a40fc94..f4231c7 100644 --- a/ui/src/hooks/useDashboard.ts +++ b/ui/src/hooks/useDashboard.ts @@ -22,10 +22,14 @@ export function useDashboard(pollInterval = 30_000) { refetchInterval, }); + // refetch is referentially stable in TanStack v5; destructured so the + // dependency array doesn't have to carry the whole query result objects. + const { refetch: refetchDashboard } = dash; + const { refetch: refetchStats } = stats; const reload = useCallback(() => { - void dash.refetch(); - void stats.refetch(); - }, [dash.refetch, stats.refetch]); + void refetchDashboard(); + void refetchStats(); + }, [refetchDashboard, refetchStats]); return { dashboard: dash.data ?? null, diff --git a/ui/src/test-setup.ts b/ui/src/test-setup.ts index bb02c60..48d1a91 100644 --- a/ui/src/test-setup.ts +++ b/ui/src/test-setup.ts @@ -1 +1,21 @@ import '@testing-library/jest-dom/vitest'; + +// jsdom lacks a handful of platform APIs that Radix primitives touch. +// Guarded stubs — real implementations win if jsdom ever grows them. +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} +if (!('ResizeObserver' in globalThis)) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + (globalThis as Record).ResizeObserver = ResizeObserverStub; +} diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 13fbff6..1d02d27 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -1,12 +1,23 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'], include: ['src/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.*', 'src/**/__tests__/**', 'src/vite-env.d.ts'], + }, }, }); From 7e7ee913b1842c943070891ab0ccc51aa21f748a Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 16:40:46 +0000 Subject: [PATCH 35/61] feat(ui): gzip bundle budget gate (npm run check-budgets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-dep node script: gzips the dist output and fails on any breach of ui/budgets.json — initial JS 160KB / initial CSS 24KB / vendored fonts 90KB raw woff2 / lazy chunks 40KB with a temporary 170KB exception for the ServicesView chunk (carries cytoscape until the C3 flow-map rewrite). Initial set = entry script + modulepreloads + stylesheets parsed from dist/index.html. Deliberately not wired into 'build' yet. Current numbers: initial JS 106.61KB, CSS 10.78KB, fonts 67.80KB, ServicesView 156.33KB — all budgets met. Co-Authored-By: Claude Fable 5 --- ui/budgets.json | 12 ++++++ ui/package.json | 3 +- ui/scripts/check-budgets.mjs | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 ui/budgets.json create mode 100644 ui/scripts/check-budgets.mjs diff --git a/ui/budgets.json b/ui/budgets.json new file mode 100644 index 0000000..30cf561 --- /dev/null +++ b/ui/budgets.json @@ -0,0 +1,12 @@ +{ + "$comment": "Gzipped size budgets enforced by scripts/check-budgets.mjs (npm run check-budgets). 'initial' = assets referenced from dist/index.html (entry script + modulepreloads + stylesheets). Fonts are raw woff2 bytes (already compressed). lazyChunkExceptions maps a chunk name prefix (before the content hash) to a higher cap: ServicesView still carries cytoscape until the C3 flow-map rewrite deletes it.", + "gzipKb": { + "initialJs": 160, + "initialCss": 24, + "fonts": 90, + "lazyChunkDefault": 40, + "lazyChunkExceptions": { + "ServicesView": 170 + } + } +} diff --git a/ui/package.json b/ui/package.json index 903c5d7..ad05ef2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,8 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "check-budgets": "node scripts/check-budgets.mjs" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", diff --git a/ui/scripts/check-budgets.mjs b/ui/scripts/check-budgets.mjs new file mode 100644 index 0000000..93256eb --- /dev/null +++ b/ui/scripts/check-budgets.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// Zero-dependency bundle budget gate. Gzips the built dist output and fails +// (exit 1) when any budget in ui/budgets.json is exceeded. Run manually or +// in CI via `npm run check-budgets` — deliberately NOT wired into `build` +// yet (phase C1/C2 reports numbers; later phases flip it to a hard gate). +import { gzipSync } from 'node:zlib'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const uiDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = resolve(uiDir, '../internal/ui/dist'); +const budgets = JSON.parse(readFileSync(join(uiDir, 'budgets.json'), 'utf8')).gzipKb; + +if (!existsSync(join(distDir, 'index.html'))) { + console.error(`check-budgets: ${distDir}/index.html not found — run \`npm run build\` first`); + process.exit(1); +} + +const gzipKb = (path) => gzipSync(readFileSync(path)).length / 1024; +const fmt = (kb) => `${kb.toFixed(2)} KB`; + +// "Initial" = everything index.html references: the entry