logfire client for zig
1//! logfire-zig: otel-backed observability SDK for Logfire
2//!
3//! API modeled after Rust and Python Logfire SDKs.
4//!
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);
17
18const std = @import("std");
19const otel = @import("otel");
20const otel_api = otel.api;
21const otel_sdk = otel.sdk;
22const otel_exporters = otel.exporters;
23
24const Config = @import("config").Config;
25
26// ============================================================================
27// global state (similar to Python's DEFAULT_LOGFIRE_INSTANCE)
28// ============================================================================
29
30var global_instance: ?*Logfire = null;
31var global_allocator: ?std.mem.Allocator = null;
32
33// ============================================================================
34// thread-local span context tracking for parent-child relationships
35// ============================================================================
36
37/// thread-local current span context - enables parent-child span linking
38threadlocal var tl_current_span_context: ?otel_api.trace.Span.Context = null;
39
40// ============================================================================
41// Logfire - main SDK type
42// ============================================================================
43
44pub const Logfire = struct {
45 allocator: std.mem.Allocator,
46 config: Config,
47
48 // otel providers
49 trace_provider: ?*otel_sdk.trace.TracerProvider = null,
50 log_provider: ?*otel_sdk.logs.LoggerProvider = null,
51
52 // cached instruments
53 tracer: ?otel_api.trace.Tracer = null,
54 logger: ?otel_api.logs.Logger = null,
55
56 // heap-allocated data that must outlive providers
57 auth_header_value: ?[]u8 = null,
58 headers: ?[]std.http.Header = null,
59
60 const Self = @This();
61
62 /// configure and return a Logfire instance
63 /// also sets the global instance for convenience functions
64 pub fn init(allocator: std.mem.Allocator, config: Config) !*Self {
65 const resolved = config.resolve();
66
67 const self = try allocator.create(Self);
68 errdefer allocator.destroy(self);
69
70 self.* = .{
71 .allocator = allocator,
72 .config = resolved,
73 };
74
75 // set global state
76 global_instance = self;
77 global_allocator = allocator;
78
79 // if not sending, return no-op instance
80 if (!resolved.shouldSend()) {
81 return self;
82 }
83
84 // set up OTLP exporter with Logfire endpoint
85 if (resolved.token) |token| {
86 const endpoint = getEndpointFromToken(token);
87
88 // heap-allocate auth header (must outlive exporter)
89 self.auth_header_value = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
90 errdefer if (self.auth_header_value) |v| allocator.free(v);
91
92 self.headers = try allocator.alloc(std.http.Header, 1);
93 errdefer if (self.headers) |h| allocator.free(h);
94
95 self.headers.?[0] = .{
96 .name = "Authorization",
97 .value = self.auth_header_value.?,
98 };
99
100 const otlp_config = otel_exporters.otlp.OtlpExporterConfig{
101 .endpoint = endpoint,
102 .transport = .http_protobuf,
103 .headers = self.headers.?,
104 };
105
106 // create trace provider manually to avoid OS-specific resource detection
107 // (otel-zig's setupGlobalProvider uses detectResource which only supports macOS)
108 const provider_ptr = try allocator.create(otel_sdk.trace.TracerProvider);
109 errdefer allocator.destroy(provider_ptr);
110
111 // create minimal resource with service name
112 const resource = try createMinimalResource(allocator, resolved.service_name orelse "logfire-zig");
113 errdefer resource.deinitOwned(allocator);
114
115 provider_ptr.* = otel_sdk.trace.TracerProvider.init(
116 allocator,
117 resource,
118 otel_sdk.trace.createDefaultIdGenerator(),
119 otel_api.trace.Sampler{ .keep = {} },
120 );
121
122 // add OTLP exporter pipeline using BatchSpanProcessor for async export
123 // (BasicSpanProcessor exports synchronously on every span.end(), causing latency)
124 var builder = provider_ptr.pipelineBuilder();
125 builder = builder.with(
126 otel_sdk.trace.BatchSpanProcessor.PipelineStep.init(.{
127 .export_interval_ms = 500, // 500ms matches Python/Rust logfire
128 .max_queue_size = 2048,
129 }).flowTo(otel_exporters.otlp.OtlpTraceExporter.PipelineStep.init(otlp_config)),
130 );
131 try builder.done();
132
133 // set as global provider (use tracerProvider() to get interface wrapper)
134 try otel_api.provider_registry.setGlobalTracerProvider(provider_ptr.tracerProvider());
135 self.trace_provider = provider_ptr;
136
137 // get tracer
138 const scope = otel_api.InstrumentationScope{
139 .name = resolved.service_name orelse "logfire-zig",
140 .version = resolved.service_version orelse "0.1.0",
141 };
142 self.tracer = try otel_api.getGlobalTracerProvider().getTracerWithScope(scope);
143 }
144
145 return self;
146 }
147
148 /// shutdown and cleanup all resources
149 pub fn shutdown(self: *Self) void {
150 if (self.trace_provider) |p| {
151 _ = p.shutdown(null);
152 p.deinit();
153 p.destroy();
154 }
155 if (self.log_provider) |p| {
156 _ = p.shutdown(null);
157 p.deinit();
158 p.destroy();
159 }
160 otel_api.provider_registry.unsetAllProviders();
161
162 // free heap-allocated data
163 if (self.headers) |h| self.allocator.free(h);
164 if (self.auth_header_value) |v| self.allocator.free(v);
165
166 // clear global state
167 if (global_instance == self) {
168 global_instance = null;
169 }
170
171 self.allocator.destroy(self);
172 }
173
174 /// force flush pending telemetry
175 pub fn flush(self: *Self) !void {
176 if (self.trace_provider) |p| {
177 _ = p.forceFlush(null);
178 }
179 }
180
181 // ========================================================================
182 // spans
183 // ========================================================================
184
185 /// create a span with the given name and attributes
186 pub fn createSpan(self: *Self, name: []const u8, attrs: anytype) Span {
187 if (self.tracer) |*tracer| {
188 const otel_attrs = attrsToOtel(attrs);
189
190 // capture parent context before creating new span
191 const parent_context = tl_current_span_context;
192
193 // build context with parent span if one exists
194 var ctx_storage: [1]otel_api.ContextKeyValue = undefined;
195 const ctx: []const otel_api.ContextKeyValue = if (parent_context) |parent| blk: {
196 ctx_storage[0] = .{
197 .key = otel_api.trace.context_keys.active_span_context_key.key_id,
198 .value = otel_api.trace.context_keys.active_span_context_key.wrapValue(parent),
199 };
200 break :blk &ctx_storage;
201 } else &[_]otel_api.ContextKeyValue{};
202
203 const span_result = tracer.startSpan(name, .{
204 .kind = .internal,
205 .attributes = otel_attrs,
206 }, ctx) catch {
207 return .{ .inner = null, .parent_context = parent_context };
208 };
209
210 // update thread-local context with new span
211 tl_current_span_context = span_result.getSpanContext();
212
213 return .{ .inner = span_result, .parent_context = parent_context };
214 }
215 return .{ .inner = null, .parent_context = null };
216 }
217
218 // ========================================================================
219 // logging
220 // ========================================================================
221
222 pub fn logMessage(self: *Self, comptime level: Config.Level, comptime fmt: []const u8, args: anytype) void {
223 // console output if enabled
224 if (self.config.console) |console| {
225 if (console.enabled and @intFromEnum(level) >= @intFromEnum(console.min_level)) {
226 std.debug.print("[{s}] ", .{level.name()});
227 std.debug.print(fmt ++ "\n", args);
228 }
229 }
230
231 // TODO: emit to otel logger when log provider is set up
232 }
233};
234
235// ============================================================================
236// Span wrapper
237// ============================================================================
238
239/// span status codes (per otel semantic conventions)
240pub const StatusCode = enum {
241 /// default - status not explicitly set
242 unset,
243 /// operation completed successfully
244 ok,
245 /// operation failed
246 @"error",
247};
248
249pub const Span = struct {
250 inner: ?otel_api.trace.Span,
251 parent_context: ?otel_api.trace.Span.Context,
252
253 /// end the span - takes *const for API compatibility with legacy logfire
254 pub fn end(self: *const Span) void {
255 if (self.inner != null) {
256 // use @constCast for API compatibility (allows const span = ... pattern)
257 const mutable = @constCast(self);
258 if (mutable.inner) |*s| {
259 s.end(null);
260 s.deinit();
261 mutable.inner = null;
262
263 // restore parent context (enables proper nesting)
264 tl_current_span_context = mutable.parent_context;
265 }
266 }
267 }
268
269 pub fn setAttribute(self: *const Span, key: []const u8, value: anytype) void {
270 if (self.inner != null) {
271 const mutable = @constCast(self);
272 if (mutable.inner) |*s| {
273 if (toOtelValue(value)) |val| {
274 s.setAttribute(.{ .key = key, .value = val });
275 }
276 }
277 }
278 }
279
280 /// set span status (call with .@"error" to mark failures)
281 pub fn setStatus(self: *const Span, code: StatusCode, description: ?[]const u8) void {
282 if (self.inner != null) {
283 const mutable = @constCast(self);
284 if (mutable.inner) |*s| {
285 s.setStatus(.{
286 .code = switch (code) {
287 .unset => .unset,
288 .ok => .ok,
289 .@"error" => .@"error",
290 },
291 .description = description,
292 });
293 }
294 }
295 }
296
297 /// record an error on the span (adds event + sets error status)
298 pub fn recordError(self: *const Span, e: anyerror) void {
299 if (self.inner != null) {
300 const mutable = @constCast(self);
301 if (mutable.inner) |*s| {
302 // otel API calls this "exception" but we use zig-idiomatic naming
303 s.recordException(e, null, null) catch {};
304 s.setStatus(.{
305 .code = .@"error",
306 .description = @errorName(e),
307 });
308 }
309 }
310 }
311};
312
313// ============================================================================
314// convenience functions (use global instance)
315// ============================================================================
316
317/// configure logfire with the given options
318/// returns the configured instance
319pub fn configure(config: Config) !*Logfire {
320 const allocator = std.heap.page_allocator;
321 return Logfire.init(allocator, config);
322}
323
324/// create a span (uses global instance)
325pub fn span(name: []const u8, attrs: anytype) Span {
326 if (global_instance) |lf| {
327 return lf.createSpan(name, attrs);
328 }
329 return .{ .inner = null, .parent_context = null };
330}
331
332/// log at trace level
333pub fn trace(comptime fmt: []const u8, args: anytype) void {
334 if (global_instance) |lf| lf.logMessage(.trace, fmt, args);
335}
336
337/// log at debug level
338pub fn debug(comptime fmt: []const u8, args: anytype) void {
339 if (global_instance) |lf| lf.logMessage(.debug, fmt, args);
340}
341
342/// log at info level
343pub fn info(comptime fmt: []const u8, args: anytype) void {
344 if (global_instance) |lf| lf.logMessage(.info, fmt, args);
345}
346
347/// log at warn level
348pub fn warn(comptime fmt: []const u8, args: anytype) void {
349 if (global_instance) |lf| lf.logMessage(.warn, fmt, args);
350}
351
352/// log at error level
353pub fn err(comptime fmt: []const u8, args: anytype) void {
354 if (global_instance) |lf| lf.logMessage(.err, fmt, args);
355}
356
357// ============================================================================
358// metrics (stubs for now - TODO: implement with otel metrics)
359// ============================================================================
360
361pub fn counter(name: []const u8, value: i64) void {
362 _ = name;
363 _ = value;
364 // TODO: implement with otel-zig metrics
365}
366
367pub fn counterWithOpts(name: []const u8, value: i64, opts: anytype) void {
368 _ = name;
369 _ = value;
370 _ = opts;
371}
372
373pub fn gaugeInt(name: []const u8, value: i64) void {
374 _ = name;
375 _ = value;
376}
377
378pub fn gaugeDouble(name: []const u8, value: f64) void {
379 _ = name;
380 _ = value;
381}
382
383// ============================================================================
384// instrumentation helpers (HTTP, SQL)
385// ============================================================================
386
387/// create an HTTP span with formatted name: "HTTP {method} {path}"
388/// adds standard http.request.method and url.path attributes
389pub fn httpSpan(method: []const u8, path: []const u8, attrs: anytype) Span {
390 var name_buf: [256]u8 = undefined;
391 const span_name = std.fmt.bufPrint(&name_buf, "HTTP {s} {s}", .{ method, path }) catch "HTTP request";
392
393 // merge standard HTTP attrs with user attrs
394 const T = @TypeOf(attrs);
395 if (@typeInfo(T) == .@"struct" and @typeInfo(T).@"struct".fields.len > 0) {
396 // user provided extra attrs - for now just use standard ones
397 // TODO: merge attrs properly
398 return span(span_name, .{
399 .@"http.request.method" = method,
400 .@"url.path" = path,
401 });
402 } else {
403 return span(span_name, .{
404 .@"http.request.method" = method,
405 .@"url.path" = path,
406 });
407 }
408}
409
410/// create a SQL span with truncated query as name
411/// adds db.system attribute
412pub fn sqlSpan(sql: []const u8, db_system: []const u8) Span {
413 var name_buf: [128]u8 = undefined;
414 const span_name = truncateSql(&name_buf, sql);
415 return span(span_name, .{ .@"db.system" = db_system });
416}
417
418/// truncate SQL for display (max 60 chars, break at word boundary)
419fn truncateSql(buf: []u8, sql: []const u8) []const u8 {
420 const max_len: usize = 60;
421 if (sql.len <= max_len) {
422 return std.fmt.bufPrint(buf, "{s}", .{sql}) catch sql[0..@min(sql.len, buf.len)];
423 }
424
425 // find a good break point (space)
426 var end: usize = max_len;
427 while (end > 40 and sql[end] != ' ') : (end -= 1) {}
428 if (end <= 40) end = max_len;
429
430 return std.fmt.bufPrint(buf, "{s}...", .{sql[0..end]}) catch sql[0..@min(sql.len, buf.len)];
431}
432
433// ============================================================================
434// helpers
435// ============================================================================
436
437/// create a minimal resource without OS-specific detection
438/// this avoids otel-zig's ProcessDetector which only supports macOS
439fn createMinimalResource(allocator: std.mem.Allocator, service_name: []const u8) !otel_sdk.resource.Resource {
440 const builtin = @import("builtin");
441
442 // build attributes
443 var attrs = otel_api.common.AttributeBuilder.init(allocator);
444
445 // telemetry SDK info
446 attrs = attrs.add(.{ .key = "telemetry.sdk.name", .value = .{ .string = "logfire-zig" } });
447 attrs = attrs.add(.{ .key = "telemetry.sdk.language", .value = .{ .string = "zig" } });
448 attrs = attrs.add(.{ .key = "telemetry.sdk.version", .value = .{ .string = "0.1.0" } });
449
450 // service name
451 attrs = attrs.add(.{ .key = "service.name", .value = .{ .string = service_name } });
452
453 // process.pid (works on all POSIX systems)
454 const pid = std.c.getpid();
455 attrs = attrs.add(.{ .key = "process.pid", .value = .{ .int = @intCast(pid) } });
456
457 // process.executable.name via /proc/self/exe (Linux) or _NSGetExecutablePath (macOS)
458 switch (builtin.os.tag) {
459 .linux => {
460 // read /proc/self/exe symlink
461 var buf: [std.fs.max_path_bytes]u8 = undefined;
462 if (std.fs.readLinkAbsolute("/proc/self/exe", &buf)) |path| {
463 const basename = std.fs.path.basename(path);
464 const owned_path = try allocator.dupe(u8, path);
465 const owned_basename = try allocator.dupe(u8, basename);
466 attrs = attrs.add(.{ .key = "process.executable.path", .value = .{ .string = owned_path } });
467 attrs = attrs.add(.{ .key = "process.executable.name", .value = .{ .string = owned_basename } });
468 } else |_| {}
469 },
470 .macos => {
471 var size: u32 = std.fs.max_path_bytes;
472 var buf: [std.fs.max_path_bytes:0]u8 = undefined;
473 if (std.c._NSGetExecutablePath(&buf, &size) == 0) {
474 const path = std.mem.sliceTo(&buf, 0);
475 const basename = std.fs.path.basename(path);
476 const owned_path = try allocator.dupe(u8, path);
477 const owned_basename = try allocator.dupe(u8, basename);
478 attrs = attrs.add(.{ .key = "process.executable.path", .value = .{ .string = owned_path } });
479 attrs = attrs.add(.{ .key = "process.executable.name", .value = .{ .string = owned_basename } });
480 }
481 },
482 else => {}, // skip executable info on other OSes
483 }
484
485 return try otel_sdk.resource.Resource.initOwnedFromBuilder(allocator, null, &attrs);
486}
487
488fn getEndpointFromToken(token: []const u8) []const u8 {
489 if (std.mem.startsWith(u8, token, "pylf_v")) {
490 var it = std.mem.splitScalar(u8, token, '_');
491 _ = it.next(); // pylf
492 _ = it.next(); // v1
493 if (it.next()) |region| {
494 if (std.mem.eql(u8, region, "eu")) {
495 return "https://logfire-eu.pydantic.dev:443";
496 }
497 }
498 }
499 return "https://logfire-us.pydantic.dev:443";
500}
501
502fn toOtelValue(value: anytype) ?otel_api.common.AttributeValue {
503 const T = @TypeOf(value);
504 return switch (@typeInfo(T)) {
505 .int, .comptime_int => .{ .int = @intCast(value) },
506 .float, .comptime_float => .{ .double = @floatCast(value) },
507 .bool => .{ .bool = value },
508 .optional => {
509 // handle optional types - return null if the value is null
510 if (value) |v| {
511 return toOtelValue(v);
512 }
513 return null;
514 },
515 .pointer => |ptr| {
516 if (ptr.size == .slice and ptr.child == u8) {
517 return .{ .string = value };
518 }
519 if (ptr.size == .one) {
520 const child_info = @typeInfo(ptr.child);
521 if (child_info == .array and child_info.array.child == u8) {
522 return .{ .string = value };
523 }
524 }
525 @compileError("unsupported pointer type");
526 },
527 else => @compileError("unsupported type for attribute: " ++ @typeName(T)),
528 };
529}
530
531fn attrsToOtel(attrs: anytype) []const otel_api.common.AttributeKeyValue {
532 const T = @TypeOf(attrs);
533 if (@typeInfo(T) != .@"struct") {
534 return &[_]otel_api.common.AttributeKeyValue{};
535 }
536
537 const fields = @typeInfo(T).@"struct".fields;
538 if (fields.len == 0) {
539 return &[_]otel_api.common.AttributeKeyValue{};
540 }
541
542 // use static storage per type - populated at runtime
543 // note: not thread-safe, but attributes are copied by otel-zig immediately
544 const Storage = struct {
545 var result: [fields.len]otel_api.common.AttributeKeyValue = undefined;
546 var count: usize = 0;
547 };
548
549 Storage.count = 0;
550 inline for (fields) |field| {
551 if (toOtelValue(@field(attrs, field.name))) |val| {
552 Storage.result[Storage.count] = .{
553 .key = field.name,
554 .value = val,
555 };
556 Storage.count += 1;
557 }
558 }
559 return Storage.result[0..Storage.count];
560}