logfire client for zig
at main 560 lines 21 kB view raw
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}