diff --git a/build.zig.zon b/build.zig.zon index 1bc2895..9aa49ce 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,7 +7,7 @@ .hash = "1220e5ede084ca6b94defd466a8f8779aab151d37bf688fefb928fded6f02cde4135", }, .args = .{ - .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/cli/build.zig.zon b/cli/build.zig.zon index f703560..66a91ad 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -5,7 +5,7 @@ .dependencies = .{ .args = .{ - .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/demo/src/main.zig b/demo/src/main.zig index 6ba3a19..bfd2fec 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const jetzig = @import("jetzig"); pub const routes = @import("routes").routes; +// Override default settings in jetzig.config here: pub const jetzig_options = struct { pub const middleware: []const type = &.{ // htmx middleware skips layouts when `HX-Target` header is present and issues @@ -10,6 +11,11 @@ pub const jetzig_options = struct { jetzig.middleware.HtmxMiddleware, @import("app/middleware/DemoMiddleware.zig"), }; + + pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); + pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); + pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); + pub const public_content_path = "public"; }; pub fn main() !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 9f782a9..39be497 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -35,13 +35,48 @@ pub const Data = data.Data; /// generate a `View`. pub const View = views.View; +const root = @import("root"); + +/// Global configuration. Override these values by defining in `src/main.zig` with: +/// ```zig +/// pub const jetzig_options = struct { +/// // ... +/// } +/// ``` +/// All constants defined below can be overridden. pub const config = struct { + /// Maximum bytes to allow in request body. pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); + + /// Maximum filesize for `public/` content. pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); + + /// Path relative to cwd() to serve public content from. Symlinks are not followed. + pub const public_content_path = "public"; + + /// Middleware chain. Add any custom middleware here, or use middleware provided in + /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). + pub const middleware = &.{}; + + /// HTTP buffer. Must be large enough to store all headers. This should typically not be + /// modified. pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); - pub const public_content = .{ .path = "public" }; + + /// Reconciles a configuration value from user-defined values and defaults provided by Jetzig. + pub fn get(T: type, comptime key: []const u8) T { + const self = @This(); + if (!@hasDecl(self, key)) @panic("Unknown config option: " ++ key); + + if (@hasDecl(root, "jetzig_options") and @hasDecl(root.jetzig_options, key)) { + return @field(root.jetzig_options, key); + } else { + return @field(self, key); + } + } }; +/// Initialize a new Jetzig app. Call this from `src/main.zig` and then call +/// `start(@import("routes").routes)` on the returned value. pub fn init(allocator: std.mem.Allocator) !App { const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 73f2d84..4b81fd5 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -12,17 +12,20 @@ const Options = struct { help: bool = false, bind: []const u8 = "127.0.0.1", port: u16 = 8080, - environment: []const u8 = "development", + // TODO: + // environment: []const u8 = "development", log: []const u8 = "-", @"log-error": []const u8 = "-", - @"log-level": jetzig.loggers.LogLevel = .DEBUG, + @"log-level": jetzig.loggers.LogLevel = .INFO, + @"log-format": jetzig.loggers.LogFormat = .development, detach: bool = false, pub const shorthands = .{ .h = "help", .b = "bind", .p = "port", - .e = "environment", + // TODO: + // .e = "environment", .d = "detach", }; @@ -32,13 +35,17 @@ const Options = struct { .option_docs = .{ .bind = "IP address/hostname to bind to (default: 127.0.0.1)", .port = "Port to listen on (default: 8080)", - .environment = "Load an environment configuration from src/app/environments/.zig", - .log = "Path to log file. Use '-' for stdout (default: -)", + // TODO: + // .environment = "Load an environment configuration from src/app/environments/.zig", + .log = "Path to log file. Use '-' for stdout (default: '-')", .@"log-error" = - \\Optional path to separate error log file. Use '-' for stdout. If omitted, errors are logged to the location specified by the `log` option. + \\Optional path to separate error log file. Use '-' for stderr. If omitted, errors are logged to the location specified by the `log` option (or stderr if `log` is '-'). , .@"log-level" = - \\Specify the minimum log level. Log events below the given level are ignored. Must be one of: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (default: DEBUG) + \\Minimum log level. Log events below the given level are ignored. Must be one of: { TRACE, DEBUG, INFO, WARN, ERROR, FATAL } (default: DEBUG) + , + .@"log-format" = + \\Output logs in the given format. Must be one of: { development, json } (default: development) , .detach = \\Run the server in the background. Must be used in conjunction with --log (default: false) @@ -62,11 +69,23 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { std.process.exit(0); } - var logger = jetzig.loggers.Logger{ - .development_logger = jetzig.loggers.DevelopmentLogger.init( - self.allocator, - try getLogFile(options.options.log), - ), + var logger = switch (options.options.@"log-format") { + .development => jetzig.loggers.Logger{ + .development_logger = jetzig.loggers.DevelopmentLogger.init( + self.allocator, + options.options.@"log-level", + try getLogFile(.stdout, options.options), + try getLogFile(.stderr, options.options), + ), + }, + .json => jetzig.loggers.Logger{ + .json_logger = jetzig.loggers.JsonLogger.init( + self.allocator, + options.options.@"log-level", + try getLogFile(.stdout, options.options), + try getLogFile(.stderr, options.options), + ), + }, }; if (options.options.detach and std.mem.eql(u8, options.options.log, "-")) { @@ -93,8 +112,16 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { }; } -fn getLogFile(path: []const u8) !std.fs.File { - if (std.mem.eql(u8, path, "-")) return std.io.getStdOut(); +fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File { + const path = switch (stream) { + .stdout => options.log, + .stderr => options.@"log-error", + }; + + if (std.mem.eql(u8, path, "-")) return switch (stream) { + .stdout => std.io.getStdOut(), + .stderr => std.io.getStdErr(), + }; const file = try std.fs.createFileAbsolute(path, .{ .truncate = false }); try file.seekFromEnd(0); diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 9992bfb..7aa0703 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -30,10 +30,12 @@ rendered: bool = false, redirected: bool = false, rendered_multiple: bool = false, rendered_view: ?jetzig.views.View = null, +start_time: i128, pub fn init( allocator: std.mem.Allocator, server: *jetzig.http.Server, + start_time: i128, std_http_request: std.http.Server.Request, response: *jetzig.http.Response, ) !Self { @@ -69,6 +71,7 @@ pub fn init( .query_data = query_data, .query = query, .std_http_request = std_http_request, + .start_time = start_time, }; } @@ -109,7 +112,7 @@ pub fn process(self: *Self) !void { }; const reader = try self.std_http_request.reader(); - self.body = try reader.readAllAlloc(self.allocator, jetzig.config.max_bytes_request_body); + self.body = try reader.readAllAlloc(self.allocator, jetzig.config.get(usize, "max_bytes_request_body")); self.processed = true; } @@ -310,7 +313,7 @@ pub fn hash(self: *Self) ![]const u8 { ); } -pub fn fmtMethod(self: *Self, colorized: bool) []const u8 { +pub fn fmtMethod(self: *const Self, colorized: bool) []const u8 { if (!colorized) return @tagName(self.method); return switch (self.method) { diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index c3e3bca..fe95207 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -3,13 +3,6 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); const zmpl = @import("zmpl"); -const root_file = @import("root"); - -pub const jetzig_server_options = if (@hasDecl(root_file, "jetzig_options")) - root_file.jetzig_options -else - struct {}; - pub const ServerOptions = struct { logger: jetzig.loggers.Logger, bind: []const u8, @@ -21,7 +14,6 @@ pub const ServerOptions = struct { allocator: std.mem.Allocator, logger: jetzig.loggers.Logger, options: ServerOptions, -start_time: i128 = undefined, routes: []*jetzig.views.Route, mime_map: *jetzig.http.mime.MimeMap, std_net_server: std.net.Server = undefined, @@ -69,7 +61,7 @@ fn processRequests(self: *Self) !void { const connection = try self.std_net_server.accept(); - var buf: [jetzig.config.http_buffer_size]u8 = undefined; + var buf: [jetzig.config.get(usize, "http_buffer_size")]u8 = undefined; var std_http_server = std.http.Server.init(connection, &buf); errdefer std_http_server.connection.stream.close(); @@ -86,13 +78,13 @@ fn processRequests(self: *Self) !void { } fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server: *std.http.Server) !void { - self.start_time = std.time.nanoTimestamp(); + const start_time = std.time.nanoTimestamp(); const std_http_request = try std_http_server.receiveHead(); if (std_http_server.state == .receiving_head) return error.JetzigParseHeadError; var response = try jetzig.http.Response.init(allocator); - var request = try jetzig.http.Request.init(allocator, self, std_http_request, &response); + var request = try jetzig.http.Request.init(allocator, self, start_time, std_http_request, &response); try request.process(); @@ -108,9 +100,7 @@ fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server try jetzig.http.middleware.afterResponse(&middleware_data, &request); jetzig.http.middleware.deinit(&middleware_data, &request); - const log_message = try self.requestLogMessage(&request); - defer self.allocator.free(log_message); - try self.logger.INFO("{s}", .{log_message}); + try self.logger.logRequest(&request); } fn renderResponse(self: *Self, request: *jetzig.http.Request) !void { @@ -327,34 +317,7 @@ fn logStackTrace( defer buf.deinit(); const writer = buf.writer(); try stack.format("", .{}, writer); - try self.logger.ERROR("{s}", .{buf.items}); -} - -fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { - const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { - inline else => |status_code| @unionInit( - jetzig.http.status_codes.TaggedStatusCode, - @tagName(status_code), - .{}, - ), - }; - - const formatted_duration = if (self.logger.isColorized()) - try jetzig.colors.duration(self.allocator, self.duration()) - else - try std.fmt.allocPrint(self.allocator, "{}", .{self.duration()}); - defer self.allocator.free(formatted_duration); - - return try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ - formatted_duration, - request.fmtMethod(self.logger.isColorized()), - status.format(self.logger.isColorized()), - request.path.path, - }); -} - -fn duration(self: *Self) i64 { - return @intCast(std.time.nanoTimestamp() - self.start_time); + try self.logger.ERROR("{s}\n", .{buf.items}); } fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?*jetzig.views.Route { @@ -395,7 +358,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour if (request.method != .GET) return null; var iterable_dir = std.fs.cwd().openDir( - jetzig.config.public_content.path, + jetzig.config.get([]const u8, "public_content_path"), .{ .iterate = true, .no_follow = true }, ) catch |err| { switch (err) { @@ -415,7 +378,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour const content = try iterable_dir.readFileAlloc( request.allocator, file.path, - jetzig.config.max_bytes_static_content, + jetzig.config.get(usize, "max_bytes_static_content"), ); const extension = std.fs.path.extension(file.path); const mime_type = if (self.mime_map.get(extension)) |mime| mime else "application/octet-stream"; @@ -447,7 +410,7 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { return static_dir.readFileAlloc( request.allocator, capture, - jetzig.config.max_bytes_static_content, + jetzig.config.get(usize, "max_bytes_static_content"), ) catch |err| { switch (err) { error.FileNotFound => return null, diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index 965b612..bf28cf3 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -1,12 +1,7 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const server_options = jetzig.http.Server.jetzig_server_options; - -const middlewares: []const type = if (@hasDecl(server_options, "middleware")) - server_options.middleware -else - &.{}; +const middlewares: []const type = jetzig.config.get([]const type, "middleware"); const MiddlewareData = std.BoundedArray(*anyopaque, middlewares.len); diff --git a/src/jetzig/http/status_codes.zig b/src/jetzig/http/status_codes.zig index f1e0008..5e78ad9 100644 --- a/src/jetzig/http/status_codes.zig +++ b/src/jetzig/http/status_codes.zig @@ -164,4 +164,10 @@ pub const TaggedStatusCode = union(StatusCode) { inline else => |capture| capture.format(colorized), }; } + + pub fn getCode(self: Self) []const u8 { + return switch (self) { + inline else => |capture| capture.code, + }; + } }; diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 367678d..67683a7 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -1,59 +1,64 @@ const std = @import("std"); +const jetzig = @import("../jetzig.zig"); + const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); +pub const JsonLogger = @import("loggers/JsonLogger.zig"); -pub const LogLevel = enum { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; +pub const LogLevel = enum(u4) { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; +pub const LogFormat = enum { development, json }; pub const Logger = union(enum) { development_logger: DevelopmentLogger, - - pub fn isColorized(self: Logger) bool { - switch (self) { - inline else => |logger| return logger.isColorized(), - } - } + json_logger: JsonLogger, /// Log a TRACE level message to the configured logger. pub fn TRACE(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.TRACE(message, args), + inline else => |*logger| try logger.log(.TRACE, message, args), } } /// Log a DEBUG level message to the configured logger. pub fn DEBUG(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.DEBUG(message, args), + inline else => |*logger| try logger.log(.DEBUG, message, args), } } /// Log an INFO level message to the configured logger. pub fn INFO(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.INFO(message, args), + inline else => |*logger| try logger.log(.INFO, message, args), } } /// Log a WARN level message to the configured logger. pub fn WARN(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.WARN(message, args), + inline else => |*logger| try logger.log(.WARN, message, args), } } /// Log an ERROR level message to the configured logger. pub fn ERROR(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.ERROR(message, args), + inline else => |*logger| try logger.log(.ERROR, message, args), } } /// Log a FATAL level message to the configured logger. pub fn FATAL(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*logger| try logger.FATAL(message, args), + inline else => |*logger| try logger.log(.FATAL, message, args), + } + } + + pub fn logRequest(self: *const Logger, request: *const jetzig.http.Request) !void { + switch (self.*) { + inline else => |*logger| try logger.logRequest(request), } } }; diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 038ae92..cd1ba18 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -2,64 +2,91 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const Self = @This(); +const DevelopmentLogger = @This(); const Timestamp = jetzig.types.Timestamp; const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, -file: std.fs.File, -colorized: bool, +stdout: std.fs.File, +stderr: std.fs.File, +stdout_colorized: bool, +stderr_colorized: bool, +level: LogLevel, -pub fn init(allocator: std.mem.Allocator, file: std.fs.File) Self { - return .{ .allocator = allocator, .file = file, .colorized = file.isTty() }; +/// Initialize a new Development Logger. +pub fn init( + allocator: std.mem.Allocator, + level: LogLevel, + stdout: std.fs.File, + stderr: std.fs.File, +) DevelopmentLogger { + return .{ + .allocator = allocator, + .level = level, + .stdout = stdout, + .stderr = stderr, + .stdout_colorized = stdout.isTty(), + .stderr_colorized = stderr.isTty(), + }; } -/// Return true if logger was initialized with colorization (i.e. if log file is a tty) -pub fn isColorized(self: Self) bool { - return self.colorized; -} +/// Generic log function, receives log level, message (format string), and args for format string. +pub fn log( + self: DevelopmentLogger, + comptime level: LogLevel, + comptime message: []const u8, + args: anytype, +) !void { + if (@intFromEnum(level) < @intFromEnum(self.level)) return; -/// Log a TRACE level message to the configured logger. -pub fn TRACE(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.DEBUG, message, args); -} - -/// Log a DEBUG level message to the configured logger. -pub fn DEBUG(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.DEBUG, message, args); -} - -/// Log an INFO level message to the configured logger. -pub fn INFO(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.INFO, message, args); -} - -/// Log a WARN level message to the configured logger. -pub fn WARN(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.WARN, message, args); -} - -/// Log an ERROR level message to the configured logger. -pub fn ERROR(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.ERROR, message, args); -} - -/// Log a FATAL level message to the configured logger. -pub fn FATAL(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.FATAL, message, args); -} - -pub fn log(self: Self, comptime level: LogLevel, comptime message: []const u8, args: anytype) !void { const output = try std.fmt.allocPrint(self.allocator, message, args); defer self.allocator.free(output); + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); const iso8601 = try timestamp.iso8601(); defer self.allocator.free(iso8601); - const writer = self.file.writer(); - const level_formatted = if (self.colorized) colorizedLogLevel(level) else @tagName(level); + + const colorized = switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout_colorized, + .WARN, .ERROR, .FATAL => self.stderr_colorized, + }; + const file = switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout, + .WARN, .ERROR, .FATAL => self.stderr, + }; + const writer = file.writer(); + const level_formatted = if (colorized) colorizedLogLevel(level) else @tagName(level); + try writer.print("{s: >5} [{s}] {s}\n", .{ level_formatted, iso8601, output }); - if (!self.file.isTty()) try self.file.sync(); + + if (!file.isTty()) try file.sync(); +} + +/// Log a one-liner including response status code, path, method, duration, etc. +pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) !void { + const formatted_duration = if (self.stdout_colorized) + try jetzig.colors.duration(self.allocator, jetzig.util.duration(request.start_time)) + else + try std.fmt.allocPrint(self.allocator, "{}", .{jetzig.util.duration(request.start_time)}); + defer self.allocator.free(formatted_duration); + + const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { + inline else => |status_code| @unionInit( + jetzig.http.status_codes.TaggedStatusCode, + @tagName(status_code), + .{}, + ), + }; + + const message = try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ + formatted_duration, + request.fmtMethod(self.stdout_colorized), + status.format(self.stdout_colorized), + request.path.path, + }); + defer self.allocator.free(message); + try self.log(.INFO, "{s}", .{message}); } fn colorizedLogLevel(comptime level: LogLevel) []const u8 { diff --git a/src/jetzig/loggers/JsonLogger.zig b/src/jetzig/loggers/JsonLogger.zig new file mode 100644 index 0000000..ae2600d --- /dev/null +++ b/src/jetzig/loggers/JsonLogger.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +const jetzig = @import("../../jetzig.zig"); + +const JsonLogger = @This(); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; +const LogMessage = struct { + level: []const u8, + timestamp: []const u8, + message: []const u8, +}; +const RequestLogMessage = struct { + level: []const u8, + timestamp: []const u8, + method: []const u8, + status: []const u8, + path: []const u8, + duration: i64, +}; + +allocator: std.mem.Allocator, +stdout: std.fs.File, +stderr: std.fs.File, +level: LogLevel, + +/// Initialize a new JSON Logger. +pub fn init( + allocator: std.mem.Allocator, + level: LogLevel, + stdout: std.fs.File, + stderr: std.fs.File, +) JsonLogger { + return .{ + .allocator = allocator, + .level = level, + .stdout = stdout, + .stderr = stderr, + }; +} + +/// Generic log function, receives log level, message (format string), and args for format string. +pub fn log( + self: JsonLogger, + comptime level: LogLevel, + comptime message: []const u8, + args: anytype, +) !void { + if (@intFromEnum(level) < @intFromEnum(self.level)) return; + + const output = try std.fmt.allocPrint(self.allocator, message, args); + defer self.allocator.free(output); + + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); + const iso8601 = try timestamp.iso8601(); + defer self.allocator.free(iso8601); + + const file = self.getFile(level); + const writer = file.writer(); + const log_message = LogMessage{ .level = @tagName(level), .timestamp = iso8601, .message = output }; + + const json = try std.json.stringifyAlloc(self.allocator, log_message, .{ .whitespace = .minified }); + defer self.allocator.free(json); + + try writer.writeAll(json); + try writer.writeByte('\n'); + + if (!file.isTty()) try file.sync(); // Make configurable ? +} + +/// Log a one-liner including response status code, path, method, duration, etc. +pub fn logRequest(self: JsonLogger, request: *const jetzig.http.Request) !void { + const level: LogLevel = .INFO; + + const duration = jetzig.util.duration(request.start_time); + + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); + const iso8601 = try timestamp.iso8601(); + defer self.allocator.free(iso8601); + + const status = switch (request.response.status_code) { + inline else => |status_code| @unionInit( + jetzig.http.status_codes.TaggedStatusCode, + @tagName(status_code), + .{}, + ), + }; + const message = RequestLogMessage{ + .level = @tagName(level), + .timestamp = iso8601, + .method = @tagName(request.method), + .status = status.getCode(), + .path = request.path.path, + .duration = duration, + }; + const json = try std.json.stringifyAlloc(self.allocator, message, .{ .whitespace = .minified }); + defer self.allocator.free(json); + + const file = self.getFile(level); + const writer = file.writer(); + + try writer.writeAll(json); + try writer.writeByte('\n'); + + if (!file.isTty()) try file.sync(); // Make configurable ? +} + +fn getFile(self: JsonLogger, level: LogLevel) std.fs.File { + return switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout, + .WARN, .ERROR, .FATAL => self.stderr, + }; +} diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index 0301b75..6e40ada 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -44,3 +44,8 @@ pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const return try allocator.dupe(u8, &secret); } + +/// Calculate a duration from a given start time (in nanoseconds) to the current time. +pub fn duration(start_time: i128) i64 { + return @intCast(std.time.nanoTimestamp() - start_time); +}