logfire client for zig
at c7a46726fe7ff8bb65e4e72c985aa146fbf6d404 615 lines 21 kB view raw
1//! metrics for observability 2//! 3//! provides counter, gauge, up-down counter, and histogram instruments 4//! matching the OpenTelemetry metrics specification. 5//! 6//! ## usage 7//! 8//! ```zig 9//! var counter = logfire.u64_counter("requests.total", .{ 10//! .description = "total HTTP requests", 11//! .unit = "1", 12//! }); 13//! counter.add(1, &.{.{ .key = "method", .value = .{ .string = "GET" } }}); 14//! ``` 15 16const std = @import("std"); 17const Attribute = @import("attribute.zig").Attribute; 18 19// ============================================================================ 20// instrument options 21// ============================================================================ 22 23pub const InstrumentOptions = struct { 24 description: []const u8 = "", 25 unit: []const u8 = "", 26}; 27 28pub const HistogramOptions = struct { 29 description: []const u8 = "", 30 unit: []const u8 = "", 31 /// explicit bucket boundaries (defaults to OpenTelemetry default boundaries) 32 boundaries: ?[]const f64 = null, 33}; 34 35pub const ExponentialHistogramOptions = struct { 36 description: []const u8 = "", 37 unit: []const u8 = "", 38 /// scale factor for exponential buckets (higher = more precision) 39 scale: i8 = 20, 40}; 41 42// ============================================================================ 43// counter - monotonically increasing value 44// ============================================================================ 45 46pub fn Counter(comptime T: type) type { 47 return struct { 48 const Self = @This(); 49 50 name: []const u8, 51 description: []const u8, 52 unit: []const u8, 53 value: if (T == u64) std.atomic.Value(u64) else if (T == f64) std.atomic.Value(u64) else @compileError("Counter supports u64 and f64"), 54 55 pub fn init(name: []const u8, opts: InstrumentOptions) Self { 56 return .{ 57 .name = name, 58 .description = opts.description, 59 .unit = opts.unit, 60 .value = @TypeOf(@as(Self, undefined).value).init(0), 61 }; 62 } 63 64 pub fn add(self: *Self, delta: T, attributes: []const Attribute) void { 65 _ = attributes; // TODO: attribute aggregation 66 if (T == f64) { 67 // store f64 as bits 68 const bits: u64 = @bitCast(delta); 69 const old_bits = self.value.load(.monotonic); 70 const old_val: f64 = @bitCast(old_bits); 71 const new_bits: u64 = @bitCast(old_val + delta); 72 // simple add for now (not atomic for f64, but close enough for metrics) 73 self.value.store(new_bits, .monotonic); 74 _ = bits; 75 } else { 76 _ = self.value.fetchAdd(delta, .monotonic); 77 } 78 } 79 80 pub fn get(self: *const Self) T { 81 const raw = self.value.load(.monotonic); 82 if (T == f64) { 83 return @bitCast(raw); 84 } 85 return raw; 86 } 87 }; 88} 89 90// ============================================================================ 91// gauge - instantaneous value 92// ============================================================================ 93 94pub fn Gauge(comptime T: type) type { 95 return struct { 96 const Self = @This(); 97 98 name: []const u8, 99 description: []const u8, 100 unit: []const u8, 101 value: if (T == i64) std.atomic.Value(i64) else if (T == u64) std.atomic.Value(u64) else if (T == f64) std.atomic.Value(u64) else @compileError("Gauge supports i64, u64, and f64"), 102 103 pub fn init(name: []const u8, opts: InstrumentOptions) Self { 104 return .{ 105 .name = name, 106 .description = opts.description, 107 .unit = opts.unit, 108 .value = @TypeOf(@as(Self, undefined).value).init(0), 109 }; 110 } 111 112 pub fn record(self: *Self, value: T, attributes: []const Attribute) void { 113 _ = attributes; 114 if (T == f64) { 115 self.value.store(@bitCast(value), .monotonic); 116 } else { 117 self.value.store(value, .monotonic); 118 } 119 } 120 121 pub fn get(self: *const Self) T { 122 const raw = self.value.load(.monotonic); 123 if (T == f64) { 124 return @bitCast(raw); 125 } 126 return raw; 127 } 128 }; 129} 130 131// ============================================================================ 132// up-down counter - bidirectional counter 133// ============================================================================ 134 135pub fn UpDownCounter(comptime T: type) type { 136 return struct { 137 const Self = @This(); 138 139 name: []const u8, 140 description: []const u8, 141 unit: []const u8, 142 value: if (T == i64) std.atomic.Value(i64) else if (T == f64) std.atomic.Value(u64) else @compileError("UpDownCounter supports i64 and f64"), 143 144 pub fn init(name: []const u8, opts: InstrumentOptions) Self { 145 return .{ 146 .name = name, 147 .description = opts.description, 148 .unit = opts.unit, 149 .value = @TypeOf(@as(Self, undefined).value).init(0), 150 }; 151 } 152 153 pub fn add(self: *Self, delta: T, attributes: []const Attribute) void { 154 _ = attributes; 155 if (T == f64) { 156 const old_bits = self.value.load(.monotonic); 157 const old_val: f64 = @bitCast(old_bits); 158 const new_bits: u64 = @bitCast(old_val + delta); 159 self.value.store(new_bits, .monotonic); 160 } else { 161 _ = self.value.fetchAdd(delta, .monotonic); 162 } 163 } 164 165 pub fn get(self: *const Self) T { 166 const raw = self.value.load(.monotonic); 167 if (T == f64) { 168 return @bitCast(raw); 169 } 170 return raw; 171 } 172 }; 173} 174 175// ============================================================================ 176// histogram - value distribution with explicit buckets 177// ============================================================================ 178 179pub fn Histogram(comptime T: type) type { 180 return struct { 181 const Self = @This(); 182 183 name: []const u8, 184 description: []const u8, 185 unit: []const u8, 186 boundaries: []const f64, 187 counts: []std.atomic.Value(u64), 188 sum: std.atomic.Value(i64), 189 count: std.atomic.Value(u64), 190 min: std.atomic.Value(u64), 191 max: std.atomic.Value(u64), 192 allocator: std.mem.Allocator, 193 194 /// OpenTelemetry default bucket boundaries 195 pub const default_boundaries = [_]f64{ 0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000 }; 196 197 pub fn init(allocator: std.mem.Allocator, name: []const u8, opts: HistogramOptions) !Self { 198 const boundaries = opts.boundaries orelse &default_boundaries; 199 const counts = try allocator.alloc(std.atomic.Value(u64), boundaries.len + 1); 200 for (counts) |*c| { 201 c.* = std.atomic.Value(u64).init(0); 202 } 203 204 return .{ 205 .name = name, 206 .description = opts.description, 207 .unit = opts.unit, 208 .boundaries = boundaries, 209 .counts = counts, 210 .sum = std.atomic.Value(i64).init(0), 211 .count = std.atomic.Value(u64).init(0), 212 .min = std.atomic.Value(u64).init(std.math.maxInt(u64)), 213 .max = std.atomic.Value(u64).init(0), 214 .allocator = allocator, 215 }; 216 } 217 218 pub fn deinit(self: *Self) void { 219 self.allocator.free(self.counts); 220 } 221 222 pub fn record(self: *Self, value: T, attributes: []const Attribute) void { 223 _ = attributes; 224 const val_f64: f64 = if (T == f64) value else @floatFromInt(value); 225 226 // find bucket 227 var bucket: usize = self.boundaries.len; 228 for (self.boundaries, 0..) |bound, i| { 229 if (val_f64 < bound) { 230 bucket = i; 231 break; 232 } 233 } 234 235 _ = self.counts[bucket].fetchAdd(1, .monotonic); 236 _ = self.sum.fetchAdd(@intFromFloat(val_f64), .monotonic); 237 _ = self.count.fetchAdd(1, .monotonic); 238 239 // update min/max 240 const val_bits: u64 = @bitCast(val_f64); 241 while (true) { 242 const old_min = self.min.load(.monotonic); 243 const old_min_f64: f64 = @bitCast(old_min); 244 if (val_f64 >= old_min_f64) break; 245 if (self.min.cmpxchgWeak(old_min, val_bits, .monotonic, .monotonic) == null) break; 246 } 247 while (true) { 248 const old_max = self.max.load(.monotonic); 249 const old_max_f64: f64 = @bitCast(old_max); 250 if (val_f64 <= old_max_f64) break; 251 if (self.max.cmpxchgWeak(old_max, val_bits, .monotonic, .monotonic) == null) break; 252 } 253 } 254 255 pub fn getCount(self: *const Self) u64 { 256 return self.count.load(.monotonic); 257 } 258 259 pub fn getSum(self: *const Self) f64 { 260 return @floatFromInt(self.sum.load(.monotonic)); 261 } 262 }; 263} 264 265// ============================================================================ 266// exponential histogram - Base2 exponential bucket histogram 267// ============================================================================ 268 269pub fn ExponentialHistogram(comptime T: type) type { 270 return struct { 271 const Self = @This(); 272 273 name: []const u8, 274 description: []const u8, 275 unit: []const u8, 276 scale: i8, 277 sum: std.atomic.Value(i64), 278 count: std.atomic.Value(u64), 279 zero_count: std.atomic.Value(u64), 280 min: std.atomic.Value(u64), 281 max: std.atomic.Value(u64), 282 // positive bucket counts (simplified - fixed size) 283 positive_counts: [256]std.atomic.Value(u64), 284 positive_offset: i32, 285 286 pub fn init(name: []const u8, opts: ExponentialHistogramOptions) Self { 287 var self = Self{ 288 .name = name, 289 .description = opts.description, 290 .unit = opts.unit, 291 .scale = opts.scale, 292 .sum = std.atomic.Value(i64).init(0), 293 .count = std.atomic.Value(u64).init(0), 294 .zero_count = std.atomic.Value(u64).init(0), 295 .min = std.atomic.Value(u64).init(std.math.maxInt(u64)), 296 .max = std.atomic.Value(u64).init(0), 297 .positive_counts = undefined, 298 .positive_offset = 0, 299 }; 300 for (&self.positive_counts) |*c| { 301 c.* = std.atomic.Value(u64).init(0); 302 } 303 return self; 304 } 305 306 pub fn record(self: *Self, value: T, attributes: []const Attribute) void { 307 _ = attributes; 308 const val_f64: f64 = if (T == f64) value else @floatFromInt(value); 309 310 if (val_f64 == 0) { 311 _ = self.zero_count.fetchAdd(1, .monotonic); 312 } else if (val_f64 > 0) { 313 // compute bucket index using base2 exponential 314 const scale_factor = std.math.pow(f64, 2, @as(f64, @floatFromInt(self.scale))); 315 const bucket_idx: i32 = @intFromFloat(@ceil(@log2(val_f64) * scale_factor)); 316 const adjusted_idx: usize = @intCast(@max(0, @min(255, bucket_idx - self.positive_offset))); 317 _ = self.positive_counts[adjusted_idx].fetchAdd(1, .monotonic); 318 } 319 320 _ = self.sum.fetchAdd(@intFromFloat(val_f64), .monotonic); 321 _ = self.count.fetchAdd(1, .monotonic); 322 323 // update min/max 324 const val_bits: u64 = @bitCast(val_f64); 325 while (true) { 326 const old_min = self.min.load(.monotonic); 327 const old_min_f64: f64 = @bitCast(old_min); 328 if (val_f64 >= old_min_f64) break; 329 if (self.min.cmpxchgWeak(old_min, val_bits, .monotonic, .monotonic) == null) break; 330 } 331 while (true) { 332 const old_max = self.max.load(.monotonic); 333 const old_max_f64: f64 = @bitCast(old_max); 334 if (val_f64 <= old_max_f64) break; 335 if (self.max.cmpxchgWeak(old_max, val_bits, .monotonic, .monotonic) == null) break; 336 } 337 } 338 339 pub fn getCount(self: *const Self) u64 { 340 return self.count.load(.monotonic); 341 } 342 343 pub fn getSum(self: *const Self) f64 { 344 return @floatFromInt(self.sum.load(.monotonic)); 345 } 346 }; 347} 348 349// ============================================================================ 350// observable instruments (callback-based) 351// ============================================================================ 352 353pub fn ObservableCallback(comptime T: type) type { 354 return *const fn (observer: *Observer(T)) void; 355} 356 357pub fn Observer(comptime T: type) type { 358 return struct { 359 const Self = @This(); 360 361 value: T = 0, 362 attributes: [32]Attribute = undefined, 363 attribute_count: usize = 0, 364 365 pub fn observe(self: *Self, value: T, attributes: []const Attribute) void { 366 self.value = value; 367 self.attribute_count = @min(attributes.len, 32); 368 @memcpy(self.attributes[0..self.attribute_count], attributes[0..self.attribute_count]); 369 } 370 }; 371} 372 373pub fn ObservableCounter(comptime T: type) type { 374 return struct { 375 const Self = @This(); 376 377 name: []const u8, 378 description: []const u8, 379 unit: []const u8, 380 callback: ObservableCallback(T), 381 382 pub fn init(name: []const u8, opts: InstrumentOptions, callback: ObservableCallback(T)) Self { 383 return .{ 384 .name = name, 385 .description = opts.description, 386 .unit = opts.unit, 387 .callback = callback, 388 }; 389 } 390 391 pub fn observe(self: *const Self) Observer(T) { 392 var observer = Observer(T){}; 393 self.callback(&observer); 394 return observer; 395 } 396 }; 397} 398 399pub fn ObservableGauge(comptime T: type) type { 400 return struct { 401 const Self = @This(); 402 403 name: []const u8, 404 description: []const u8, 405 unit: []const u8, 406 callback: ObservableCallback(T), 407 408 pub fn init(name: []const u8, opts: InstrumentOptions, callback: ObservableCallback(T)) Self { 409 return .{ 410 .name = name, 411 .description = opts.description, 412 .unit = opts.unit, 413 .callback = callback, 414 }; 415 } 416 417 pub fn observe(self: *const Self) Observer(T) { 418 var observer = Observer(T){}; 419 self.callback(&observer); 420 return observer; 421 } 422 }; 423} 424 425pub fn ObservableUpDownCounter(comptime T: type) type { 426 return struct { 427 const Self = @This(); 428 429 name: []const u8, 430 description: []const u8, 431 unit: []const u8, 432 callback: ObservableCallback(T), 433 434 pub fn init(name: []const u8, opts: InstrumentOptions, callback: ObservableCallback(T)) Self { 435 return .{ 436 .name = name, 437 .description = opts.description, 438 .unit = opts.unit, 439 .callback = callback, 440 }; 441 } 442 443 pub fn observe(self: *const Self) Observer(T) { 444 var observer = Observer(T){}; 445 self.callback(&observer); 446 return observer; 447 } 448 }; 449} 450 451// ============================================================================ 452// convenience type aliases matching Rust API 453// ============================================================================ 454 455pub const U64Counter = Counter(u64); 456pub const F64Counter = Counter(f64); 457 458pub const I64Gauge = Gauge(i64); 459pub const U64Gauge = Gauge(u64); 460pub const F64Gauge = Gauge(f64); 461 462pub const I64UpDownCounter = UpDownCounter(i64); 463pub const F64UpDownCounter = UpDownCounter(f64); 464 465pub const U64Histogram = Histogram(u64); 466pub const F64Histogram = Histogram(f64); 467 468pub const U64ExponentialHistogram = ExponentialHistogram(u64); 469pub const F64ExponentialHistogram = ExponentialHistogram(f64); 470 471pub const U64ObservableCounter = ObservableCounter(u64); 472pub const F64ObservableCounter = ObservableCounter(f64); 473 474pub const I64ObservableGauge = ObservableGauge(i64); 475pub const U64ObservableGauge = ObservableGauge(u64); 476pub const F64ObservableGauge = ObservableGauge(f64); 477 478pub const I64ObservableUpDownCounter = ObservableUpDownCounter(i64); 479pub const F64ObservableUpDownCounter = ObservableUpDownCounter(f64); 480 481// ============================================================================ 482// metric data for export 483// ============================================================================ 484 485/// aggregation temporality for metric export 486pub const AggregationTemporality = enum(u8) { 487 unspecified = 0, 488 delta = 1, 489 cumulative = 2, 490}; 491 492/// data point for sum/gauge metrics 493pub const NumberDataPoint = struct { 494 start_time_ns: i128, 495 time_ns: i128, 496 value: union(enum) { 497 int: i64, 498 double: f64, 499 }, 500 attributes: []const Attribute = &.{}, 501}; 502 503/// data point for histogram metrics 504pub const HistogramDataPoint = struct { 505 start_time_ns: i128, 506 time_ns: i128, 507 count: u64, 508 sum: f64, 509 bucket_counts: []const u64, 510 explicit_bounds: []const f64, 511 min: f64, 512 max: f64, 513 attributes: []const Attribute = &.{}, 514}; 515 516/// data point for exponential histogram metrics 517pub const ExponentialHistogramDataPoint = struct { 518 start_time_ns: i128, 519 time_ns: i128, 520 count: u64, 521 sum: f64, 522 scale: i8, 523 zero_count: u64, 524 positive_offset: i32, 525 positive_bucket_counts: []const u64, 526 min: f64, 527 max: f64, 528 attributes: []const Attribute = &.{}, 529}; 530 531/// metric data for export 532pub const MetricData = struct { 533 name: []const u8, 534 description: []const u8 = "", 535 unit: []const u8 = "", 536 data: union(enum) { 537 sum: struct { 538 data_points: []const NumberDataPoint, 539 temporality: AggregationTemporality = .cumulative, 540 is_monotonic: bool = true, 541 }, 542 gauge: struct { 543 data_points: []const NumberDataPoint, 544 }, 545 histogram: struct { 546 data_points: []const HistogramDataPoint, 547 temporality: AggregationTemporality = .cumulative, 548 }, 549 exponential_histogram: struct { 550 data_points: []const ExponentialHistogramDataPoint, 551 temporality: AggregationTemporality = .cumulative, 552 }, 553 }, 554}; 555 556// ============================================================================ 557// tests 558// ============================================================================ 559 560test "u64 counter" { 561 var counter = U64Counter.init("test.counter", .{ .description = "test counter" }); 562 counter.add(5, &.{}); 563 counter.add(3, &.{}); 564 try std.testing.expectEqual(@as(u64, 8), counter.get()); 565} 566 567test "i64 gauge" { 568 var gauge = I64Gauge.init("test.gauge", .{}); 569 gauge.record(42, &.{}); 570 try std.testing.expectEqual(@as(i64, 42), gauge.get()); 571 gauge.record(-10, &.{}); 572 try std.testing.expectEqual(@as(i64, -10), gauge.get()); 573} 574 575test "i64 up down counter" { 576 var counter = I64UpDownCounter.init("test.updown", .{}); 577 counter.add(5, &.{}); 578 counter.add(-3, &.{}); 579 try std.testing.expectEqual(@as(i64, 2), counter.get()); 580} 581 582test "f64 histogram" { 583 var histogram = try F64Histogram.init(std.testing.allocator, "test.histogram", .{ 584 .boundaries = &[_]f64{ 10, 50, 100 }, 585 }); 586 defer histogram.deinit(); 587 588 histogram.record(5.0, &.{}); // bucket 0 589 histogram.record(25.0, &.{}); // bucket 1 590 histogram.record(75.0, &.{}); // bucket 2 591 histogram.record(200.0, &.{}); // bucket 3 (overflow) 592 593 try std.testing.expectEqual(@as(u64, 4), histogram.getCount()); 594} 595 596test "exponential histogram" { 597 var histogram = U64ExponentialHistogram.init("test.exp_histogram", .{ .scale = 10 }); 598 histogram.record(1, &.{}); 599 histogram.record(10, &.{}); 600 histogram.record(100, &.{}); 601 602 try std.testing.expectEqual(@as(u64, 3), histogram.getCount()); 603} 604 605test "observable gauge" { 606 const callback = struct { 607 fn cb(observer: *Observer(i64)) void { 608 observer.observe(42, &.{}); 609 } 610 }.cb; 611 612 const gauge = I64ObservableGauge.init("test.observable", .{}, callback); 613 const result = gauge.observe(); 614 try std.testing.expectEqual(@as(i64, 42), result.value); 615}