logfire client for zig

fix: implement context propagation for parent-child span relationships

adds thread-local tracking of current span context so nested spans
properly share trace_id and have parent_span_id set correctly.

- adds threadlocal tl_current_span_context for tracking active span
- createSpan now passes parent context to otel-zig's startSpan
- Span.end restores parent context for proper nesting
- also fixes setAttribute to check for null from toOtelValue

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

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

+36 -6
+36 -6
src/otel_wrapper.zig
··· 31 31 var global_allocator: ?std.mem.Allocator = null; 32 32 33 33 // ============================================================================ 34 + // thread-local span context tracking for parent-child relationships 35 + // ============================================================================ 36 + 37 + /// thread-local current span context - enables parent-child span linking 38 + threadlocal var tl_current_span_context: ?otel_api.trace.Span.Context = null; 39 + 40 + // ============================================================================ 34 41 // Logfire - main SDK type 35 42 // ============================================================================ 36 43 ··· 176 183 pub fn createSpan(self: *Self, name: []const u8, attrs: anytype) Span { 177 184 if (self.tracer) |*tracer| { 178 185 const otel_attrs = attrsToOtel(attrs); 179 - const ctx = &[_]otel_api.ContextKeyValue{}; 186 + 187 + // capture parent context before creating new span 188 + const parent_context = tl_current_span_context; 189 + 190 + // build context with parent span if one exists 191 + var ctx_storage: [1]otel_api.ContextKeyValue = undefined; 192 + const ctx: []const otel_api.ContextKeyValue = if (parent_context) |parent| blk: { 193 + ctx_storage[0] = .{ 194 + .key = otel_api.trace.context_keys.active_span_context_key.key_id, 195 + .value = otel_api.trace.context_keys.active_span_context_key.wrapValue(parent), 196 + }; 197 + break :blk &ctx_storage; 198 + } else &[_]otel_api.ContextKeyValue{}; 199 + 180 200 const span_result = tracer.startSpan(name, .{ 181 201 .kind = .internal, 182 202 .attributes = otel_attrs, 183 203 }, ctx) catch { 184 - return .{ .inner = null }; 204 + return .{ .inner = null, .parent_context = parent_context }; 185 205 }; 186 - return .{ .inner = span_result }; 206 + 207 + // update thread-local context with new span 208 + tl_current_span_context = span_result.getSpanContext(); 209 + 210 + return .{ .inner = span_result, .parent_context = parent_context }; 187 211 } 188 - return .{ .inner = null }; 212 + return .{ .inner = null, .parent_context = null }; 189 213 } 190 214 191 215 // ======================================================================== ··· 211 235 212 236 pub const Span = struct { 213 237 inner: ?otel_api.trace.Span, 238 + parent_context: ?otel_api.trace.Span.Context, 214 239 215 240 /// end the span - takes *const for API compatibility with legacy logfire 216 241 pub fn end(self: *const Span) void { ··· 221 246 s.end(null); 222 247 s.deinit(); 223 248 mutable.inner = null; 249 + 250 + // restore parent context (enables proper nesting) 251 + tl_current_span_context = mutable.parent_context; 224 252 } 225 253 } 226 254 } ··· 229 257 if (self.inner != null) { 230 258 const mutable = @constCast(self); 231 259 if (mutable.inner) |*s| { 232 - s.setAttribute(.{ .key = key, .value = toOtelValue(value) }); 260 + if (toOtelValue(value)) |val| { 261 + s.setAttribute(.{ .key = key, .value = val }); 262 + } 233 263 } 234 264 } 235 265 } ··· 251 281 if (global_instance) |lf| { 252 282 return lf.createSpan(name, attrs); 253 283 } 254 - return .{ .inner = null }; 284 + return .{ .inner = null, .parent_context = null }; 255 285 } 256 286 257 287 /// log at trace level