Skip to content

Commit 3f23e07

Browse files
Merge pull request #443 from karlseguin/logging
Add a structured logger
2 parents 6c75177 + 1036f75 commit 3f23e07

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
@@ -381,4 +381,5 @@ test {
381381
std.testing.refAllDecls(@import("iterator/iterator.zig"));
382382
std.testing.refAllDecls(@import("server.zig"));
383383
std.testing.refAllDecls(@import("cdp/cdp.zig"));
384+
std.testing.refAllDecls(@import("log.zig"));
384385
}

0 commit comments

Comments
 (0)