logfire client for zig

initial implementation: spans, logs, OTLP export

working:
- config with env var resolution (LOGFIRE_WRITE_TOKEN, LOGFIRE_TOKEN)
- OTLP HTTP/JSON export to /v1/traces and /v1/logs
- span tracking with timing
- structured logging with severity levels
- console fallback when no token

pending:
- attribute storage for spans/logs
- metrics (counter, gauge, histogram)

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

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

+991
+3
.gitignore
··· 1 + .env 2 + .zig-cache/ 3 + zig-out/
+35
build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + // library module 8 + const mod = b.addModule("logfire", .{ 9 + .root_source_file = b.path("src/root.zig"), 10 + .target = target, 11 + .optimize = optimize, 12 + }); 13 + 14 + // tests 15 + const tests = b.addTest(.{ .root_module = mod }); 16 + const run_tests = b.addRunArtifact(tests); 17 + 18 + const test_step = b.step("test", "run unit tests"); 19 + test_step.dependOn(&run_tests.step); 20 + 21 + // example executable 22 + const example = b.addExecutable(.{ 23 + .name = "example", 24 + .root_module = b.createModule(.{ 25 + .root_source_file = b.path("examples/basic.zig"), 26 + .target = target, 27 + .optimize = optimize, 28 + .imports = &.{.{ .name = "logfire", .module = mod }}, 29 + }), 30 + }); 31 + 32 + const run_example = b.addRunArtifact(example); 33 + const example_step = b.step("example", "run the basic example"); 34 + example_step.dependOn(&run_example.step); 35 + }
+11
build.zig.zon
··· 1 + .{ 2 + .name = .logfire_zig, 3 + .version = "0.1.0", 4 + .fingerprint = 0x9bd424192e836cc7, 5 + .minimum_zig_version = "0.15.0", 6 + .paths = .{ 7 + "build.zig", 8 + "build.zig.zon", 9 + "src", 10 + }, 11 + }
+60
examples/basic.zig
··· 1 + //! basic logfire example 2 + //! 3 + //! demonstrates spans, logging, and export to logfire. 4 + //! 5 + //! run with: 6 + //! LOGFIRE_TOKEN=your_token zig build example 7 + //! 8 + //! or without token for console-only output: 9 + //! zig build example 10 + 11 + const std = @import("std"); 12 + const logfire = @import("logfire"); 13 + 14 + pub fn main() !void { 15 + // configure logfire 16 + // will send to logfire if LOGFIRE_TOKEN is set, otherwise just prints 17 + const lf = try logfire.configure(.{ 18 + .service_name = "logfire-zig-example", 19 + .service_version = "0.1.0", 20 + .environment = "development", 21 + }); 22 + defer lf.shutdown(); 23 + 24 + // simple logging 25 + logfire.info("application started", .{}); 26 + logfire.debug("debug message with value: {d}", .{42}); 27 + 28 + // span with timing 29 + { 30 + const s = logfire.span("example.work", .{ 31 + .iteration = @as(i64, 1), 32 + }); 33 + defer s.end(); 34 + 35 + // simulate some work 36 + std.posix.nanosleep(0, 10 * std.time.ns_per_ms); 37 + 38 + logfire.info("work completed", .{}); 39 + } 40 + 41 + // nested spans 42 + { 43 + const outer = logfire.span("example.outer", .{}); 44 + defer outer.end(); 45 + 46 + { 47 + const inner = logfire.span("example.inner", .{ 48 + .depth = @as(i64, 1), 49 + }); 50 + defer inner.end(); 51 + 52 + std.posix.nanosleep(0, 5 * std.time.ns_per_ms); 53 + } 54 + } 55 + 56 + // flush to ensure all data is sent 57 + try lf.flush(); 58 + 59 + logfire.info("example complete", .{}); 60 + }
+170
src/config.zig
··· 1 + //! logfire configuration 2 + //! 3 + //! mirrors the rust client's builder pattern. 4 + //! supports both explicit config and environment variables. 5 + 6 + const std = @import("std"); 7 + 8 + pub const Config = struct { 9 + /// service name (required for logfire) 10 + /// env: LOGFIRE_SERVICE_NAME, OTEL_SERVICE_NAME 11 + service_name: ?[]const u8 = null, 12 + 13 + /// service version 14 + /// env: LOGFIRE_SERVICE_VERSION, OTEL_SERVICE_VERSION 15 + service_version: ?[]const u8 = null, 16 + 17 + /// deployment environment (e.g., "production", "staging") 18 + /// env: LOGFIRE_ENVIRONMENT 19 + environment: ?[]const u8 = null, 20 + 21 + /// logfire/OTLP write token 22 + /// env: LOGFIRE_TOKEN 23 + token: ?[]const u8 = null, 24 + 25 + /// base URL for logfire API 26 + /// env: OTEL_EXPORTER_OTLP_ENDPOINT 27 + /// defaults based on token region (us/eu) 28 + base_url: ?[]const u8 = null, 29 + 30 + /// whether to send to logfire 31 + /// if false, just prints to console 32 + send_to_logfire: SendToLogfire = .if_token_present, 33 + 34 + /// minimum log level to capture 35 + default_level: Level = .info, 36 + 37 + /// console output options 38 + console: ?ConsoleOptions = .{}, 39 + 40 + /// batch export settings 41 + batch_size: usize = 512, 42 + batch_timeout_ms: u64 = 500, 43 + 44 + pub const SendToLogfire = enum { 45 + yes, 46 + no, 47 + if_token_present, 48 + }; 49 + 50 + pub const Level = enum { 51 + trace, 52 + debug, 53 + info, 54 + warn, 55 + err, 56 + 57 + pub fn severity(self: Level) u8 { 58 + return switch (self) { 59 + .trace => 1, 60 + .debug => 5, 61 + .info => 9, 62 + .warn => 13, 63 + .err => 17, 64 + }; 65 + } 66 + 67 + pub fn name(self: Level) []const u8 { 68 + return @tagName(self); 69 + } 70 + }; 71 + 72 + pub const ConsoleOptions = struct { 73 + enabled: bool = true, 74 + colors: bool = true, 75 + min_level: Level = .info, 76 + }; 77 + 78 + /// resolve config from explicit values + environment 79 + pub fn resolve(self: Config) Config { 80 + var resolved = self; 81 + 82 + // token 83 + if (resolved.token == null) { 84 + resolved.token = std.posix.getenv("LOGFIRE_WRITE_TOKEN") orelse 85 + std.posix.getenv("LOGFIRE_TOKEN"); 86 + } 87 + 88 + // service name 89 + if (resolved.service_name == null) { 90 + resolved.service_name = std.posix.getenv("LOGFIRE_SERVICE_NAME") orelse 91 + std.posix.getenv("OTEL_SERVICE_NAME"); 92 + } 93 + 94 + // service version 95 + if (resolved.service_version == null) { 96 + resolved.service_version = std.posix.getenv("LOGFIRE_SERVICE_VERSION") orelse 97 + std.posix.getenv("OTEL_SERVICE_VERSION"); 98 + } 99 + 100 + // environment 101 + if (resolved.environment == null) { 102 + resolved.environment = std.posix.getenv("LOGFIRE_ENVIRONMENT"); 103 + } 104 + 105 + // base URL - derive from token region if not explicit 106 + if (resolved.base_url == null) { 107 + if (std.posix.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) |endpoint| { 108 + resolved.base_url = endpoint; 109 + } else if (resolved.token) |token| { 110 + resolved.base_url = getBaseUrlFromToken(token); 111 + } 112 + } 113 + 114 + return resolved; 115 + } 116 + 117 + /// determine if we should actually send data 118 + pub fn shouldSend(self: Config) bool { 119 + return switch (self.send_to_logfire) { 120 + .yes => true, 121 + .no => false, 122 + .if_token_present => self.token != null, 123 + }; 124 + } 125 + }; 126 + 127 + /// extract region from logfire token and return appropriate endpoint 128 + /// token format: pylf_v{version}_{region}_{token} 129 + fn getBaseUrlFromToken(token: []const u8) []const u8 { 130 + // pylf_v1_eu_xxxxx -> eu 131 + // pylf_v1_us_xxxxx -> us 132 + if (std.mem.startsWith(u8, token, "pylf_v")) { 133 + var it = std.mem.splitScalar(u8, token, '_'); 134 + _ = it.next(); // pylf 135 + _ = it.next(); // v1 136 + if (it.next()) |region| { 137 + if (std.mem.eql(u8, region, "eu")) { 138 + return "https://logfire-eu.pydantic.dev"; 139 + } 140 + } 141 + } 142 + // default to US 143 + return "https://logfire-us.pydantic.dev"; 144 + } 145 + 146 + // tests 147 + 148 + test "resolve from environment" { 149 + // can't easily test env vars, but test the default resolution 150 + const config = Config{ 151 + .service_name = "test", 152 + }; 153 + const resolved = config.resolve(); 154 + try std.testing.expectEqualStrings("test", resolved.service_name.?); 155 + } 156 + 157 + test "getBaseUrlFromToken us" { 158 + const url = getBaseUrlFromToken("pylf_v1_us_abc123"); 159 + try std.testing.expectEqualStrings("https://logfire-us.pydantic.dev", url); 160 + } 161 + 162 + test "getBaseUrlFromToken eu" { 163 + const url = getBaseUrlFromToken("pylf_v1_eu_abc123"); 164 + try std.testing.expectEqualStrings("https://logfire-eu.pydantic.dev", url); 165 + } 166 + 167 + test "getBaseUrlFromToken unknown defaults to us" { 168 + const url = getBaseUrlFromToken("some_other_token"); 169 + try std.testing.expectEqualStrings("https://logfire-us.pydantic.dev", url); 170 + }
+345
src/exporter.zig
··· 1 + //! OTLP HTTP/JSON exporter 2 + //! 3 + //! sends spans and logs to logfire (or any OTLP-compatible backend) 4 + //! uses HTTP with JSON encoding for simplicity. 5 + //! 6 + //! endpoints: 7 + //! - /v1/traces - span data 8 + //! - /v1/logs - log records 9 + //! 10 + //! see: https://opentelemetry.io/docs/specs/otlp/ 11 + 12 + const std = @import("std"); 13 + const Config = @import("config.zig").Config; 14 + const Span = @import("span.zig").Span; 15 + const LogRecord = @import("log.zig").LogRecord; 16 + 17 + pub const Exporter = struct { 18 + allocator: std.mem.Allocator, 19 + config: Config, 20 + http_client: std.http.Client, 21 + 22 + pub fn init(allocator: std.mem.Allocator, config: Config) Exporter { 23 + return .{ 24 + .allocator = allocator, 25 + .config = config, 26 + .http_client = .{ .allocator = allocator }, 27 + }; 28 + } 29 + 30 + pub fn deinit(self: *Exporter) void { 31 + self.http_client.deinit(); 32 + } 33 + 34 + /// send spans and logs to OTLP endpoints 35 + pub fn send( 36 + self: *Exporter, 37 + spans: []const Span.Data, 38 + logs: []const LogRecord, 39 + ) !void { 40 + if (!self.config.shouldSend()) { 41 + // just log to console if not sending 42 + self.printToConsole(spans, logs); 43 + return; 44 + } 45 + 46 + const base_url = self.config.base_url orelse return error.NoBaseUrl; 47 + 48 + // send spans 49 + if (spans.len > 0) { 50 + const traces_json = try self.buildTracesJson(spans); 51 + defer self.allocator.free(traces_json); 52 + 53 + try self.sendToEndpoint(base_url, "/v1/traces", traces_json); 54 + } 55 + 56 + // send logs 57 + if (logs.len > 0) { 58 + const logs_json = try self.buildLogsJson(logs); 59 + defer self.allocator.free(logs_json); 60 + 61 + try self.sendToEndpoint(base_url, "/v1/logs", logs_json); 62 + } 63 + } 64 + 65 + fn sendToEndpoint(self: *Exporter, base_url: []const u8, path: []const u8, body: []const u8) !void { 66 + // build full URL 67 + var url_buf: [1024]u8 = undefined; 68 + const url = std.fmt.bufPrint(&url_buf, "{s}{s}", .{ base_url, path }) catch return error.UrlTooLong; 69 + 70 + // response writer 71 + var aw: std.Io.Writer.Allocating = .init(self.allocator); 72 + defer aw.deinit(); 73 + 74 + // build auth header on stack 75 + var auth_buf: [2048]u8 = undefined; 76 + var auth_header: ?[]const u8 = null; 77 + if (self.config.token) |token| { 78 + auth_header = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{token}) catch null; 79 + } 80 + 81 + // headers 82 + var headers: std.http.Client.Request.Headers = .{ 83 + .content_type = .{ .override = "application/json" }, 84 + // disable gzip: zig stdlib flate.Decompress can panic 85 + .accept_encoding = .{ .override = "identity" }, 86 + .user_agent = .{ .override = "logfire-zig/0.1.0" }, 87 + }; 88 + if (auth_header) |auth| { 89 + headers.authorization = .{ .override = auth }; 90 + } 91 + 92 + const result = self.http_client.fetch(.{ 93 + .location = .{ .url = url }, 94 + .response_writer = &aw.writer, 95 + .method = .POST, 96 + .payload = body, 97 + .headers = headers, 98 + }) catch return error.RequestFailed; 99 + 100 + if (result.status != .ok and result.status != .accepted) { 101 + std.log.warn("logfire: export failed: {s}", .{aw.toArrayList().items}); 102 + return error.SendFailed; 103 + } 104 + } 105 + 106 + /// build OTLP JSON for traces 107 + /// see: https://opentelemetry.io/docs/specs/otlp/#otlphttp-request 108 + fn buildTracesJson(self: *Exporter, spans: []const Span.Data) ![]u8 { 109 + var json: std.ArrayList(u8) = .{}; 110 + errdefer json.deinit(self.allocator); 111 + 112 + var w = json.writer(self.allocator); 113 + 114 + try w.writeAll("{\"resourceSpans\":[{"); 115 + 116 + // resource attributes 117 + try w.writeAll("\"resource\":{\"attributes\":["); 118 + try self.writeResourceAttributes(&w); 119 + try w.writeAll("]},"); 120 + 121 + // scope spans 122 + try w.writeAll("\"scopeSpans\":[{"); 123 + try w.writeAll("\"scope\":{\"name\":\"logfire-zig\"},"); 124 + try w.writeAll("\"spans\":["); 125 + 126 + for (spans, 0..) |s, i| { 127 + if (i > 0) try w.writeByte(','); 128 + try self.writeSpanJson(&w, s); 129 + } 130 + 131 + try w.writeAll("]}]}]}"); 132 + 133 + return json.toOwnedSlice(self.allocator); 134 + } 135 + 136 + /// build OTLP JSON for logs 137 + fn buildLogsJson(self: *Exporter, logs: []const LogRecord) ![]u8 { 138 + var json: std.ArrayList(u8) = .{}; 139 + errdefer json.deinit(self.allocator); 140 + 141 + var w = json.writer(self.allocator); 142 + 143 + try w.writeAll("{\"resourceLogs\":[{"); 144 + 145 + // resource attributes 146 + try w.writeAll("\"resource\":{\"attributes\":["); 147 + try self.writeResourceAttributes(&w); 148 + try w.writeAll("]},"); 149 + 150 + // scope logs 151 + try w.writeAll("\"scopeLogs\":[{"); 152 + try w.writeAll("\"scope\":{\"name\":\"logfire-zig\"},"); 153 + try w.writeAll("\"logRecords\":["); 154 + 155 + for (logs, 0..) |log, i| { 156 + if (i > 0) try w.writeByte(','); 157 + try self.writeLogJson(&w, log); 158 + } 159 + 160 + try w.writeAll("]}]}]}"); 161 + 162 + return json.toOwnedSlice(self.allocator); 163 + } 164 + 165 + fn writeResourceAttributes(self: *Exporter, w: anytype) !void { 166 + var first = true; 167 + 168 + if (self.config.service_name) |name| { 169 + try writeAttribute(w, "service.name", .{ .string = name }, &first); 170 + } 171 + if (self.config.service_version) |version| { 172 + try writeAttribute(w, "service.version", .{ .string = version }, &first); 173 + } 174 + if (self.config.environment) |env| { 175 + try writeAttribute(w, "deployment.environment.name", .{ .string = env }, &first); 176 + } 177 + } 178 + 179 + fn writeSpanJson(self: *Exporter, w: anytype, s: Span.Data) !void { 180 + _ = self; 181 + try w.writeByte('{'); 182 + 183 + // trace and span IDs (hex encoded) 184 + try w.writeAll("\"traceId\":\""); 185 + try writeHex(w, &s.trace_id); 186 + try w.writeAll("\",\"spanId\":\""); 187 + try writeHex(w, &s.span_id); 188 + try w.writeAll("\","); 189 + 190 + // name 191 + try w.writeAll("\"name\":"); 192 + try writeJsonString(w, s.name); 193 + try w.writeByte(','); 194 + 195 + // kind (internal = 1) 196 + try w.writeAll("\"kind\":1,"); 197 + 198 + // timestamps (nanoseconds) 199 + try w.print("\"startTimeUnixNano\":\"{d}\",", .{s.start_time_ns}); 200 + try w.print("\"endTimeUnixNano\":\"{d}\",", .{s.end_time_ns}); 201 + 202 + // attributes (empty for now, will add later) 203 + try w.writeAll("\"attributes\":[]"); 204 + 205 + // status (unset = 0) 206 + try w.writeAll(",\"status\":{\"code\":0}"); 207 + 208 + try w.writeByte('}'); 209 + } 210 + 211 + fn writeLogJson(self: *Exporter, w: anytype, log: LogRecord) !void { 212 + _ = self; 213 + try w.writeByte('{'); 214 + 215 + // timestamp 216 + try w.print("\"timeUnixNano\":\"{d}\",", .{log.timestamp_ns}); 217 + 218 + // severity 219 + try w.print("\"severityNumber\":{d},", .{log.level.severity()}); 220 + try w.writeAll("\"severityText\":"); 221 + try writeJsonString(w, log.level.name()); 222 + try w.writeByte(','); 223 + 224 + // body 225 + try w.writeAll("\"body\":{\"stringValue\":"); 226 + try writeJsonString(w, log.message); 227 + try w.writeAll("},"); 228 + 229 + // trace context 230 + if (log.trace_id) |tid| { 231 + try w.writeAll("\"traceId\":\""); 232 + try writeHex(w, &tid); 233 + try w.writeAll("\","); 234 + } 235 + 236 + // attributes (empty for now) 237 + try w.writeAll("\"attributes\":[]"); 238 + 239 + try w.writeByte('}'); 240 + } 241 + 242 + fn printToConsole(self: *Exporter, spans: []const Span.Data, logs: []const LogRecord) void { 243 + const console = self.config.console orelse return; 244 + if (!console.enabled) return; 245 + 246 + for (spans) |s| { 247 + const duration_ms = @as(f64, @floatFromInt(s.end_time_ns - s.start_time_ns)) / 1_000_000.0; 248 + std.debug.print("[span] {s} ({d:.2}ms)\n", .{ s.name, duration_ms }); 249 + } 250 + 251 + for (logs) |log| { 252 + if (@intFromEnum(log.level) < @intFromEnum(console.min_level)) continue; 253 + std.debug.print("[{s}] {s}\n", .{ log.level.name(), log.message }); 254 + } 255 + } 256 + }; 257 + 258 + const AttributeValue = union(enum) { 259 + string: []const u8, 260 + int: i64, 261 + float: f64, 262 + bool_val: bool, 263 + }; 264 + 265 + fn writeAttribute(w: anytype, key: []const u8, value: AttributeValue, first: *bool) !void { 266 + if (!first.*) try w.writeByte(','); 267 + first.* = false; 268 + try writeAttributeKv(w, key, value); 269 + } 270 + 271 + fn writeAttributeKv(w: anytype, key: []const u8, value: AttributeValue) !void { 272 + try w.writeAll("{\"key\":"); 273 + try writeJsonString(w, key); 274 + try w.writeAll(",\"value\":{"); 275 + 276 + switch (value) { 277 + .string => |s| { 278 + try w.writeAll("\"stringValue\":"); 279 + try writeJsonString(w, s); 280 + }, 281 + .int => |i| { 282 + try w.print("\"intValue\":\"{d}\"", .{i}); 283 + }, 284 + .float => |f| { 285 + try w.print("\"doubleValue\":{d}", .{f}); 286 + }, 287 + .bool_val => |b| { 288 + try w.print("\"boolValue\":{}", .{b}); 289 + }, 290 + } 291 + 292 + try w.writeAll("}}"); 293 + } 294 + 295 + fn writeHex(w: anytype, bytes: []const u8) !void { 296 + for (bytes) |b| { 297 + try w.print("{x:0>2}", .{b}); 298 + } 299 + } 300 + 301 + fn writeJsonString(w: anytype, s: []const u8) !void { 302 + try w.writeByte('"'); 303 + for (s) |c| { 304 + switch (c) { 305 + '"' => try w.writeAll("\\\""), 306 + '\\' => try w.writeAll("\\\\"), 307 + '\n' => try w.writeAll("\\n"), 308 + '\r' => try w.writeAll("\\r"), 309 + '\t' => try w.writeAll("\\t"), 310 + 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try w.print("\\u00{x:0>2}", .{c}), 311 + else => try w.writeByte(c), 312 + } 313 + } 314 + try w.writeByte('"'); 315 + } 316 + 317 + // tests 318 + 319 + test "buildTracesJson" { 320 + const allocator = std.testing.allocator; 321 + var config = Config{ 322 + .service_name = "test-service", 323 + .send_to_logfire = .no, 324 + }; 325 + config = config.resolve(); 326 + 327 + var exporter = Exporter.init(allocator, config); 328 + defer exporter.deinit(); 329 + 330 + const spans = [_]Span.Data{ 331 + .{ 332 + .name = "test.span", 333 + .trace_id = [_]u8{0x01} ** 16, 334 + .span_id = [_]u8{0x02} ** 8, 335 + .start_time_ns = 1000, 336 + .end_time_ns = 2000, 337 + }, 338 + }; 339 + 340 + const json = try exporter.buildTracesJson(&spans); 341 + defer allocator.free(json); 342 + 343 + try std.testing.expect(std.mem.indexOf(u8, json, "test.span") != null); 344 + try std.testing.expect(std.mem.indexOf(u8, json, "test-service") != null); 345 + }
+37
src/log.zig
··· 1 + //! structured logging 2 + //! 3 + //! logs are exported as OTLP log records with severity levels. 4 + 5 + const std = @import("std"); 6 + pub const Level = @import("config.zig").Config.Level; 7 + 8 + pub const LogRecord = struct { 9 + timestamp_ns: i128, 10 + level: Level, 11 + message: []const u8, 12 + trace_id: ?[16]u8, 13 + 14 + pub fn init( 15 + trace_id: ?[16]u8, 16 + level: Level, 17 + message: []const u8, 18 + attrs: anytype, 19 + ) LogRecord { 20 + _ = attrs; // TODO: implement attribute storage 21 + 22 + return .{ 23 + .timestamp_ns = std.time.nanoTimestamp(), 24 + .level = level, 25 + .message = message, 26 + .trace_id = trace_id, 27 + }; 28 + } 29 + }; 30 + 31 + // tests 32 + 33 + test "LogRecord init" { 34 + const record = LogRecord.init(null, .info, "test message", .{}); 35 + try std.testing.expectEqual(Level.info, record.level); 36 + try std.testing.expectEqualStrings("test message", record.message); 37 + }
+245
src/root.zig
··· 1 + //! logfire-zig: zig SDK for pydantic logfire 2 + //! 3 + //! a lightweight observability client that sends traces, logs, and metrics 4 + //! to logfire (or any OTLP-compatible backend) via HTTP/JSON. 5 + //! 6 + //! ## quick start 7 + //! 8 + //! ```zig 9 + //! const logfire = @import("logfire"); 10 + //! 11 + //! pub fn main() !void { 12 + //! var lf = try logfire.configure(.{ 13 + //! .service_name = "my-service", 14 + //! }); 15 + //! defer lf.shutdown(); 16 + //! 17 + //! logfire.info("hello from zig", .{}); 18 + //! 19 + //! const s = logfire.span("do_work", .{ .item_count = 42 }); 20 + //! defer s.end(); 21 + //! // ... do work 22 + //! } 23 + //! ``` 24 + 25 + const std = @import("std"); 26 + 27 + pub const Config = @import("config.zig").Config; 28 + pub const Span = @import("span.zig").Span; 29 + pub const Exporter = @import("exporter.zig").Exporter; 30 + 31 + const log_mod = @import("log.zig"); 32 + pub const Level = log_mod.Level; 33 + pub const LogRecord = log_mod.LogRecord; 34 + 35 + /// global logfire instance (set after configure()) 36 + var global_instance: ?*Logfire = null; 37 + var global_mutex: std.Thread.Mutex = .{}; 38 + 39 + pub const Logfire = struct { 40 + allocator: std.mem.Allocator, 41 + config: Config, 42 + exporter: Exporter, 43 + 44 + /// pending spans/logs waiting to be exported 45 + pending_spans: std.ArrayList(Span.Data), 46 + pending_logs: std.ArrayList(LogRecord), 47 + pending_mutex: std.Thread.Mutex, 48 + 49 + /// trace context 50 + current_trace_id: ?[16]u8, 51 + span_id_counter: std.atomic.Value(u64), 52 + 53 + pub fn init(allocator: std.mem.Allocator, config: Config) !*Logfire { 54 + const resolved = config.resolve(); 55 + 56 + const self = try allocator.create(Logfire); 57 + self.* = .{ 58 + .allocator = allocator, 59 + .config = resolved, 60 + .exporter = Exporter.init(allocator, resolved), 61 + .pending_spans = .{}, 62 + .pending_logs = .{}, 63 + .pending_mutex = .{}, 64 + .current_trace_id = null, 65 + .span_id_counter = std.atomic.Value(u64).init(1), 66 + }; 67 + 68 + // generate initial trace id 69 + self.current_trace_id = generateTraceId(); 70 + 71 + return self; 72 + } 73 + 74 + pub fn deinit(self: *Logfire) void { 75 + self.pending_spans.deinit(self.allocator); 76 + self.pending_logs.deinit(self.allocator); 77 + self.exporter.deinit(); 78 + self.allocator.destroy(self); 79 + } 80 + 81 + pub fn shutdown(self: *Logfire) void { 82 + self.flush() catch |e| { 83 + std.log.warn("logfire: flush failed during shutdown: {}", .{e}); 84 + }; 85 + 86 + global_mutex.lock(); 87 + defer global_mutex.unlock(); 88 + if (global_instance == self) { 89 + global_instance = null; 90 + } 91 + 92 + self.deinit(); 93 + } 94 + 95 + pub fn flush(self: *Logfire) !void { 96 + self.pending_mutex.lock(); 97 + defer self.pending_mutex.unlock(); 98 + 99 + if (self.pending_spans.items.len > 0 or self.pending_logs.items.len > 0) { 100 + try self.exporter.send( 101 + self.pending_spans.items, 102 + self.pending_logs.items, 103 + ); 104 + self.pending_spans.clearRetainingCapacity(); 105 + self.pending_logs.clearRetainingCapacity(); 106 + } 107 + } 108 + 109 + pub fn createSpan(self: *Logfire, name: []const u8, attributes: anytype) Span { 110 + const span_id = self.span_id_counter.fetchAdd(1, .monotonic); 111 + return Span.init(self, name, span_id, attributes); 112 + } 113 + 114 + pub fn recordLog(self: *Logfire, level: Level, message: []const u8, attributes: anytype) void { 115 + const record = LogRecord.init( 116 + self.current_trace_id, 117 + level, 118 + message, 119 + attributes, 120 + ); 121 + 122 + self.pending_mutex.lock(); 123 + defer self.pending_mutex.unlock(); 124 + self.pending_logs.append(self.allocator, record) catch { 125 + std.log.warn("logfire: failed to record log", .{}); 126 + }; 127 + } 128 + 129 + pub fn recordSpanEnd(self: *Logfire, data: Span.Data) void { 130 + self.pending_mutex.lock(); 131 + defer self.pending_mutex.unlock(); 132 + self.pending_spans.append(self.allocator, data) catch { 133 + std.log.warn("logfire: failed to record span", .{}); 134 + }; 135 + } 136 + 137 + fn generateTraceId() [16]u8 { 138 + var id: [16]u8 = undefined; 139 + std.crypto.random.bytes(&id); 140 + return id; 141 + } 142 + }; 143 + 144 + /// configure logfire with the given options 145 + pub fn configure(options: Config) !*Logfire { 146 + const allocator = std.heap.page_allocator; 147 + const instance = try Logfire.init(allocator, options); 148 + 149 + global_mutex.lock(); 150 + defer global_mutex.unlock(); 151 + global_instance = instance; 152 + 153 + return instance; 154 + } 155 + 156 + /// get the global logfire instance 157 + pub fn getInstance() ?*Logfire { 158 + global_mutex.lock(); 159 + defer global_mutex.unlock(); 160 + return global_instance; 161 + } 162 + 163 + // convenience functions that use the global instance 164 + 165 + pub fn span(name: []const u8, attributes: anytype) Span { 166 + if (getInstance()) |lf| { 167 + return lf.createSpan(name, attributes); 168 + } 169 + return Span.noop(); 170 + } 171 + 172 + pub fn trace(comptime fmt: []const u8, args: anytype) void { 173 + logWithLevel(.trace, fmt, args); 174 + } 175 + 176 + pub fn debug(comptime fmt: []const u8, args: anytype) void { 177 + logWithLevel(.debug, fmt, args); 178 + } 179 + 180 + pub fn info(comptime fmt: []const u8, args: anytype) void { 181 + logWithLevel(.info, fmt, args); 182 + } 183 + 184 + pub fn warn(comptime fmt: []const u8, args: anytype) void { 185 + logWithLevel(.warn, fmt, args); 186 + } 187 + 188 + pub fn err(comptime fmt: []const u8, args: anytype) void { 189 + logWithLevel(.err, fmt, args); 190 + } 191 + 192 + fn logWithLevel(level: Level, comptime fmt: []const u8, args: anytype) void { 193 + if (getInstance()) |lf| { 194 + var buf: [4096]u8 = undefined; 195 + const message = std.fmt.bufPrint(&buf, fmt, args) catch fmt; 196 + // dupe the message since buf is on the stack 197 + const owned_message = lf.allocator.dupe(u8, message) catch return; 198 + lf.recordLog(level, owned_message, .{}); 199 + } 200 + } 201 + 202 + // tests 203 + 204 + test "basic configuration" { 205 + const lf = try configure(.{ 206 + .service_name = "test-service", 207 + .send_to_logfire = .no, 208 + }); 209 + defer lf.shutdown(); 210 + 211 + try std.testing.expect(getInstance() != null); 212 + } 213 + 214 + test "span creation" { 215 + const lf = try configure(.{ 216 + .service_name = "test-service", 217 + .send_to_logfire = .no, 218 + }); 219 + defer lf.shutdown(); 220 + 221 + const s = span("test.operation", .{}); 222 + defer s.end(); 223 + 224 + try std.testing.expect(s.active); 225 + } 226 + 227 + test "logging" { 228 + const lf = try configure(.{ 229 + .service_name = "test-service", 230 + .send_to_logfire = .no, 231 + }); 232 + defer lf.shutdown(); 233 + 234 + info("test message with {d}", .{42}); 235 + 236 + try std.testing.expectEqual(@as(usize, 1), lf.pending_logs.items.len); 237 + } 238 + 239 + // re-export tests from submodules 240 + test { 241 + _ = @import("config.zig"); 242 + _ = @import("exporter.zig"); 243 + _ = @import("span.zig"); 244 + _ = @import("log.zig"); 245 + }
+85
src/span.zig
··· 1 + //! span tracking for distributed tracing 2 + //! 3 + //! spans measure the duration of operations. 4 + //! use defer to ensure spans are ended even on error paths. 5 + //! 6 + //! ## usage 7 + //! 8 + //! ```zig 9 + //! const s = logfire.span("db.query", .{}); 10 + //! defer s.end(); 11 + //! // ... do work 12 + //! ``` 13 + 14 + const std = @import("std"); 15 + const root = @import("root.zig"); 16 + 17 + pub const Span = struct { 18 + logfire: ?*root.Logfire, 19 + data: Data, 20 + active: bool, 21 + 22 + pub const Data = struct { 23 + name: []const u8, 24 + trace_id: [16]u8, 25 + span_id: [8]u8, 26 + start_time_ns: i128, 27 + end_time_ns: i128, 28 + }; 29 + 30 + /// create a span (called by Logfire.createSpan) 31 + pub fn init(logfire: *root.Logfire, name: []const u8, span_id_num: u64, attrs: anytype) Span { 32 + _ = attrs; // TODO: implement attribute storage 33 + 34 + var s = Span{ 35 + .logfire = logfire, 36 + .data = .{ 37 + .name = name, 38 + .trace_id = logfire.current_trace_id orelse [_]u8{0} ** 16, 39 + .span_id = undefined, 40 + .start_time_ns = std.time.nanoTimestamp(), 41 + .end_time_ns = 0, 42 + }, 43 + .active = true, 44 + }; 45 + 46 + // encode span ID from counter 47 + std.mem.writeInt(u64, &s.data.span_id, span_id_num, .big); 48 + 49 + return s; 50 + } 51 + 52 + /// create a no-op span (when logfire not configured) 53 + pub fn noop() Span { 54 + return .{ 55 + .logfire = null, 56 + .data = .{ 57 + .name = "", 58 + .trace_id = [_]u8{0} ** 16, 59 + .span_id = [_]u8{0} ** 8, 60 + .start_time_ns = 0, 61 + .end_time_ns = 0, 62 + }, 63 + .active = false, 64 + }; 65 + } 66 + 67 + /// end the span and record it 68 + pub fn end(self: *const Span) void { 69 + if (!self.active) return; 70 + if (self.logfire) |lf| { 71 + var data = self.data; 72 + data.end_time_ns = std.time.nanoTimestamp(); 73 + lf.recordSpanEnd(data); 74 + } 75 + } 76 + }; 77 + 78 + // tests 79 + 80 + test "span lifecycle" { 81 + const s = Span.noop(); 82 + defer s.end(); 83 + 84 + try std.testing.expect(!s.active); 85 + }