logfire client for zig

otel wrapper: complete API redesign for leaflet-search compatibility

- global instance pattern (like Python's DEFAULT_LOGFIRE_INSTANCE)
- convenience functions: configure(), span(), info/warn/err/debug/trace()
- const span support via @constCast for `const span = ...` pattern
- metrics stubs: counter(), gaugeInt(), gaugeDouble()
- fixed Zig 0.15 stderr API (use std.debug.print)
- fixed comptime/runtime attrs via static storage per type

API now matches leaflet-search usage patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+236 -89
+25 -22
examples/otel_basic.zig
··· 1 1 //! otel-based logfire example 2 2 //! 3 3 //! demonstrates the new otel-zig backed API. 4 + //! API matches current leaflet-search usage patterns. 4 5 //! 5 6 //! run with: 6 7 //! LOGFIRE_TOKEN=your_token zig build otel-example 7 8 //! 8 - //! or without token for no-op behavior: 9 + //! or without token for console-only output: 9 10 //! zig build otel-example 10 11 11 12 const std = @import("std"); 12 13 const logfire = @import("logfire-otel"); 13 14 14 15 pub fn main() !void { 15 - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 16 - defer _ = gpa.deinit(); 17 - const allocator = gpa.allocator(); 18 - 19 - // configure with otel-zig backend 20 - const lf = try logfire.OtelLogfire.init(allocator, .{ 16 + // configure logfire (reads LOGFIRE_TOKEN from env) 17 + // API matches: logfire.configure({...}) 18 + _ = logfire.configure(.{ 21 19 .service_name = "logfire-zig-otel-example", 22 20 .service_version = "0.1.0", 23 21 .environment = "development", 24 - }); 25 - defer lf.shutdown(); 22 + }) catch |err| { 23 + std.debug.print("logfire init failed: {}, continuing\n", .{err}); 24 + }; 26 25 27 - std.debug.print("logfire initialized (otel backend)\n", .{}); 26 + // logging - matches: logfire.info("msg", .{args}) 27 + logfire.info("application started", .{}); 28 28 29 - // create a span 29 + // span with attributes - matches: logfire.span("name", .{attrs}) 30 + // uses const span pattern for API compatibility with leaflet-search 30 31 { 31 - var s = lf.span("example.work"); 32 - defer s.end(); 33 - 34 - // add attributes 35 - s.setAttribute("iteration", @as(i64, 1)); 36 - s.setAttribute("query", "prefect python"); 32 + const span = logfire.span("example.work", .{ 33 + .iteration = @as(i64, 1), 34 + .query = "prefect python", 35 + }); 36 + defer span.end(); 37 37 38 38 // simulate work 39 39 std.posix.nanosleep(0, 50 * std.time.ns_per_ms); 40 + 41 + logfire.info("work completed", .{}); 40 42 } 41 43 42 44 // nested spans 43 45 { 44 - var outer = lf.span("example.outer"); 46 + const outer = logfire.span("example.outer", .{}); 45 47 defer outer.end(); 46 48 47 49 { 48 - var inner = lf.span("example.inner"); 50 + const inner = logfire.span("example.inner", .{}); 49 51 defer inner.end(); 50 52 51 53 std.posix.nanosleep(0, 25 * std.time.ns_per_ms); 52 54 } 53 55 } 54 56 55 - // flush to ensure export 56 - try lf.flush(); 57 + // metrics (stubs for now) 58 + logfire.counter("requests.total", 1); 59 + logfire.gaugeInt("connections.active", 42); 57 60 58 - std.debug.print("example complete - check logfire dashboard\n", .{}); 61 + logfire.info("example complete", .{}); 59 62 }
+211 -67
src/otel_wrapper.zig
··· 1 - //! otel-zig wrapper for logfire 1 + //! logfire-zig: otel-backed observability SDK for Logfire 2 + //! 3 + //! API modeled after Rust and Python Logfire SDKs. 2 4 //! 3 - //! provides a simple, logfire-specific wrapper around otel-zig. 4 - //! this is phase 3 of the otel-zig adoption plan. 5 + //! usage: 6 + //! const lf = try logfire.configure(.{ 7 + //! .service_name = "my-service", 8 + //! }); 9 + //! defer lf.shutdown(); 10 + //! 11 + //! logfire.info("server started on port {d}", .{port}); 12 + //! 13 + //! const span = logfire.span("http.request", .{ .method = "GET" }); 14 + //! defer span.end(); 15 + //! 16 + //! logfire.counter("requests.total", 1); 5 17 6 18 const std = @import("std"); 7 19 const otel = @import("otel"); ··· 9 21 const otel_sdk = otel.sdk; 10 22 const otel_exporters = otel.exporters; 11 23 12 - const Config = @import("config.zig").Config; 24 + const Config = @import("config").Config; 25 + 26 + // ============================================================================ 27 + // global state (similar to Python's DEFAULT_LOGFIRE_INSTANCE) 28 + // ============================================================================ 29 + 30 + var global_instance: ?*Logfire = null; 31 + var global_allocator: ?std.mem.Allocator = null; 32 + 33 + // ============================================================================ 34 + // Logfire - main SDK type 35 + // ============================================================================ 13 36 14 - /// logfire wrapper around otel-zig providers 15 - pub const OtelLogfire = struct { 37 + pub const Logfire = struct { 16 38 allocator: std.mem.Allocator, 17 39 config: Config, 18 40 19 - // providers (owned) 41 + // otel providers 20 42 trace_provider: ?*otel_sdk.trace.TracerProvider = null, 21 43 log_provider: ?*otel_sdk.logs.LoggerProvider = null, 22 - // metric_provider: ?*otel_sdk.metrics.MeterProvider = null, 23 44 24 45 // cached instruments 25 46 tracer: ?otel_api.trace.Tracer = null, 26 47 logger: ?otel_api.logs.Logger = null, 27 48 28 - // heap-allocated auth header (must outlive exporter) 49 + // heap-allocated data that must outlive providers 29 50 auth_header_value: ?[]u8 = null, 30 51 headers: ?[]std.http.Header = null, 31 52 32 53 const Self = @This(); 33 54 55 + /// configure and return a Logfire instance 56 + /// also sets the global instance for convenience functions 34 57 pub fn init(allocator: std.mem.Allocator, config: Config) !*Self { 35 58 const resolved = config.resolve(); 36 59 37 - if (!resolved.shouldSend()) { 38 - // return no-op instance 39 - const self = try allocator.create(Self); 40 - self.* = .{ 41 - .allocator = allocator, 42 - .config = resolved, 43 - }; 44 - return self; 45 - } 46 - 47 60 const self = try allocator.create(Self); 48 61 errdefer allocator.destroy(self); 49 62 ··· 52 65 .config = resolved, 53 66 }; 54 67 55 - // set up trace provider with OTLP exporter 68 + // set global state 69 + global_instance = self; 70 + global_allocator = allocator; 71 + 72 + // if not sending, return no-op instance 73 + if (!resolved.shouldSend()) { 74 + return self; 75 + } 76 + 77 + // set up OTLP exporter with Logfire endpoint 56 78 if (resolved.token) |token| { 57 79 const endpoint = getEndpointFromToken(token); 58 80 59 - // heap-allocate auth header value (must outlive exporter) 81 + // heap-allocate auth header (must outlive exporter) 60 82 self.auth_header_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); 61 83 errdefer if (self.auth_header_value) |v| allocator.free(v); 62 84 63 - // heap-allocate headers array 64 85 self.headers = try allocator.alloc(std.http.Header, 1); 65 86 errdefer if (self.headers) |h| allocator.free(h); 66 87 ··· 93 114 return self; 94 115 } 95 116 96 - pub fn deinit(self: *Self) void { 117 + /// shutdown and cleanup all resources 118 + pub fn shutdown(self: *Self) void { 97 119 if (self.trace_provider) |p| { 120 + _ = p.shutdown(null); 98 121 p.deinit(); 99 122 p.destroy(); 100 123 } 101 124 if (self.log_provider) |p| { 125 + _ = p.shutdown(null); 102 126 p.deinit(); 103 127 p.destroy(); 104 128 } 105 129 otel_api.provider_registry.unsetAllProviders(); 106 130 107 - // free heap-allocated header data 131 + // free heap-allocated data 108 132 if (self.headers) |h| self.allocator.free(h); 109 133 if (self.auth_header_value) |v| self.allocator.free(v); 110 134 135 + // clear global state 136 + if (global_instance == self) { 137 + global_instance = null; 138 + } 139 + 111 140 self.allocator.destroy(self); 112 141 } 142 + 143 + /// force flush pending telemetry 144 + pub fn flush(self: *Self) !void { 145 + if (self.trace_provider) |p| { 146 + _ = p.forceFlush(null); 147 + } 148 + } 149 + 150 + // ======================================================================== 151 + // spans 152 + // ======================================================================== 113 153 114 154 /// create a span with the given name and attributes 115 - pub fn span(self: *Self, name: []const u8) Span { 155 + pub fn createSpan(self: *Self, name: []const u8, attrs: anytype) Span { 116 156 if (self.tracer) |*tracer| { 157 + const otel_attrs = attrsToOtel(attrs); 117 158 const ctx = &[_]otel_api.ContextKeyValue{}; 118 159 const span_result = tracer.startSpan(name, .{ 119 160 .kind = .internal, 161 + .attributes = otel_attrs, 120 162 }, ctx) catch { 121 163 return .{ .inner = null }; 122 164 }; ··· 125 167 return .{ .inner = null }; 126 168 } 127 169 128 - /// flush any pending data 129 - pub fn flush(self: *Self) !void { 130 - if (self.trace_provider) |p| { 131 - _ = p.forceFlush(null); 170 + // ======================================================================== 171 + // logging 172 + // ======================================================================== 173 + 174 + pub fn logMessage(self: *Self, comptime level: Config.Level, comptime fmt: []const u8, args: anytype) void { 175 + // console output if enabled 176 + if (self.config.console) |console| { 177 + if (console.enabled and @intFromEnum(level) >= @intFromEnum(console.min_level)) { 178 + std.debug.print("[{s}] ", .{level.name()}); 179 + std.debug.print(fmt ++ "\n", args); 180 + } 132 181 } 133 - } 134 182 135 - /// shutdown the provider 136 - pub fn shutdown(self: *Self) void { 137 - if (self.trace_provider) |p| { 138 - _ = p.shutdown(null); 139 - } 140 - self.deinit(); 183 + // TODO: emit to otel logger when log provider is set up 141 184 } 142 185 }; 143 186 144 - /// span wrapper for ergonomic usage 187 + // ============================================================================ 188 + // Span wrapper 189 + // ============================================================================ 190 + 145 191 pub const Span = struct { 146 192 inner: ?otel_api.trace.Span, 147 193 148 - pub fn end(self: *Span) void { 149 - if (self.inner) |*s| { 150 - s.end(null); 151 - s.deinit(); 152 - self.inner = null; 194 + /// end the span - takes *const for API compatibility with legacy logfire 195 + pub fn end(self: *const Span) void { 196 + if (self.inner != null) { 197 + // use @constCast for API compatibility (allows const span = ... pattern) 198 + const mutable = @constCast(self); 199 + if (mutable.inner) |*s| { 200 + s.end(null); 201 + s.deinit(); 202 + mutable.inner = null; 203 + } 153 204 } 154 205 } 155 206 156 - pub fn setAttribute(self: *Span, key: []const u8, value: anytype) void { 157 - if (self.inner) |*s| { 158 - const attr_value = toAttributeValue(value); 159 - s.setAttribute(.{ .key = key, .value = attr_value }); 207 + pub fn setAttribute(self: *const Span, key: []const u8, value: anytype) void { 208 + if (self.inner != null) { 209 + const mutable = @constCast(self); 210 + if (mutable.inner) |*s| { 211 + s.setAttribute(.{ .key = key, .value = toOtelValue(value) }); 212 + } 160 213 } 161 214 } 162 215 }; 163 216 164 - fn toAttributeValue(value: anytype) otel_api.common.AttributeValue { 217 + // ============================================================================ 218 + // convenience functions (use global instance) 219 + // ============================================================================ 220 + 221 + /// configure logfire with the given options 222 + /// returns the configured instance 223 + pub fn configure(config: Config) !*Logfire { 224 + const allocator = std.heap.page_allocator; 225 + return Logfire.init(allocator, config); 226 + } 227 + 228 + /// create a span (uses global instance) 229 + pub fn span(name: []const u8, attrs: anytype) Span { 230 + if (global_instance) |lf| { 231 + return lf.createSpan(name, attrs); 232 + } 233 + return .{ .inner = null }; 234 + } 235 + 236 + /// log at trace level 237 + pub fn trace(comptime fmt: []const u8, args: anytype) void { 238 + if (global_instance) |lf| lf.logMessage(.trace, fmt, args); 239 + } 240 + 241 + /// log at debug level 242 + pub fn debug(comptime fmt: []const u8, args: anytype) void { 243 + if (global_instance) |lf| lf.logMessage(.debug, fmt, args); 244 + } 245 + 246 + /// log at info level 247 + pub fn info(comptime fmt: []const u8, args: anytype) void { 248 + if (global_instance) |lf| lf.logMessage(.info, fmt, args); 249 + } 250 + 251 + /// log at warn level 252 + pub fn warn(comptime fmt: []const u8, args: anytype) void { 253 + if (global_instance) |lf| lf.logMessage(.warn, fmt, args); 254 + } 255 + 256 + /// log at error level 257 + pub fn err(comptime fmt: []const u8, args: anytype) void { 258 + if (global_instance) |lf| lf.logMessage(.err, fmt, args); 259 + } 260 + 261 + // ============================================================================ 262 + // metrics (stubs for now - TODO: implement with otel metrics) 263 + // ============================================================================ 264 + 265 + pub fn counter(name: []const u8, value: i64) void { 266 + _ = name; 267 + _ = value; 268 + // TODO: implement with otel-zig metrics 269 + } 270 + 271 + pub fn counterWithOpts(name: []const u8, value: i64, opts: anytype) void { 272 + _ = name; 273 + _ = value; 274 + _ = opts; 275 + } 276 + 277 + pub fn gaugeInt(name: []const u8, value: i64) void { 278 + _ = name; 279 + _ = value; 280 + } 281 + 282 + pub fn gaugeDouble(name: []const u8, value: f64) void { 283 + _ = name; 284 + _ = value; 285 + } 286 + 287 + // ============================================================================ 288 + // helpers 289 + // ============================================================================ 290 + 291 + fn getEndpointFromToken(token: []const u8) []const u8 { 292 + if (std.mem.startsWith(u8, token, "pylf_v")) { 293 + var it = std.mem.splitScalar(u8, token, '_'); 294 + _ = it.next(); // pylf 295 + _ = it.next(); // v1 296 + if (it.next()) |region| { 297 + if (std.mem.eql(u8, region, "eu")) { 298 + return "https://logfire-eu.pydantic.dev:443"; 299 + } 300 + } 301 + } 302 + return "https://logfire-us.pydantic.dev:443"; 303 + } 304 + 305 + fn toOtelValue(value: anytype) otel_api.common.AttributeValue { 165 306 const T = @TypeOf(value); 166 307 return switch (@typeInfo(T)) { 167 308 .int, .comptime_int => .{ .int = @intCast(value) }, 168 309 .float, .comptime_float => .{ .double = @floatCast(value) }, 169 310 .bool => .{ .bool = value }, 170 311 .pointer => |ptr| { 171 - // handle []const u8 (slices) 172 312 if (ptr.size == .slice and ptr.child == u8) { 173 313 return .{ .string = value }; 174 314 } 175 - // handle *const [N]u8 and *const [N:0]u8 (string literals) 176 315 if (ptr.size == .one) { 177 316 const child_info = @typeInfo(ptr.child); 178 317 if (child_info == .array and child_info.array.child == u8) { 179 318 return .{ .string = value }; 180 319 } 181 320 } 182 - @compileError("unsupported pointer type for attribute"); 321 + @compileError("unsupported pointer type"); 183 322 }, 184 323 else => @compileError("unsupported type for attribute: " ++ @typeName(T)), 185 324 }; 186 325 } 187 326 188 - /// extract region from logfire token and return base OTLP endpoint 189 - fn getEndpointFromToken(token: []const u8) []const u8 { 190 - if (std.mem.startsWith(u8, token, "pylf_v")) { 191 - var it = std.mem.splitScalar(u8, token, '_'); 192 - _ = it.next(); // pylf 193 - _ = it.next(); // v1 194 - if (it.next()) |region| { 195 - if (std.mem.eql(u8, region, "eu")) { 196 - return "https://logfire-eu.pydantic.dev:443"; 197 - } 198 - } 327 + fn attrsToOtel(attrs: anytype) []const otel_api.common.AttributeKeyValue { 328 + const T = @TypeOf(attrs); 329 + if (@typeInfo(T) != .@"struct") { 330 + return &[_]otel_api.common.AttributeKeyValue{}; 331 + } 332 + 333 + const fields = @typeInfo(T).@"struct".fields; 334 + if (fields.len == 0) { 335 + return &[_]otel_api.common.AttributeKeyValue{}; 199 336 } 200 - return "https://logfire-us.pydantic.dev:443"; 201 - } 202 337 203 - // convenience function to create and configure 204 - pub fn configure(config: Config) !*OtelLogfire { 205 - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 206 - return OtelLogfire.init(gpa.allocator(), config); 338 + // use static storage per type - populated at runtime 339 + // note: not thread-safe, but attributes are copied by otel-zig immediately 340 + const Storage = struct { 341 + var result: [fields.len]otel_api.common.AttributeKeyValue = undefined; 342 + }; 343 + 344 + inline for (fields, 0..) |field, i| { 345 + Storage.result[i] = .{ 346 + .key = field.name, 347 + .value = toOtelValue(@field(attrs, field.name)), 348 + }; 349 + } 350 + return &Storage.result; 207 351 }