logfire client for zig

fix: cross-platform resource detection for Linux support

otel-zig's setupGlobalProvider calls detectResource which only supports
macOS (uses _NSGetExecutablePath). bypass this by:

- manually creating TracerProvider instead of setupGlobalProvider
- implementing createMinimalResource with platform-specific detection:
- linux: /proc/self/exe symlink
- macos: _NSGetExecutablePath
- others: skip executable info (pid still works)

tested in docker (debian bookworm + zig 0.15.2) - passes.

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

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

+74 -4
+74 -4
src/otel_wrapper.zig
··· 96 96 .headers = self.headers.?, 97 97 }; 98 98 99 - // set up trace provider 100 - self.trace_provider = try otel_sdk.trace.setupGlobalProvider( 99 + // create trace provider manually to avoid OS-specific resource detection 100 + // (otel-zig's setupGlobalProvider uses detectResource which only supports macOS) 101 + const provider_ptr = try allocator.create(otel_sdk.trace.TracerProvider); 102 + errdefer allocator.destroy(provider_ptr); 103 + 104 + // create minimal resource with service name 105 + const resource = try createMinimalResource(allocator, resolved.service_name orelse "logfire-zig"); 106 + errdefer resource.deinitOwned(allocator); 107 + 108 + provider_ptr.* = otel_sdk.trace.TracerProvider.init( 101 109 allocator, 102 - .{otel_sdk.trace.BasicSpanProcessor.PipelineStep.init({}) 103 - .flowTo(otel_exporters.otlp.OtlpTraceExporter.PipelineStep.init(otlp_config))}, 110 + resource, 111 + otel_sdk.trace.createDefaultIdGenerator(), 112 + otel_api.trace.Sampler{ .keep = {} }, 113 + ); 114 + 115 + // add OTLP exporter pipeline 116 + try provider_ptr.addProcessor( 117 + otel_sdk.trace.BasicSpanProcessor.PipelineStep.init({}) 118 + .flowTo(otel_exporters.otlp.OtlpTraceExporter.PipelineStep.init(otlp_config)), 104 119 ); 120 + 121 + // set as global provider 122 + otel_api.provider_registry.setGlobalTracerProvider(provider_ptr); 123 + self.trace_provider = provider_ptr; 105 124 106 125 // get tracer 107 126 const scope = otel_api.InstrumentationScope{ ··· 287 306 // ============================================================================ 288 307 // helpers 289 308 // ============================================================================ 309 + 310 + /// create a minimal resource without OS-specific detection 311 + /// this avoids otel-zig's ProcessDetector which only supports macOS 312 + fn createMinimalResource(allocator: std.mem.Allocator, service_name: []const u8) !otel_sdk.resource.Resource { 313 + const builtin = @import("builtin"); 314 + 315 + // build attributes 316 + var attrs = otel_api.common.AttributeBuilder.init(allocator); 317 + 318 + // telemetry SDK info 319 + attrs = attrs.add(.{ .key = "telemetry.sdk.name", .value = .{ .string = "logfire-zig" } }); 320 + attrs = attrs.add(.{ .key = "telemetry.sdk.language", .value = .{ .string = "zig" } }); 321 + attrs = attrs.add(.{ .key = "telemetry.sdk.version", .value = .{ .string = "0.1.0" } }); 322 + 323 + // service name 324 + attrs = attrs.add(.{ .key = "service.name", .value = .{ .string = service_name } }); 325 + 326 + // process.pid (works on all POSIX systems) 327 + const pid = std.c.getpid(); 328 + attrs = attrs.add(.{ .key = "process.pid", .value = .{ .int = @intCast(pid) } }); 329 + 330 + // process.executable.name via /proc/self/exe (Linux) or _NSGetExecutablePath (macOS) 331 + switch (builtin.os.tag) { 332 + .linux => { 333 + // read /proc/self/exe symlink 334 + var buf: [std.fs.max_path_bytes]u8 = undefined; 335 + if (std.fs.readLinkAbsolute("/proc/self/exe", &buf)) |path| { 336 + const basename = std.fs.path.basename(path); 337 + const owned_path = try allocator.dupe(u8, path); 338 + const owned_basename = try allocator.dupe(u8, basename); 339 + attrs = attrs.add(.{ .key = "process.executable.path", .value = .{ .string = owned_path } }); 340 + attrs = attrs.add(.{ .key = "process.executable.name", .value = .{ .string = owned_basename } }); 341 + } else |_| {} 342 + }, 343 + .macos => { 344 + var size: u32 = std.fs.max_path_bytes; 345 + var buf: [std.fs.max_path_bytes:0]u8 = undefined; 346 + if (std.c._NSGetExecutablePath(&buf, &size) == 0) { 347 + const path = std.mem.sliceTo(&buf, 0); 348 + const basename = std.fs.path.basename(path); 349 + const owned_path = try allocator.dupe(u8, path); 350 + const owned_basename = try allocator.dupe(u8, basename); 351 + attrs = attrs.add(.{ .key = "process.executable.path", .value = .{ .string = owned_path } }); 352 + attrs = attrs.add(.{ .key = "process.executable.name", .value = .{ .string = owned_basename } }); 353 + } 354 + }, 355 + else => {}, // skip executable info on other OSes 356 + } 357 + 358 + return try otel_sdk.resource.Resource.initOwnedFromBuilder(allocator, null, &attrs); 359 + } 290 360 291 361 fn getEndpointFromToken(token: []const u8) []const u8 { 292 362 if (std.mem.startsWith(u8, token, "pylf_v")) {