From cdcaaec1f3481afe974f6efa936b0e18e739a61b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 18:12:20 +0200 Subject: [PATCH] feat: add count and exportText to metrics --- src/metrics.zig | 160 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/metrics.zig b/src/metrics.zig index 7524539..38beb22 100644 --- a/src/metrics.zig +++ b/src/metrics.zig @@ -319,3 +319,163 @@ pub const Registry = struct { 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); +} +