Format timestamp into ISO 8601 strings

I’m surprised that it’s not in the standard library. I know of a few Zig datetime libraries but sometimes all you want is to dump the current time as ISO 8601. So, I wanted to share this snippet in case anyone was interested. Look for TimeParts below for the formatting code.

Features:

  • Format a millisecond timestamp from std.time.milliTimestamp().
  • No allocation.
  • A packed struct of only 48 bits yet still loosely aligned on 8-bit byte boundary.
  • Can format geographical timezones (i.e., merely shifting some hours).
  • Easy to extend to support nanoseconds or more use cases.

I also build a crude logger on top of it.

const std = @import("std");

pub threadlocal var context: LogContext = .{};

/// When targeting at the "Windows" subsystem, the Windows OS will not create a
/// console for us, and thus the default console logging would not work. We
/// have to implement a custom logging function anyway.
///
/// Ref: `build.zig`
pub fn log(
    comptime level: std.log.Level,
    comptime scope: @TypeOf(.EnumLiteral),
    comptime format: []const u8,
    args: anytype,
) void {
    context.log(level, scope, format, args) catch |e| {
        std.log.defaultLog(.err, .log, "Failed to log {any}", .{.{
            .level = level,
            .scope = scope,
            .format = format,
            .args = args,
            .reason = e,
        }});
    };
}

const timezone_shift = -5; // -05:00

pub const LogContext = struct {
    file: ?std.fs.File = null,
    log_console: bool = true,

    pub fn log(
        me: @This(),
        comptime level: std.log.Level,
        comptime scope: @TypeOf(.EnumLiteral),
        comptime fmt: []const u8,
        args: anytype,
    ) !void {
        const now: u64 = @intCast(std.time.milliTimestamp());
        const shifted = now + timezone_shift * 1000 * 60 * 60;
        const time = TimeParts.fromMsTimestamp(shifted);

        const level_tag = switch (level) {
            .debug => "[DEBUG] ",
            .info => "[INFO] ",
            .warn => "[WARNING] ",
            .err => "[ERROR] ",
        };
        const scope_tag = if (scope == .default) "" else "(" ++ @tagName(scope) ++ ") ";

        const real_fmt = "{} " ++ level_tag ++ scope_tag ++ fmt ++ "\n";
        const real_args = .{time} ++ args;

        if (me.log_console) std.debug.print(real_fmt, real_args);
        if (me.file) |file| try std.fmt.format(file.writer(), real_fmt, real_args);
    }
};

const TimeParts = packed struct {
    year: u12,
    month: u4, // 16
    day: u5,
    hour: u5,
    minute: u6, // 32
    second: u6,
    millisecond: u10, // 48

    /// Not applicable to time previous to epoch.
    ///
    /// Ported from: https://stackoverflow.com/a/11197532/12185226
    pub fn fromMsTimestamp(timestamp: u64) @This() {
        const ms = timestamp % 1000;

        // Re-bias from 1970 to 1601:
        // 1970 - 1601 = 369 = 3*100 + 17*4 + 1 years (incl. 89 leap days) =
        // (3*100*(365+24/100) + 17*4*(365+1/4) + 1*365)*24*3600 seconds
        var sec: u64 = (timestamp / 1000) + 11644473600;

        // Remove multiples of 400 years (incl. 97 leap days)
        const quadricentennials: u64 = sec / 12622780800; // 400*365.2425*24*3600
        sec %= 12622780800;

        // Remove multiples of 100 years (incl. 24 leap days), can't be more than 3
        // (because multiples of 4*100=400 years (incl. leap days) have been removed)
        const centennials: u64 = @min(3, sec / 3155673600); // 100*(365+24/100)*24*3600
        sec -= centennials * 3155673600;

        // Remove multiples of 4 years (incl. 1 leap day), can't be more than 24
        // (because multiples of 25*4=100 years (incl. leap days) have been removed)
        const quadrennials: u64 = @min(24, sec / 126230400); // 4*(365+1/4)*24*3600
        sec -= quadrennials * 126230400;

        // Remove multiples of years (incl. 0 leap days), can't be more than 3
        // (because multiples of 4 years (incl. leap days) have been removed)
        const annuals: u64 = @min(3, sec / 31536000); // 365*24*3600
        sec -= annuals * 31536000;

        const year = 1601 + quadricentennials * 400 + centennials * 100 + quadrennials * 4 + annuals;
        const leap = (year % 4 == 0) and (year % 100 != 0 or (year % 400 == 0));

        const yday = sec / 86400;
        sec %= 86400;

        const hour = sec / 3600;
        sec %= 3600;

        const minute = sec / 60;
        sec %= 60;

        const mday_list: [12]u9 = if (leap)
            .{ 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366 }
        else
            .{ 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 };
        const month = for (mday_list, 0..) |x, i| {
            if (yday < x) break i;
        } else unreachable;
        const mday = if (month == 0) yday else yday - mday_list[month - 1];

        return .{
            .year = @intCast(year),
            .month = @intCast(month),
            .day = @intCast(mday),
            .hour = @intCast(hour),
            .minute = @intCast(minute),
            .second = @intCast(sec),
            .millisecond = @intCast(ms),
        };
    }

    /// Format into an ISO 8601 time string. Example: 2024-12-31T23:59:59.999Z
    pub fn format(me: @This(), comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void {
        _ = fmt;

        var tz_buffer: [6]u8 = undefined;
        var tz: []const u8 = "Z";
        if (timezone_shift != 0) {
            tz = try std.fmt.bufPrint(&tz_buffer, "{c}{:0>2}:00", .{
                if (timezone_shift > 0) '+' else '-',
                timezone_shift,
            });
        }

        var buffer: [30]u8 = undefined;
        const slice = try std.fmt.bufPrint(
            &buffer,
            "{:0>4}-{:0>2}-{:0>2}T{:0>2}:{:0>2}:{:0>2},{:0>3}{s}",
            .{ me.year, me.month + 1, me.day + 1, me.hour, me.minute, me.second, me.millisecond, tz },
        );
        try std.fmt.formatBuf(slice, options, writer);
    }
};

3 Likes

related: seems like there will be another attempt to get some date/time functionality into the std lib. That might include ISO format output, but will definitively have to include Unix time to field-based datetime conversion.

Interesting to see threadlocal btw., plus the logging implementation. Could you elaborate a bit why you implemented this?