I am using zig for some time now. Wanted to print timestamp in log messages for one of my other project. Developed the simple pure zig implementation with no C link or dependencies.
ehrktia/zig-epoch: simple lib to print timestamp in human readable format using posix epoch . Pure zig implementation thread safe. - Codeberg.org
Thank you to all the people in zig discord community who did review and help me with the implementation.
I added some addition function that I needed for myself. Here is my small refactor that I did:
fn check_decimal(comptime T: type, in: T) ![2]u8 {
var b: [2]u8 = undefined;
_ = try std.fmt.bufPrint(&b, "{d:0>2}", .{in}); // Forces 2 digits, padding with '0'
return b;
}
fn check_m_sec(in: u16) ![3]u8 {
var b: [3]u8 = undefined;
_ = try std.fmt.bufPrint(&b, "{d:0>3}", .{in}); // Forces 3 digits, padding with '0'
return b;
}
const MONTH_NAMES = [12][]const u8{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
};
const TimeParts = struct {
year: u16,
month_index: u4, // 0 = Jan
day: u5, // 1..31
hour: u5,
min: u6,
sec: u6,
msec: u16,
};
fn getTimeParts(timestamp: std.Io.Timestamp) TimeParts {
const sec = std.Io.Timestamp.toSeconds(timestamp);
const sec_u: u64 = @intCast(sec);
const day_sec = epoch_seconds.getDaySeconds(epoch_seconds{ .secs = sec_u });
const hrs = day_epoch_seconds.getHoursIntoDay(day_sec);
const mins = day_epoch_seconds.getMinutesIntoHour(day_sec);
const secs = day_epoch_seconds.getSecondsIntoMinute(day_sec);
const day = epoch_seconds.getEpochDay(epoch_seconds{ .secs = sec_u });
const yr_day = epoch_day.calculateYearDay(day);
const mon_day = epoch_year_day.calculateMonthDay(yr_day);
const msec = @rem(std.Io.Timestamp.toMilliseconds(timestamp), 1000);
return TimeParts{
.year = yr_day.year,
.month_index = mon_day.month.numeric(),
.day = mon_day.day_index + 1,
.hour = hrs,
.min = mins,
.sec = secs,
.msec = @intCast(msec),
};
}
I extracted handling of time parts so the functions look like this now:
pub fn now(self: Self) [23]u8 {
var buf: [23]u8 = undefined;
const timestamp = clock.now(
real_clock,
self.io.*,
) catch |e|
panic("failed to get clock:{any}\n", .{e});
const parts = getTimeParts(timestamp);
const hr = check_decimal(u5, parts.hour) catch |e|
panic("{any}\n", .{e});
const min = check_decimal(u6, parts.min) catch |e|
panic("{any}\n", .{e});
const sec_padded = check_decimal(u6, parts.sec) catch |e|
panic("{any}\n", .{e});
const mon = check_decimal(u4, parts.month_index) catch |e|
panic("{any}\n", .{e});
const day_check = check_decimal(u5, parts.day) catch |e|
panic("failed to check day:{any}\n", .{e});
const m_sec = check_m_sec(parts.msec) catch |e|
panic("{any}\n", .{e});
_ = std.fmt.bufPrint(&buf, "{d}-{s}-{s} {s}:{s}:{s}.{s}", .{
parts.year,
mon,
day_check,
hr,
min,
sec_padded,
m_sec,
}) catch |e| panic("error getting time:{any}\n", .{e});
return buf;
}
pub fn nowSyslog(self: Self) [15]u8 {
const timestamp = clock.now(
real_clock,
self.io.*,
) catch |e| {
panic("failed to get clock:{any}\n", .{e});
};
const parts = getTimeParts(timestamp);
var buf: [15]u8 = undefined;
const mon = MONTH_NAMES[parts.month_index - 1];
const hr = check_decimal(u5, parts.hour) catch |e|
panic("{any}\n", .{e});
const min = check_decimal(u6, parts.min) catch |e|
panic("{any}\n", .{e});
const sec = check_decimal(u6, parts.sec) catch |e|
panic("{any}\n", .{e});
const day = check_decimal(u5, parts.day) catch |e|
panic("{any}\n", .{e});
_ = std.fmt.bufPrint(&buf, "{s} {s} {s}:{s}:{s}", .{
mon,
day,
hr,
min,
sec,
}) catch |e| panic("{any}\n", .{e});
return buf;
}
and I changed these tests:
test "create time" {
var arena_allocator = heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena_allocator.deinit();
var io_threaded = threaded.init(
arena_allocator.allocator(),
.{ .environ = .empty },
);
defer io_threaded.deinit();
var threaded_io = io_threaded.io();
const tnow = Time.create(&threaded_io);
//TODO: use sdout
for (0..5) |_| {
print("{s}\n", .{tnow.now()});
print("{s}\n", .{tnow.nowSyslog()});
}
try std.testing.expect(tnow.now().len == 23);
try std.testing.expect(tnow.nowSyslog().len == 15);
}
test "day_with_prefix" {
const d: u5 = 5;
const day = try check_decimal(u5, d);
try std.testing.expectEqualSlices(u8, "05", &day);
}
test "day_with_no_prefix" {
const d: u5 = 15;
const day = try check_decimal(u5, d);
try std.testing.expectEqualSlices(u8, "15", &day);
}
Only thing I was not able to figure out was how to use stdout instead your print(), to remove the false positive that happens because of writing to stderr. At least that’s my understanding why false positive occurs.
Thanks would you like to raise a PR in the repo if you don’t mind
Alright, I’ll do it.
I wanted to write the changes here so others can see and comment on it first ![]()
Thanks we can work in your pr the stderr part mentioned is that okay
Yes, of course. I’m just setting up commitlint since you are using it then I’ll create a PR.
Thanks for your time and contribution I have merged it to main
Reminds me a bit of this topic:
@miagi Personally I think it would be better to use format functions and write the output directly to a writer, also I don’t like the baked in panic everywhere, I think it would be better to return the error and then let the user/call-site decide whether they want to panic. (And document that those functions could panic)
I think for application code it is fine to decide to panic, but in library code I would try to avoid it or at least keep the number of places that could panic lower, for example by having an error-ing version and another versions that panics and just wraps the error version (if it seems appropriate to provide a panic-ing one at all).
Also it makes sense to statically prove the absense of errors for certain inputs through exhaustive testing, because check_decimal(u6, ...) will never result in error.NoSpaceLeft, because it is impossible to provide a value that would cause that error, so basically I think it would make sense to use more specialized functions here that are proven via tests or assertions to not fail and thus don’t need to return an error. (Which also removes the need for the repeated catch panic)
Additionally there is already a digit2 function in the standard library so we can use that one when size is 2. So here is a sketch of what I am thinking of:
const std = @import("std");
fn safeDecimal(size: comptime_int, comptime T: type, in: T) [size]u8 {
const fmt = "{[value]d:0>[size]}";
comptime std.debug.assert(std.fmt.count(fmt, .{ .value = std.math.maxInt(T), .size = size }) <= size);
if (comptime size == 2) return std.fmt.digits2(in);
var b: [size]u8 = undefined;
_ = std.fmt.bufPrint(&b, fmt, .{ .value = in, .size = size }) catch unreachable;
return b;
}
fn testHelper(size: comptime_int, comptime T: type) void {
for (0..std.math.maxInt(T)) |i| {
_ = safeDecimal(size, T, @intCast(i));
}
}
test {
testHelper(1, u1);
testHelper(1, u2);
testHelper(1, u3);
testHelper(2, u4);
testHelper(2, u5);
testHelper(2, u6);
testHelper(3, u7);
testHelper(3, u8);
testHelper(3, u9);
testHelper(4, u10);
}
An additional improvement could be to calculate the highest type of all types that fit in a certain size and then use that one’s specialization for all in that size-group (to save on the number of generated type specializations and save on code size).
Thank you for this. You are fully correct.
When writing library code we should lower the places it can error as much as possible, and handle them. To absolve myself a bit, I wanted to write as little changes as possible to original code so I left those as it were.
I was using a C library in a logging function where I handled most of errors, then I saw this small library which caught my attention how easy it was to implement something in Zig instead of using C.
Yup, that safeDecimal() looks lovely. Instead of using two different functions that are similar, just write a single that is more generic.
I agrree on
Personally I think it would be better to use format functions and write the output directly to a writer, also I don’t like the baked in panic everywhere, I think it would be better to return the error and then let the user/call-site decide whether they want to panic. (And document that those functions could panic)
This part but the focus is to use it in log statements I.e std.log(now());
This is the reason panic was allowed in the lib.
I think I’ve figured out a way how to handle this better. I used a bit different approach. Since we don’t really care about the usize types being passed (I mean, do we?), I decided to use a limit that works for us humans. That is, the count of units in a specific unit. What I mean by that is, we all know how much seconds goes into a minute, how much milliseconds go into a second and how much minutes go in an hour, etc.
fn safeDecimal(comptime limit: u64, in: anytype) [std.fmt.comptimePrint("{d}", .{limit}).len]u8 {
// 1. Calculate the digit count of the limit at compile-time
const size = comptime std.fmt.comptimePrint("{d}", .{limit}).len;
var b: [size]u8 = undefined;
// Clamp any value that is larger to a limit
const value = @min(@as(u64, in), limit);
_ = std.fmt.bufPrint(&b, "{d:0>[1]}", .{ value, b.len }) catch unreachable;
return b;
}
Now unreachable really becomes unreachable, since we know the limits that go where:
pub fn now(self: Self) [23]u8 {
var buf: [23]u8 = undefined;
const timestamp = clock.now(
real_clock,
self.io.*,
) catch |e|
panic("failed to get clock:{any}\n", .{e});
const parts = getTimeParts(timestamp);
const mon = safeDecimal(12, parts.month_index);
const day_check = safeDecimal(31, parts.day);
const hr = safeDecimal(23, parts.hour);
const min = safeDecimal(59, parts.min);
const sec_padded = safeDecimal(59, parts.sec);
const m_sec = safeDecimal(999, parts.msec);
_ = std.fmt.bufPrint(&buf, "{d}-{s}-{s} {s}:{s}:{s}.{s}", .{
parts.year,
mon,
day_check,
hr,
min,
sec_padded,
m_sec,
}) catch |e| panic("error getting time:{any}\n", .{e});
return buf;
}
Maybe there is a better way to handle dynamic buffer size at comptime that I don’t know of
I like the idea of using std.Io.Timestamp as the basis for a date/time structure. Thanks for pointing me to std.time.epoch btw., I didn’t realize there was rd-to-date and date-to-rd functionality there.
Regarding the specific application of writing a timestamp to a logfile, what about defining a `format` method for your `Time` struct? So you could just do log.info(“{f} - something happened”, .{instance_of_Time}); I mean?
Addendum: From practical experience: I hate to be on a server, look at logfiles and have to guess if they’re in UTC or local time (yes not every server runs on UTC…).
So since you only using UTC as far as I can tell, I’d suggest to add a “Z” or a “+00:00” (both ISO8601-compliant) to the timestamp ![]()
I like the addendum part
Makes sense, yes. and read the system config to see what timesone it’s on.
I did not know as well that there are functions that already do all the calculations in epoch till I read OP’s showcase.
What do you mean by “I like the idea of using std.Io.Timestamp as the basis for a date/time structure.” ?
Adding a format, I’m trying to write something like this:
pub const Time = struct {
const Self = @This();
io: std.Io,
writer: *std.Io.Writer,
pub fn create(io: std.Io, writer: *std.Io.Writer) Time {
return Time{
.io = io,
.writer = writer,
};
}
pub fn now(self: Self) !void {
const timestamp = clock.now(
real_clock,
self.io,
) catch |e|
panic("failed to get clock:{any}\n", .{e});
const parts = getTimeParts(timestamp);
try self.writer.print("{d}-{:0>2}-{:0>2} {:0>2}:{:0>2}:{:0>2}.{:0>3}", .{
parts.year,
parts.month_index,
parts.day,
parts.hour,
parts.min,
parts.sec,
parts.msec,
});
}
pub fn syslog(self: Self) !void {
const timestamp = clock.now(
real_clock,
self.io,
) catch |e| {
panic("failed to get clock:{any}\n", .{e});
};
const parts = getTimeParts(timestamp);
try self.writer.print("{s} {:0>2} {:0>2}:{:0>2}:{:0>2}", .{
MONTH_NAMES[parts.month_index - 1],
parts.day,
parts.hour,
parts.min,
parts.sec,
});
}
};
That would remove the need for the helper functions but the thing that is currently bothering me is the use of stdout in a test. It seems when I try to do soemthing like this even:
test "Time struct formatting" {
var arena_allocator = heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena_allocator.deinit();
const alloc = arena_allocator.allocator();
// Setup IO (Required for clock.now)
var io_init = std.Io.Threaded.init(alloc, .{ .environ = .empty });
const io = io_init.io();
var stdout_init = std.Io.File.stdout().writer(io, &.{});
const stdout = &stdout_init.interface;
// Create the Time instance
const tnow = Time.create(io, stdout);
// Test 'now()'
var task_now = io.async(Time.now, .{tnow});
defer task_now.cancel(io) catch {};
try task_now.await(io);
try stdout.flush();
// Test 'syslog()'
var task_syslog = io.async(TIme.syslog, .{tnow});
defer task_syslog.cancel(io) catch {};
try task_syslog.await(io);
try stdout.flush();
}
zig build test just hangs.
I mean, Timestamp as defined currently in the std lib is just an instant relative to an epoch (which happens to be the Unix epoch but this is not super relevant). The moment in time is represented by the nanoseconds attribute of Timestamp (which btw. is not an accurate representation of time in a physical sense since in doesn’t represent leap seconds, but this is a different story). If you want “civil time”, i.e. a date & time in a specific calendar as you have it in the strings you use for the logging, you need to convert epoch time to date/time fields (what you call “parts” IIUC). So you have Timestamp as the basis, then add calendric calculations to get “civil time”, for instance in the Proleptic Gregorian calendar. And then you could combine the two to handle time zones.
I was thinking about the “special” formatfunction, like this one: Life that appeared from about. - which will automatically be called if a format string uses the “f” directive {f}.
Alright, I have a clearer picture now.
It seems that this code does use std.Io.Timestamp that gets from calling std.Io.clock.now() in nanoseconds. Then getTimeParts() converts all of that using functions from std.time.epoch.
Looking at the code now, I understood from real clock description it returns seconds, but looking at the Timestamp, it says nanoseconds. Thus this part:
const sec = std.Io.Timestamp.toSeconds(timestamp);
const sec_u: u64 = @intCast(sec);
Then it calculates remainder of seconds in last day, then from that it calculates a current hour, minute and lastly the remainder of seconds left from the start of the last minute.
Next is getEpochDay() to get number of days since epoch, and from that it calculates current year, month and the remainder of days. Adding + 1 to days_index to get human numbering after.
Regarding my formatting, I understood now what you meant. Passing a writer to it directly, not how I’m doing it. I can switch that easily. Next step would be to deal with all the timezone stuff as well.
Ok, This is now usiing {f} option:
pub const Time = struct {
const Self = @This();
io: std.Io,
pub fn create(io: std.Io) Time {
return Time{ .io = io };
}
/// Entry point for formatting. Use as: .{time.fmt(.now)}
pub fn fmt(self: Self, mode: FormatMode) Formatter {
return .{ .time = self, .mode = mode };
}
const Formatter = struct {
time: Time,
mode: FormatMode,
pub fn format(
self: Formatter,
writer: *std.Io.Writer,
) !void {
const timestamp = clock.now(
real_clock,
self.time.io,
) catch |e|
std.debug.panic("failed to get clock: {any}\n", .{e});
const parts = getTimeParts(timestamp);
switch (self.mode) {
.now => {
try writer.print("{d}-{:0>2}-{:0>2} {:0>2}:{:0>2}:{:0>2}.{:0>3}", .{
parts.year,
parts.month_index,
parts.day,
parts.hour,
parts.min,
parts.sec,
parts.msec,
});
},
.syslog => {
try writer.print("{s} {:0>2} {:0>2}:{:0>2}:{:0>2}", .{
MONTH_NAMES[parts.month_index - 1],
parts.day,
parts.hour,
parts.min,
parts.sec,
});
},
}
}
};
};