Files
cobalt/src/metrics.zig
T
2026-09-07 18:18:12 +02:00

601 lines
23 KiB
Zig

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);
}