logfire client for zig
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}