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 276 try jw.objectField("spanId"); 277 277 try writeHexString(jw, &s.span_id); 278 278 279 + if (s.parent_span_id) |parent_id| { 280 + try jw.objectField("parentSpanId"); 281 + try writeHexString(jw, &parent_id); 282 + } 283 + 279 284 try jw.objectField("name"); 280 285 try jw.write(s.name); 281 286
+50 -3
src/root.zig
··· 53 53 54 54 /// thread-local trace context - each thread gets its own trace 55 55 threadlocal var tl_trace_id: ?[16]u8 = null; 56 + threadlocal var tl_current_span_id: ?[8]u8 = null; 56 57 threadlocal var tl_active_span_count: u32 = 0; 57 58 58 59 pub const Logfire = struct { ··· 175 176 tl_trace_id = generateTraceId(); 176 177 } 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; 178 182 const span_id = self.span_id_counter.fetchAdd(1, .monotonic); 179 - return Span.init(self, name, span_id, tl_trace_id, attributes); 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); 180 192 } 181 193 182 - /// called when a span ends to track active span count 183 - pub fn spanEnded(self: *Logfire) void { 194 + /// called when a span ends to restore parent context 195 + pub fn spanEnded(self: *Logfire, parent_span_id: ?[8]u8) void { 184 196 _ = self; 185 197 if (tl_active_span_count > 0) { 186 198 tl_active_span_count -= 1; 187 199 } 200 + // restore parent as current span 201 + tl_current_span_id = parent_span_id; 188 202 } 189 203 190 204 /// start a new trace (generates new trace ID for this thread) ··· 476 490 gaugeInt("connections.active", 42); 477 491 478 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); 479 526 } 480 527 481 528 // re-export tests from submodules
+5 -6
src/span.zig
··· 26 26 name: []const u8, 27 27 trace_id: [16]u8, 28 28 span_id: [8]u8, 29 + parent_span_id: ?[8]u8 = null, 29 30 start_time_ns: i128, 30 31 end_time_ns: i128, 31 32 attributes: [max_attributes]Attribute = undefined, ··· 33 34 }; 34 35 35 36 /// 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 + 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 { 37 38 var s = Span{ 38 39 .logfire = logfire, 39 40 .data = .{ 40 41 .name = name, 41 42 .trace_id = trace_id orelse [_]u8{0} ** 16, 42 - .span_id = undefined, 43 + .span_id = span_id, 44 + .parent_span_id = parent_span_id, 43 45 .start_time_ns = std.time.nanoTimestamp(), 44 46 .end_time_ns = 0, 45 47 }, 46 48 .active = true, 47 49 }; 48 - 49 - // encode span ID from counter 50 - std.mem.writeInt(u64, &s.data.span_id, span_id_num, .big); 51 50 52 51 // store attributes 53 52 s.data.attribute_count = Attribute.fromStruct(attrs, &s.data.attributes); ··· 77 76 var data = self.data; 78 77 data.end_time_ns = std.time.nanoTimestamp(); 79 78 lf.recordSpanEnd(data); 80 - lf.spanEnded(); 79 + lf.spanEnded(self.data.parent_span_id); 81 80 } 82 81 } 83 82 };