logfire client for zig

fix: use thread-local storage for trace context

concurrent requests on different thread pool workers were sharing
the same trace_id because current_trace_id was process-global.

now each thread has its own trace context via threadlocal:
- tl_trace_id: current trace ID for this thread
- tl_active_span_count: span nesting depth for this thread

this ensures each HTTP request gets its own trace_id even when
handled by different workers concurrently.

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

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

+25 -15
+23 -13
src/root.zig
··· 51 var global_instance: ?*Logfire = null; 52 var global_mutex: std.Thread.Mutex = .{}; 53 54 pub const Logfire = struct { 55 allocator: std.mem.Allocator, 56 config: Config, ··· 65 /// allocated data points (freed on flush) 66 allocated_data_points: std.ArrayList(*NumberDataPoint), 67 68 - /// trace context 69 - current_trace_id: ?[16]u8, 70 - active_span_count: std.atomic.Value(u32), 71 span_id_counter: std.atomic.Value(u64), 72 73 /// background flush thread ··· 87 .pending_metrics = .{}, 88 .pending_mutex = .{}, 89 .allocated_data_points = .{}, 90 - .current_trace_id = null, 91 - .active_span_count = std.atomic.Value(u32).init(0), 92 .span_id_counter = std.atomic.Value(u64).init(1), 93 .flush_thread = null, 94 .running = std.atomic.Value(bool).init(true), ··· 170 } 171 172 pub fn createSpan(self: *Logfire, name: []const u8, attributes: anytype) Span { 173 - // generate new trace ID if no active spans (root span) 174 - if (self.active_span_count.fetchAdd(1, .acquire) == 0) { 175 - self.current_trace_id = generateTraceId(); 176 } 177 const span_id = self.span_id_counter.fetchAdd(1, .monotonic); 178 - return Span.init(self, name, span_id, attributes); 179 } 180 181 /// called when a span ends to track active span count 182 pub fn spanEnded(self: *Logfire) void { 183 - _ = self.active_span_count.fetchSub(1, .release); 184 } 185 186 - /// start a new trace (generates new trace ID) 187 pub fn newTrace(self: *Logfire) void { 188 - self.current_trace_id = generateTraceId(); 189 } 190 191 pub fn recordLog(self: *Logfire, level: Level, message: []const u8, attributes: anytype) void { 192 const record = LogRecord.init( 193 - self.current_trace_id, 194 level, 195 message, 196 attributes,
··· 51 var global_instance: ?*Logfire = null; 52 var global_mutex: std.Thread.Mutex = .{}; 53 54 + /// thread-local trace context - each thread gets its own trace 55 + threadlocal var tl_trace_id: ?[16]u8 = null; 56 + threadlocal var tl_active_span_count: u32 = 0; 57 + 58 pub const Logfire = struct { 59 allocator: std.mem.Allocator, 60 config: Config, ··· 69 /// allocated data points (freed on flush) 70 allocated_data_points: std.ArrayList(*NumberDataPoint), 71 72 + /// span ID counter (global, just needs to be unique) 73 span_id_counter: std.atomic.Value(u64), 74 75 /// background flush thread ··· 89 .pending_metrics = .{}, 90 .pending_mutex = .{}, 91 .allocated_data_points = .{}, 92 .span_id_counter = std.atomic.Value(u64).init(1), 93 .flush_thread = null, 94 .running = std.atomic.Value(bool).init(true), ··· 170 } 171 172 pub fn createSpan(self: *Logfire, name: []const u8, attributes: anytype) Span { 173 + // generate new trace ID if no active spans on this thread 174 + if (tl_active_span_count == 0) { 175 + tl_trace_id = generateTraceId(); 176 } 177 + tl_active_span_count += 1; 178 const span_id = self.span_id_counter.fetchAdd(1, .monotonic); 179 + return Span.init(self, name, span_id, tl_trace_id, attributes); 180 } 181 182 /// called when a span ends to track active span count 183 pub fn spanEnded(self: *Logfire) void { 184 + _ = self; 185 + if (tl_active_span_count > 0) { 186 + tl_active_span_count -= 1; 187 + } 188 } 189 190 + /// start a new trace (generates new trace ID for this thread) 191 pub fn newTrace(self: *Logfire) void { 192 + _ = self; 193 + tl_trace_id = generateTraceId(); 194 + } 195 + 196 + /// get the current thread's trace ID 197 + pub fn currentTraceId() ?[16]u8 { 198 + return tl_trace_id; 199 } 200 201 pub fn recordLog(self: *Logfire, level: Level, message: []const u8, attributes: anytype) void { 202 const record = LogRecord.init( 203 + tl_trace_id, 204 level, 205 message, 206 attributes,
+2 -2
src/span.zig
··· 33 }; 34 35 /// create a span (called by Logfire.createSpan) 36 - pub fn init(logfire: *root.Logfire, name: []const u8, span_id_num: u64, attrs: anytype) Span { 37 var s = Span{ 38 .logfire = logfire, 39 .data = .{ 40 .name = name, 41 - .trace_id = logfire.current_trace_id orelse [_]u8{0} ** 16, 42 .span_id = undefined, 43 .start_time_ns = std.time.nanoTimestamp(), 44 .end_time_ns = 0,
··· 33 }; 34 35 /// create a span (called by Logfire.createSpan) 36 + pub fn init(logfire: *root.Logfire, name: []const u8, span_id_num: u64, trace_id: ?[16]u8, attrs: anytype) Span { 37 var s = Span{ 38 .logfire = logfire, 39 .data = .{ 40 .name = name, 41 + .trace_id = trace_id orelse [_]u8{0} ** 16, 42 .span_id = undefined, 43 .start_time_ns = std.time.nanoTimestamp(), 44 .end_time_ns = 0,