const std = @import("std"); const stats_mod = @import("stats.zig"); // Metrics primitives (counter/gauge/histogram), two independent rate-limiter // algorithms (token bucket, sliding window counter), and a text-exposition // registry. Fully additive module: does not touch Db/txn/storage internals, // only depends on stats.zig's public Stats type for the export helper. pub const MetricCounter = struct { value: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), pub fn incr(self: *MetricCounter) void { self.incrBy(1); } pub fn incrBy(self: *MetricCounter, delta: u64) void { _ = self.value.fetchAdd(delta, .monotonic); } pub fn get(self: *const MetricCounter) u64 { return self.value.load(.monotonic); } pub fn reset(self: *MetricCounter) void { self.value.store(0, .monotonic); } }; pub const Gauge = struct { bits: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), pub fn set(self: *Gauge, v: f64) void { self.bits.store(@bitCast(v), .monotonic); } pub fn get(self: *const Gauge) f64 { const raw = self.bits.load(.monotonic); return @bitCast(raw); } pub fn add(self: *Gauge, delta: f64) void { while (true) { const cur = self.bits.load(.monotonic); const curF: f64 = @bitCast(cur); const newBits: u64 = @bitCast(curF + delta); if (self.bits.cmpxchgWeak(cur, newBits, .monotonic, .monotonic) == null) return; } } }; pub const HistogramError = error{EmptyBounds}; /// Fixed-bucket histogram with Prometheus-style cumulative-bucket quantile /// estimation. `bounds` must be strictly increasing and is borrowed (caller /// keeps it alive for the histogram's lifetime); values greater than the /// last bound land in an implicit +Inf bucket. pub const Histogram = struct { bounds: []const f64, counts: []u64, sum: f64 = 0, total: u64 = 0, mutex: std.Thread.Mutex = .{}, allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, bounds: []const f64) !Histogram { if (bounds.len == 0) return HistogramError.EmptyBounds; var i: usize = 1; while (i < bounds.len) : (i += 1) { std.debug.assert(bounds[i] > bounds[i - 1]); } const counts = try allocator.alloc(u64, bounds.len + 1); @memset(counts, 0); return .{ .bounds = bounds, .counts = counts, .allocator = allocator }; } pub fn deinit(self: *Histogram) void { self.allocator.free(self.counts); self.* = undefined; } pub fn observe(self: *Histogram, value: f64) void { self.mutex.lock(); defer self.mutex.unlock(); var idx: usize = self.bounds.len; for (self.bounds, 0..) |bound, i| { if (value <= bound) { idx = i; break; } } self.counts[idx] += 1; self.sum += value; self.total += 1; } pub fn count(self: *const Histogram) u64 { return self.total; } pub fn mean(self: *const Histogram) f64 { if (self.total == 0) return 0; return self.sum / @as(f64, @floatFromInt(self.total)); } /// Linear-interpolation quantile estimate over cumulative bucket counts, /// same algorithm Prometheus uses for `histogram_quantile`. q in [0, 1]. pub fn quantile(self: *Histogram, q: f64) f64 { self.mutex.lock(); defer self.mutex.unlock(); if (self.total == 0 or self.bounds.len == 0) return 0; const clamped = std.math.clamp(q, 0, 1); const target = clamped * @as(f64, @floatFromInt(self.total)); var cumulative: u64 = 0; var i: usize = 0; while (i < self.counts.len) : (i += 1) { const prevCumulative = cumulative; cumulative += self.counts[i]; if (@as(f64, @floatFromInt(cumulative)) >= target) { if (i == 0) return self.bounds[0]; if (i == self.bounds.len) return self.bounds[self.bounds.len - 1]; const lower = self.bounds[i - 1]; const upper = self.bounds[i]; const countInBucket = cumulative - prevCumulative; if (countInBucket == 0) return upper; const fraction = (target - @as(f64, @floatFromInt(prevCumulative))) / @as(f64, @floatFromInt(countInBucket)); return lower + fraction * (upper - lower); } } return self.bounds[self.bounds.len - 1]; } pub fn bucketCount(self: *Histogram, idx: usize) u64 { self.mutex.lock(); defer self.mutex.unlock(); return self.counts[idx]; } }; pub fn linearBuckets(allocator: std.mem.Allocator, start: f64, width: f64, n: usize) ![]f64 { std.debug.assert(n > 0); std.debug.assert(width > 0); const out = try allocator.alloc(f64, n); var i: usize = 0; while (i < n) : (i += 1) out[i] = start + width * @as(f64, @floatFromInt(i)); return out; } pub fn exponentialBuckets(allocator: std.mem.Allocator, start: f64, factor: f64, n: usize) ![]f64 { std.debug.assert(n > 0); std.debug.assert(start > 0); std.debug.assert(factor > 1); const out = try allocator.alloc(f64, n); var cur = start; var i: usize = 0; while (i < n) : (i += 1) { out[i] = cur; cur *= factor; } return out; } /// Classic token-bucket rate limiter. Time is passed in explicitly as /// nanoseconds so the algorithm is deterministic and testable; `tryAcquireNow` /// wraps it with the wall clock for real use. pub const TokenBucket = struct { capacity: f64, refillPerSecond: f64, tokens: f64, lastRefillNanos: u64, mutex: std.Thread.Mutex = .{}, pub fn init(capacity: f64, refillPerSecond: f64, nowNanos: u64) TokenBucket { std.debug.assert(capacity > 0); std.debug.assert(refillPerSecond > 0); return .{ .capacity = capacity, .refillPerSecond = refillPerSecond, .tokens = capacity, .lastRefillNanos = nowNanos, }; } fn refillLocked(self: *TokenBucket, nowNanos: u64) void { if (nowNanos <= self.lastRefillNanos) return; const elapsedNanos = nowNanos - self.lastRefillNanos; const elapsedSeconds = @as(f64, @floatFromInt(elapsedNanos)) / std.time.ns_per_s; self.tokens = @min(self.capacity, self.tokens + elapsedSeconds * self.refillPerSecond); self.lastRefillNanos = nowNanos; } /// Attempts to consume `n` tokens at time `nowNanos`. All-or-nothing: /// never partially consumes on failure. pub fn tryAcquireAt(self: *TokenBucket, n: f64, nowNanos: u64) bool { self.mutex.lock(); defer self.mutex.unlock(); self.refillLocked(nowNanos); if (self.tokens >= n) { self.tokens -= n; return true; } return false; } pub fn tryAcquireNow(self: *TokenBucket, n: f64) bool { return self.tryAcquireAt(n, @intCast(std.time.nanoTimestamp())); } pub fn availableAt(self: *TokenBucket, nowNanos: u64) f64 { self.mutex.lock(); defer self.mutex.unlock(); self.refillLocked(nowNanos); return self.tokens; } }; /// Sliding-window-counter rate limiter (the "Cloudflare" approximation): /// tracks a count for the current fixed window and the previous one, and /// weights the previous window's count by how much of it still overlaps the /// trailing `windowNanos` interval. O(1) memory unlike a sliding log, more /// accurate at window boundaries than a plain fixed window. pub const SlidingWindowLimiter = struct { limit: u64, windowNanos: u64, currentWindowStart: u64, currentCount: u64 = 0, previousCount: u64 = 0, mutex: std.Thread.Mutex = .{}, pub fn init(limit: u64, windowNanos: u64, nowNanos: u64) SlidingWindowLimiter { std.debug.assert(limit > 0); std.debug.assert(windowNanos > 0); return .{ .limit = limit, .windowNanos = windowNanos, .currentWindowStart = alignedWindowStart(nowNanos, windowNanos), }; } fn alignedWindowStart(nowNanos: u64, windowNanos: u64) u64 { return (nowNanos / windowNanos) * windowNanos; } fn rotateLocked(self: *SlidingWindowLimiter, nowNanos: u64) void { const windowStart = alignedWindowStart(nowNanos, self.windowNanos); if (windowStart == self.currentWindowStart) return; const windowsElapsed = (windowStart - self.currentWindowStart) / self.windowNanos; if (windowsElapsed == 1) { self.previousCount = self.currentCount; } else { self.previousCount = 0; } self.currentCount = 0; self.currentWindowStart = windowStart; } pub fn allowAt(self: *SlidingWindowLimiter, nowNanos: u64) bool { self.mutex.lock(); defer self.mutex.unlock(); self.rotateLocked(nowNanos); const elapsedInWindow = nowNanos - self.currentWindowStart; const overlap = 1.0 - @as(f64, @floatFromInt(elapsedInWindow)) / @as(f64, @floatFromInt(self.windowNanos)); const clampedOverlap = std.math.clamp(overlap, 0, 1); const weighted = @as(f64, @floatFromInt(self.previousCount)) * clampedOverlap + @as(f64, @floatFromInt(self.currentCount)); if (weighted >= @as(f64, @floatFromInt(self.limit))) return false; self.currentCount += 1; return true; } pub fn allowNow(self: *SlidingWindowLimiter) bool { return self.allowAt(@intCast(std.time.nanoTimestamp())); } }; const MetricKind = enum { counter, gauge, histogram }; const MetricRef = union(MetricKind) { counter: *const MetricCounter, gauge: *const Gauge, histogram: *Histogram, }; /// Named-metric registry with a Prometheus-style text exporter. Stores only /// borrowed pointers (owned name copies) - callers keep the metrics alive. pub const Registry = struct { allocator: std.mem.Allocator, entries: std.StringHashMapUnmanaged(MetricRef) = .{}, pub fn init(allocator: std.mem.Allocator) Registry { return .{ .allocator = allocator }; } pub fn deinit(self: *Registry) void { var it = self.entries.keyIterator(); while (it.next()) |key| self.allocator.free(key.*); self.entries.deinit(self.allocator); self.* = undefined; } fn put(self: *Registry, name: []const u8, ref: MetricRef) !void { if (self.entries.getPtr(name)) |existing| { existing.* = ref; return; } const owned = try self.allocator.dupe(u8, name); errdefer self.allocator.free(owned); try self.entries.put(self.allocator, owned, ref); } pub fn registerCounter(self: *Registry, name: []const u8, c: *const MetricCounter) !void { try self.put(name, .{ .counter = c }); } pub fn registerGauge(self: *Registry, name: []const u8, g: *const Gauge) !void { try self.put(name, .{ .gauge = g }); } pub fn registerHistogram(self: *Registry, name: []const u8, h: *Histogram) !void { try self.put(name, .{ .histogram = h }); } pub fn count(self: *const Registry) usize { return self.entries.count(); } /// Writes every registered metric in Prometheus exposition format. /// Output is sorted by name so it is deterministic (and diffable). pub fn exportText(self: *Registry, allocator: std.mem.Allocator, writer: anytype) !void { var names = try allocator.alloc([]const u8, self.entries.count()); defer allocator.free(names); var it = self.entries.keyIterator(); var i: usize = 0; while (it.next()) |key| : (i += 1) names[i] = key.*; std.mem.sort([]const u8, names, {}, lessThanStr); for (names) |name| { const ref = self.entries.get(name).?; switch (ref) { .counter => |c| try writer.print("{s} {d}\n", .{ name, c.get() }), .gauge => |g| try writer.print("{s} {d}\n", .{ name, g.get() }), .histogram => |h| { var cumulative: u64 = 0; for (h.bounds, 0..) |bound, idx| { cumulative += h.counts[idx]; try writer.print("{s}_bucket{{le=\"{d}\"}} {d}\n", .{ name, bound, cumulative }); } cumulative += h.counts[h.bounds.len]; try writer.print("{s}_bucket{{le=\"+Inf\"}} {d}\n", .{ name, cumulative }); try writer.print("{s}_sum {d}\n", .{ name, h.sum }); try writer.print("{s}_count {d}\n", .{ name, h.total }); }, } } } }; fn lessThanStr(_: void, a: []const u8, b: []const u8) bool { return std.mem.order(u8, a, b) == .lt; } /// Renders a Db Stats snapshot in the same Prometheus text format used by /// Registry.exportText, so `stats` output and ad-hoc registry metrics can be /// scraped through one shared shape. pub fn formatDbStats(writer: anytype, prefix: []const u8, s: stats_mod.Stats) !void { try writer.print("{s}_memtable_entries {d}\n", .{ prefix, s.memtableEntries }); try writer.print("{s}_memtable_bytes {d}\n", .{ prefix, s.memtableBytes }); try writer.print("{s}_sstable_count {d}\n", .{ prefix, s.sstableCount }); try writer.print("{s}_live_entries {d}\n", .{ prefix, s.liveEntries }); try writer.print("{s}_physical_bytes {d}\n", .{ prefix, s.physicalBytes }); try writer.print("{s}_cache_hits {d}\n", .{ prefix, s.cacheHits }); try writer.print("{s}_cache_misses {d}\n", .{ prefix, s.cacheMisses }); try writer.print("{s}_cache_hit_rate {d:.6}\n", .{ prefix, s.cacheHitRate() }); try writer.print("{s}_next_seq {d}\n", .{ prefix, s.nextSeq }); const deepest = s.deepestLevel(); var i: usize = 0; while (i <= deepest) : (i += 1) { const level = s.levels[i]; try writer.print("{s}_level_tables{{level=\"{d}\"}} {d}\n", .{ prefix, i, level.tables }); try writer.print("{s}_level_entries{{level=\"{d}\"}} {d}\n", .{ prefix, i, level.entries }); } } test "counter incr and incrBy accumulate" { var c = MetricCounter{}; c.incr(); c.incrBy(41); try std.testing.expectEqual(@as(u64, 42), c.get()); c.reset(); try std.testing.expectEqual(@as(u64, 0), c.get()); } test "gauge set/add round trip through bit reinterpretation" { var g = Gauge{}; g.set(3.5); try std.testing.expectApproxEqAbs(@as(f64, 3.5), g.get(), 1e-12); g.add(-1.25); try std.testing.expectApproxEqAbs(@as(f64, 2.25), g.get(), 1e-12); } test "histogram observe buckets and mean" { const bounds = [_]f64{ 10, 50, 100 }; var h = try Histogram.init(std.testing.allocator, &bounds); defer h.deinit(); h.observe(5); h.observe(20); h.observe(20); h.observe(75); h.observe(1000); try std.testing.expectEqual(@as(u64, 1), h.bucketCount(0)); try std.testing.expectEqual(@as(u64, 2), h.bucketCount(1)); try std.testing.expectEqual(@as(u64, 1), h.bucketCount(2)); try std.testing.expectEqual(@as(u64, 1), h.bucketCount(3)); try std.testing.expectEqual(@as(u64, 5), h.count()); try std.testing.expectApproxEqAbs(@as(f64, (5 + 20 + 20 + 75 + 1000) / 5.0), h.mean(), 1e-9); } test "histogram quantile interpolates within a bucket" { const bounds = [_]f64{ 100, 200 }; var h = try Histogram.init(std.testing.allocator, &bounds); defer h.deinit(); // 4 values land in the (0,100] bucket, evenly spread by construction; // linear interpolation only sees prev/cur cumulative counts, so the // median of an all-in-one-bucket histogram is the bucket's own bound. var i: usize = 0; while (i < 4) : (i += 1) h.observe(50); try std.testing.expectApproxEqAbs(@as(f64, 100), h.quantile(0.5), 1e-9); // Now spread evenly across both finite buckets: p50 should land exactly // on the boundary between them. h.observe(150); h.observe(150); h.observe(150); h.observe(150); // counts: bucket0=4 (<=100), bucket1=4 (<=200) => cumulative [4,8], total 8 const q50 = h.quantile(0.5); try std.testing.expectApproxEqAbs(@as(f64, 100), q50, 1e-9); } test "histogram quantile falls back to last bound for the +Inf bucket" { const bounds = [_]f64{ 10, 20 }; var h = try Histogram.init(std.testing.allocator, &bounds); defer h.deinit(); h.observe(5); h.observe(9999); try std.testing.expectApproxEqAbs(@as(f64, 20), h.quantile(1.0), 1e-9); } test "linearBuckets and exponentialBuckets produce expected boundaries" { const lin = try linearBuckets(std.testing.allocator, 0, 10, 5); defer std.testing.allocator.free(lin); try std.testing.expectEqualSlices(f64, &[_]f64{ 0, 10, 20, 30, 40 }, lin); const exp = try exponentialBuckets(std.testing.allocator, 1, 2, 5); defer std.testing.allocator.free(exp); try std.testing.expectEqualSlices(f64, &[_]f64{ 1, 2, 4, 8, 16 }, exp); } test "token bucket refills over time and denies when empty" { var b = TokenBucket.init(10, 5, 0); // 10 capacity, 5 tokens/sec try std.testing.expect(b.tryAcquireAt(10, 0)); try std.testing.expect(!b.tryAcquireAt(1, 0)); // half a second later: +2.5 tokens try std.testing.expect(!b.tryAcquireAt(3, std.time.ns_per_s / 2)); try std.testing.expectApproxEqAbs(@as(f64, 2.5), b.availableAt(std.time.ns_per_s / 2), 1e-9); // two full seconds later than that: refill caps at capacity const later = std.time.ns_per_s / 2 + 2 * std.time.ns_per_s; try std.testing.expect(b.tryAcquireAt(10, later)); try std.testing.expect(!b.tryAcquireAt(1, later)); } test "token bucket never partially consumes on a failed acquire" { var b = TokenBucket.init(5, 1, 0); try std.testing.expect(!b.tryAcquireAt(6, 0)); try std.testing.expectApproxEqAbs(@as(f64, 5), b.availableAt(0), 1e-9); } test "sliding window limiter smooths across a window boundary" { const windowNs: u64 = std.time.ns_per_s; var lim = SlidingWindowLimiter.init(10, windowNs, 0); var i: usize = 0; while (i < 10) : (i += 1) try std.testing.expect(lim.allowAt(0)); try std.testing.expect(!lim.allowAt(0)); // exhausted current window // Halfway into the next window: previous window's 10 requests are // weighted at 50% overlap (5), so only 5 more should be allowed before // the weighted total reaches the limit of 10. const halfwayNextWindow = windowNs + windowNs / 2; var allowed: usize = 0; i = 0; while (i < 20) : (i += 1) { if (lim.allowAt(halfwayNextWindow)) allowed += 1; } try std.testing.expectEqual(@as(usize, 5), allowed); } test "sliding window limiter resets fully after a gap of more than one window" { const windowNs: u64 = std.time.ns_per_s; var lim = SlidingWindowLimiter.init(3, windowNs, 0); try std.testing.expect(lim.allowAt(0)); try std.testing.expect(lim.allowAt(0)); try std.testing.expect(lim.allowAt(0)); try std.testing.expect(!lim.allowAt(0)); // Jump 10 windows ahead: previous window count must not leak in. const farFuture = 10 * windowNs; try std.testing.expect(lim.allowAt(farFuture)); try std.testing.expect(lim.allowAt(farFuture)); try std.testing.expect(lim.allowAt(farFuture)); try std.testing.expect(!lim.allowAt(farFuture)); } test "registry exports counters gauges and histograms as sorted prometheus text" { var c = MetricCounter{}; c.incrBy(7); var g = Gauge{}; g.set(2.5); const bounds = [_]f64{ 5, 10 }; var h = try Histogram.init(std.testing.allocator, &bounds); defer h.deinit(); h.observe(3); h.observe(8); var reg = Registry.init(std.testing.allocator); defer reg.deinit(); try reg.registerCounter("requests_total", &c); try reg.registerGauge("temperature", &g); try reg.registerHistogram("latency", &h); try std.testing.expectEqual(@as(usize, 3), reg.count()); var buf = std.ArrayList(u8).init(std.testing.allocator); defer buf.deinit(); try reg.exportText(std.testing.allocator, buf.writer()); const text = buf.items; // sorted alphabetically: latency < requests_total < temperature try std.testing.expect(std.mem.indexOf(u8, text, "latency_bucket{le=\"5\"} 1\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "latency_bucket{le=\"10\"} 2\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "latency_bucket{le=\"+Inf\"} 2\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "latency_sum 11") != null); try std.testing.expect(std.mem.indexOf(u8, text, "latency_count 2\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "requests_total 7\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "temperature 2.5\n") != null); const latencyPos = std.mem.indexOf(u8, text, "latency_bucket").?; const requestsPos = std.mem.indexOf(u8, text, "requests_total").?; const temperaturePos = std.mem.indexOf(u8, text, "temperature").?; try std.testing.expect(latencyPos < requestsPos); try std.testing.expect(requestsPos < temperaturePos); } test "registry re-registering the same name overwrites rather than duplicating" { var c1 = MetricCounter{}; c1.incrBy(1); var c2 = MetricCounter{}; c2.incrBy(99); var reg = Registry.init(std.testing.allocator); defer reg.deinit(); try reg.registerCounter("x", &c1); try reg.registerCounter("x", &c2); try std.testing.expectEqual(@as(usize, 1), reg.count()); var buf = std.ArrayList(u8).init(std.testing.allocator); defer buf.deinit(); try reg.exportText(std.testing.allocator, buf.writer()); try std.testing.expectEqualStrings("x 99\n", buf.items); } test "formatDbStats renders levels up to the deepest populated one" { var s = stats_mod.Stats{}; s.memtableEntries = 3; s.memtableBytes = 128; s.sstableCount = 2; s.cacheHits = 9; s.cacheMisses = 1; s.levels[0].tables = 1; s.levels[0].entries = 10; s.levels[2].tables = 4; s.levels[2].entries = 40; var buf = std.ArrayList(u8).init(std.testing.allocator); defer buf.deinit(); try formatDbStats(buf.writer(), "cobalt", s); const text = buf.items; try std.testing.expect(std.mem.indexOf(u8, text, "cobalt_memtable_entries 3\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "cobalt_cache_hit_rate 0.900000\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "cobalt_level_tables{level=\"0\"} 1\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, "cobalt_level_tables{level=\"2\"} 4\n") != null); // level 1 is empty but must still appear since deepestLevel is 2 try std.testing.expect(std.mem.indexOf(u8, text, "cobalt_level_tables{level=\"1\"} 0\n") != null); // level 3+ must not appear at all try std.testing.expect(std.mem.indexOf(u8, text, "level=\"3\"") == null); }