Skip to content

Commit 050cda8

Browse files
committed
Add a structured logger
In debug mode, it has a more user-friendly output: level | the log messge | ms since last message | key=value key=value In release mode, it logs using logfmt, which is supported by most log ingestion frameworks. Not being used anywhere right now, keeping this PR small with no impact on existing code.
1 parent 09505db commit 050cda8

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed

src/log.zig

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
const std = @import("std");
2+
const builtin = @import("builtin");
3+
4+
const Allocator = std.mem.Allocator;
5+
6+
// synchronizes writes to the output
7+
// in debug mode, also synchronizes the timestamp counter for a more human-
8+
// readable time display
9+
var mutex: std.Thread.Mutex = .{};
10+
11+
const LogLevel: Log.Level = blk: {
12+
const root = @import("root");
13+
break :blk if (@hasDecl(root, "LogLevel")) root.LogLevel else .info;
14+
};
15+
16+
pub const Log = LogT(std.fs.File, builtin.mode == .Debug);
17+
18+
// Generic so that we can test it against an ArrayList
19+
fn LogT(comptime Out: type, comptime enhanced_readability: bool) type {
20+
return struct {
21+
out: Out,
22+
inject: ?[]const u8,
23+
allocator: Allocator,
24+
buffer: std.ArrayListUnmanaged(u8),
25+
26+
const Self = @This();
27+
28+
pub const Level = enum {
29+
debug,
30+
info,
31+
warn,
32+
@"error",
33+
fatal,
34+
};
35+
36+
//
37+
pub fn init(allocator: Allocator) !Self {
38+
return initTo(allocator, std.io.getStdErr());
39+
}
40+
41+
// Used for tests
42+
fn initTo(allocator: Allocator, out: Out) !Self {
43+
var buffer: std.ArrayListUnmanaged(u8) = .{};
44+
try buffer.ensureTotalCapacity(allocator, 2048);
45+
46+
return .{
47+
.out = out,
48+
.inject = null,
49+
.buffer = buffer,
50+
.allocator = allocator,
51+
};
52+
}
53+
54+
pub fn deinit(self: *Self) void {
55+
self.buffer.deinit(self.allocator);
56+
}
57+
58+
pub fn enabled(comptime level: Level) bool {
59+
return @intFromEnum(level) >= @intFromEnum(LogLevel);
60+
}
61+
62+
pub fn debug(self: *Self, comptime msg: []const u8, data: anytype) void {
63+
self.log(.debug, msg, data);
64+
}
65+
66+
pub fn info(self: *Self, comptime msg: []const u8, data: anytype) void {
67+
self.log(.info, msg, data);
68+
}
69+
70+
pub fn warn(self: *Self, comptime msg: []const u8, data: anytype) void {
71+
self.log(.warn, msg, data);
72+
}
73+
74+
pub fn err(self: *Self, comptime msg: []const u8, data: anytype) void {
75+
self.log(.@"error", msg, data);
76+
}
77+
78+
pub fn fatal(self: *Self, comptime msg: []const u8, data: anytype) void {
79+
self.log(.fatal, msg, data);
80+
}
81+
82+
fn log(self: *Self, comptime level: Level, comptime msg: []const u8, data: anytype) void {
83+
if (comptime enabled(level) == false) {
84+
return;
85+
}
86+
defer self.buffer.clearRetainingCapacity();
87+
self._log(level, msg, data) catch |e| {
88+
std.debug.print("log error: {} ({s} - {s})\n", .{ e, @tagName(level), msg });
89+
};
90+
}
91+
92+
fn _log(self: *Self, comptime level: Level, comptime msg: []const u8, data: anytype) !void {
93+
const allocator = self.allocator;
94+
95+
// We use *AssumeCapacity here because we expect buffer to have
96+
// a reasonable default size. We expect time + level + msg + inject
97+
// to fit in the initial buffer;
98+
var buffer = &self.buffer;
99+
100+
comptime {
101+
if (msg.len > 512) {
102+
@compileError("log msg cannot be greater than 512 characters: '" ++ msg ++ "'");
103+
}
104+
for (msg) |b| {
105+
switch (b) {
106+
'A'...'Z', 'a'...'z', ' ', '0'...'9', '_', '-', '.', '{', '}' => {},
107+
else => @compileError("log msg contains an invalid character '" ++ msg ++ "'"),
108+
}
109+
}
110+
}
111+
112+
std.debug.assert(buffer.capacity >= 1024);
113+
114+
if (comptime enhanced_readability) {
115+
// used when developing, and we prefer readability over having
116+
// the output in logfmt
117+
118+
// write the level this way so that the column lines up.
119+
switch (level) {
120+
.info => buffer.appendSliceAssumeCapacity("info | "),
121+
.debug => buffer.appendSliceAssumeCapacity("debug | "),
122+
.warn => buffer.appendSliceAssumeCapacity("\x1b[33m warn\x1b[0m | "),
123+
.@"error" => buffer.appendSliceAssumeCapacity("\x1b[31m error\x1b[0m | "),
124+
.fatal => buffer.appendSliceAssumeCapacity("\x1b[41m fatal\x1b[0m | "),
125+
}
126+
127+
buffer.appendSliceAssumeCapacity(msg ++ " | ");
128+
const since_last_log = msSinceLastLog();
129+
130+
if (since_last_log > 1000) {
131+
buffer.appendSliceAssumeCapacity("\x1b[35m");
132+
}
133+
try std.fmt.format(buffer.writer(allocator), "{d}\x1b[0m |", .{since_last_log});
134+
} else {
135+
buffer.appendSliceAssumeCapacity("_time=");
136+
try std.fmt.format(buffer.writer(allocator), "{d}", .{getTime()});
137+
138+
const level_and_msg = comptime blk: {
139+
// only wrap msg in quotes if it contains a space
140+
const lm = " _level=" ++ @tagName(level) ++ " _msg=";
141+
if (std.mem.indexOfScalar(u8, msg, ' ') == null) {
142+
break :blk lm ++ msg;
143+
}
144+
break :blk lm ++ "\"" ++ msg ++ "\"";
145+
};
146+
buffer.appendSliceAssumeCapacity(level_and_msg);
147+
}
148+
149+
if (self.inject) |inject| {
150+
buffer.appendAssumeCapacity(' ');
151+
buffer.appendSliceAssumeCapacity(inject);
152+
}
153+
154+
inline for (@typeInfo(@TypeOf(data)).Struct.fields) |f| {
155+
// + 2 for the leading space and the equal sign
156+
// + 5 to save space for null/false/true common values
157+
const key_len = f.name.len + 7;
158+
try buffer.ensureUnusedCapacity(allocator, key_len);
159+
buffer.appendAssumeCapacity(' ');
160+
buffer.appendSliceAssumeCapacity(f.name);
161+
buffer.appendAssumeCapacity('=');
162+
try writeValue(allocator, buffer, @field(data, f.name));
163+
}
164+
165+
if (comptime enhanced_readability) {
166+
// reset any color
167+
try buffer.appendSlice(allocator, "\x1b[0m");
168+
}
169+
170+
try buffer.append(allocator, '\n');
171+
172+
mutex.lock();
173+
defer mutex.unlock();
174+
try self.out.writeAll(self.buffer.items);
175+
}
176+
};
177+
}
178+
179+
fn writeValue(allocator: Allocator, buffer: *std.ArrayListUnmanaged(u8), value: anytype) !void {
180+
const T = @TypeOf(value);
181+
switch (@typeInfo(T)) {
182+
.Optional => {
183+
if (value) |v| {
184+
return writeValue(allocator, buffer, v);
185+
}
186+
// in _log, we reserved space for a value of up to 5 bytes.
187+
return buffer.appendSliceAssumeCapacity("null");
188+
},
189+
.ComptimeInt, .Int, .ComptimeFloat, .Float => {
190+
return std.fmt.format(buffer.writer(allocator), "{d}", .{value});
191+
},
192+
.Bool => {
193+
// in _log, we reserved space for a value of up to 5 bytes.
194+
return buffer.appendSliceAssumeCapacity(if (value) "true" else "false");
195+
},
196+
.ErrorSet => return buffer.appendSlice(allocator, @errorName(value)),
197+
.Enum => return buffer.appendSlice(allocator, @tagName(value)),
198+
.Array => return writeValue(allocator, buffer, &value),
199+
.Pointer => |ptr| switch (ptr.size) {
200+
.Slice => switch (ptr.child) {
201+
u8 => return writeString(allocator, buffer, value),
202+
else => {},
203+
},
204+
.One => switch (@typeInfo(ptr.child)) {
205+
.Array => |arr| if (arr.child == u8) {
206+
return writeString(allocator, buffer, value);
207+
},
208+
else => return false,
209+
},
210+
else => {},
211+
},
212+
else => {},
213+
}
214+
@compileError("cannot log a: " ++ @typeName(T));
215+
}
216+
217+
fn writeString(allocator: Allocator, buffer: *std.ArrayListUnmanaged(u8), value: []const u8) !void {
218+
var space_count: usize = 0;
219+
var escape_count: usize = 0;
220+
var binary_count: usize = 0;
221+
222+
for (value) |b| {
223+
switch (b) {
224+
'\r', '\n', '"' => escape_count += 1,
225+
' ' => space_count += 1,
226+
'\t', '!', '#'...'~' => {}, // printable characters
227+
else => binary_count += 1,
228+
}
229+
}
230+
231+
if (binary_count > 0) {
232+
// TODO: use a different encoding if the ratio of binary data / printable
233+
// is low
234+
// TODO: Zig 0.14 adds an encodeWriter
235+
return buffer.appendSlice(allocator, "\"<binary data> (will be supported once we move to Zig 0.14\"");
236+
// return std.base64.standard_no_pad.Encoder.encodeWriter(buffer.writer(allocator), value);
237+
}
238+
239+
if (escape_count == 0) {
240+
if (space_count == 0) {
241+
return buffer.appendSlice(allocator, value);
242+
}
243+
try buffer.ensureUnusedCapacity(allocator, 2 + value.len);
244+
buffer.appendAssumeCapacity('"');
245+
buffer.appendSliceAssumeCapacity(value);
246+
buffer.appendAssumeCapacity('"');
247+
return;
248+
}
249+
250+
// + 2 for the quotes
251+
// + escape_count because every character that needs escaping is + 1
252+
try buffer.ensureUnusedCapacity(allocator, 2 + value.len + escape_count);
253+
254+
buffer.appendAssumeCapacity('"');
255+
256+
var rest = value;
257+
while (rest.len > 0) {
258+
const pos = std.mem.indexOfAny(u8, rest, "\r\n\"") orelse {
259+
buffer.appendSliceAssumeCapacity(rest);
260+
break;
261+
};
262+
buffer.appendSliceAssumeCapacity(rest[0..pos]);
263+
buffer.appendAssumeCapacity('\\');
264+
switch (rest[pos]) {
265+
'"' => buffer.appendAssumeCapacity('"'),
266+
'\r' => buffer.appendAssumeCapacity('r'),
267+
'\n' => buffer.appendAssumeCapacity('n'),
268+
else => unreachable,
269+
}
270+
rest = rest[pos + 1 ..];
271+
}
272+
273+
buffer.appendAssumeCapacity('"');
274+
}
275+
276+
fn getTime() i64 {
277+
if (comptime @import("builtin").is_test) {
278+
return 1739795092929;
279+
}
280+
return std.time.milliTimestamp();
281+
}
282+
283+
var last_log_for_debug: i64 = 0;
284+
fn msSinceLastLog() i64 {
285+
if (comptime builtin.mode != .Debug) {
286+
@compileError("Log's enhanced_readability is not safe to use in non-Debug mode");
287+
}
288+
const now = getTime();
289+
290+
mutex.lock();
291+
defer mutex.unlock();
292+
defer last_log_for_debug = now;
293+
if (last_log_for_debug == 0) {
294+
return 0;
295+
}
296+
return now - last_log_for_debug;
297+
}
298+
299+
const testing = std.testing;
300+
const TestLogger = LogT(std.ArrayListUnmanaged(u8).Writer, false);
301+
302+
test "log: data" {
303+
var buf: std.ArrayListUnmanaged(u8) = .{};
304+
defer buf.deinit(testing.allocator);
305+
306+
var log = try TestLogger.initTo(testing.allocator, buf.writer(testing.allocator));
307+
defer log.deinit();
308+
309+
{
310+
log.err("nope", .{});
311+
try testing.expectEqualStrings("_time=1739795092929 _level=error _msg=nope\n", buf.items);
312+
}
313+
314+
{
315+
buf.clearRetainingCapacity();
316+
const string = try testing.allocator.dupe(u8, "spice_must_flow");
317+
defer testing.allocator.free(string);
318+
319+
log.warn("a msg", .{
320+
.cint = 5,
321+
.cfloat = 3.43,
322+
.int = @as(i16, -49),
323+
.float = @as(f32, 0.0003232),
324+
.bt = true,
325+
.bf = false,
326+
.nn = @as(?i32, 33),
327+
.n = @as(?i32, null),
328+
.lit = "over9000!",
329+
.slice = string,
330+
.err = error.Nope,
331+
.level = Log.Level.warn,
332+
});
333+
334+
try testing.expectEqualStrings("_time=1739795092929 _level=warn _msg=\"a msg\" " ++
335+
"cint=5 cfloat=3.43 int=-49 float=0.0003232 bt=true bf=false " ++
336+
"nn=33 n=null lit=over9000! slice=spice_must_flow " ++
337+
"err=Nope level=warn\n", buf.items);
338+
}
339+
}
340+
341+
test "log: string escape" {
342+
var buf: std.ArrayListUnmanaged(u8) = .{};
343+
defer buf.deinit(testing.allocator);
344+
345+
var log = try TestLogger.initTo(testing.allocator, buf.writer(testing.allocator));
346+
defer log.deinit();
347+
348+
const prefix = "_time=1739795092929 _level=error _msg=test ";
349+
{
350+
log.err("test", .{ .string = "hello world" });
351+
try testing.expectEqualStrings(prefix ++ "string=\"hello world\"\n", buf.items);
352+
}
353+
354+
{
355+
buf.clearRetainingCapacity();
356+
log.err("test", .{ .string = "\n \thi \" \" " });
357+
try testing.expectEqualStrings(prefix ++ "string=\"\\n \thi \\\" \\\" \"\n", buf.items);
358+
}
359+
360+
// TODO: Zig 0.14
361+
// {
362+
// log.err("test", .{.string = [_]u8{0, 244, 55, 77}});
363+
// try testing.expectEqualStrings(prefix ++ "string=\"\\n \\thi \\\" \\\" \"\n", buf.items);
364+
// }
365+
}
366+
367+
test "log: with inject" {
368+
var buf: std.ArrayListUnmanaged(u8) = .{};
369+
defer buf.deinit(testing.allocator);
370+
371+
var log = try TestLogger.initTo(testing.allocator, buf.writer(testing.allocator));
372+
defer log.deinit();
373+
374+
log.inject = "conn_id=339494";
375+
log.fatal("hit", .{ .over = 9000 });
376+
try testing.expectEqualStrings("_time=1739795092929 _level=fatal _msg=hit conn_id=339494 over=9000\n", buf.items);
377+
}

src/unit_tests.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,5 @@ test {
377377
std.testing.refAllDecls(@import("storage/storage.zig"));
378378
std.testing.refAllDecls(@import("iterator/iterator.zig"));
379379
std.testing.refAllDecls(@import("server.zig"));
380+
std.testing.refAllDecls(@import("log.zig"));
380381
}

0 commit comments

Comments
 (0)