logfire client for zig

fix: add parent-child span linking

track current span ID on thread so child spans can reference their
parent. when creating a span while another is active:
- capture current span as parent_span_id
- update tl_current_span_id to new span
- on span end, restore parent as current

adds parentSpanId to OTLP JSON output when present.

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

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

+60 -9
+5
src/exporter.zig
··· 276 try jw.objectField("spanId"); 277 try writeHexString(jw, &s.span_id); 278 279 try jw.objectField("name"); 280 try jw.write(s.name); 281
··· 276 try jw.objectField("spanId"); 277 try writeHexString(jw, &s.span_id); 278 279 + if (s.parent_span_id) |parent_id| { 280 + try jw.objectField("parentSpanId"); 281 + try writeHexString(jw, &parent_id); 282 + } 283 + 284 try jw.objectField("name"); 285 try jw.write(s.name); 286
+50 -3
src/root.zig
··· 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 { ··· 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) ··· 476 gaugeInt("connections.active", 42); 477 478 try std.testing.expectEqual(@as(usize, 2), lf.pending_metrics.items.len); 479 } 480 481 // re-export tests from submodules
··· 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_current_span_id: ?[8]u8 = null; 57 threadlocal var tl_active_span_count: u32 = 0; 58 59 pub const Logfire = struct { ··· 176 tl_trace_id = generateTraceId(); 177 } 178 tl_active_span_count += 1; 179 + 180 + // capture parent span ID before creating new span 181 + const parent_span_id = tl_current_span_id; 182 const span_id = self.span_id_counter.fetchAdd(1, .monotonic); 183 + 184 + // encode span ID 185 + var span_id_bytes: [8]u8 = undefined; 186 + std.mem.writeInt(u64, &span_id_bytes, span_id, .big); 187 + 188 + // update current span ID for this thread 189 + tl_current_span_id = span_id_bytes; 190 + 191 + return Span.init(self, name, span_id_bytes, tl_trace_id, parent_span_id, attributes); 192 } 193 194 + /// called when a span ends to restore parent context 195 + pub fn spanEnded(self: *Logfire, parent_span_id: ?[8]u8) void { 196 _ = self; 197 if (tl_active_span_count > 0) { 198 tl_active_span_count -= 1; 199 } 200 + // restore parent as current span 201 + tl_current_span_id = parent_span_id; 202 } 203 204 /// start a new trace (generates new trace ID for this thread) ··· 490 gaugeInt("connections.active", 42); 491 492 try std.testing.expectEqual(@as(usize, 2), lf.pending_metrics.items.len); 493 + } 494 + 495 + test "parent-child span linking" { 496 + const lf = try configure(.{ 497 + .service_name = "test-service", 498 + .send_to_logfire = .no, 499 + }); 500 + defer lf.shutdown(); 501 + 502 + // create parent span 503 + const parent = span("parent.operation", .{}); 504 + const parent_span_id = parent.data.span_id; 505 + const parent_trace_id = parent.data.trace_id; 506 + 507 + // parent should have no parent 508 + try std.testing.expect(parent.data.parent_span_id == null); 509 + 510 + // create child span while parent is active 511 + const child = span("child.operation", .{}); 512 + 513 + // child should have parent's span_id as parent_span_id 514 + try std.testing.expect(child.data.parent_span_id != null); 515 + try std.testing.expectEqualSlices(u8, &parent_span_id, &child.data.parent_span_id.?); 516 + 517 + // child should share parent's trace_id 518 + try std.testing.expectEqualSlices(u8, &parent_trace_id, &child.data.trace_id); 519 + 520 + // end spans (child first due to defer order) 521 + child.end(); 522 + parent.end(); 523 + 524 + // should have recorded 2 spans 525 + try std.testing.expectEqual(@as(usize, 2), lf.pending_spans.items.len); 526 } 527 528 // re-export tests from submodules
+5 -6
src/span.zig
··· 26 name: []const u8, 27 trace_id: [16]u8, 28 span_id: [8]u8, 29 start_time_ns: i128, 30 end_time_ns: i128, 31 attributes: [max_attributes]Attribute = undefined, ··· 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, 45 }, 46 .active = true, 47 }; 48 - 49 - // encode span ID from counter 50 - std.mem.writeInt(u64, &s.data.span_id, span_id_num, .big); 51 52 // store attributes 53 s.data.attribute_count = Attribute.fromStruct(attrs, &s.data.attributes); ··· 77 var data = self.data; 78 data.end_time_ns = std.time.nanoTimestamp(); 79 lf.recordSpanEnd(data); 80 - lf.spanEnded(); 81 } 82 } 83 };
··· 26 name: []const u8, 27 trace_id: [16]u8, 28 span_id: [8]u8, 29 + parent_span_id: ?[8]u8 = null, 30 start_time_ns: i128, 31 end_time_ns: i128, 32 attributes: [max_attributes]Attribute = undefined, ··· 34 }; 35 36 /// create a span (called by Logfire.createSpan) 37 + pub fn init(logfire: *root.Logfire, name: []const u8, span_id: [8]u8, trace_id: ?[16]u8, parent_span_id: ?[8]u8, attrs: anytype) Span { 38 var s = Span{ 39 .logfire = logfire, 40 .data = .{ 41 .name = name, 42 .trace_id = trace_id orelse [_]u8{0} ** 16, 43 + .span_id = span_id, 44 + .parent_span_id = parent_span_id, 45 .start_time_ns = std.time.nanoTimestamp(), 46 .end_time_ns = 0, 47 }, 48 .active = true, 49 }; 50 51 // store attributes 52 s.data.attribute_count = Attribute.fromStruct(attrs, &s.data.attributes); ··· 76 var data = self.data; 77 data.end_time_ns = std.time.nanoTimestamp(); 78 lf.recordSpanEnd(data); 79 + lf.spanEnded(self.data.parent_span_id); 80 } 81 } 82 };