BazerUtils.jl

Assorted Julia utilities including custom logging
Log | Files | Refs | README | LICENSE

CustomLogger.jl (26653B)


      1 # ==================================================================================================
      2 # CustomLogger.jl — Custom multi-sink logger with per-level filtering and pluggable formats
      3 # ==================================================================================================
      4 
      5 
      6 # --- Format types (multiple dispatch instead of if/elseif) ---
      7 
      8 abstract type LogFormat end
      9 struct PrettyFormat <: LogFormat end
     10 struct OnelineFormat <: LogFormat end
     11 struct SyslogFormat <: LogFormat end
     12 struct JsonFormat <: LogFormat end
     13 struct LogfmtFormat <: LogFormat end
     14 struct Log4jStandardFormat <: LogFormat end
     15 
     16 const VALID_FORMATS = "Valid options: :pretty, :oneline, :syslog, :json, :logfmt, :log4j_standard"
     17 
     18 """
     19     resolve_format(s::Symbol) -> LogFormat
     20 
     21 Map a format symbol to its LogFormat type. `:log4j` is a deprecated alias for `:oneline`.
     22 """
     23 # TODO (March 2027): Remove :log4j alias for :oneline. Rename :log4j_standard to :log4j.
     24 # This is a breaking change requiring a major version bump.
     25 function resolve_format(s::Symbol)::LogFormat
     26     s === :pretty && return PrettyFormat()
     27     s === :oneline && return OnelineFormat()
     28     s === :log4j && (Base.depwarn(
     29         ":log4j is deprecated, use :oneline for single-line format or :log4j_standard for Apache Log4j format. :log4j will be removed in a future major version.",
     30         :log4j); return OnelineFormat())
     31     s === :syslog && return SyslogFormat()
     32     s === :json && return JsonFormat()
     33     s === :logfmt && return LogfmtFormat()
     34     s === :log4j_standard && return Log4jStandardFormat()
     35     throw(ArgumentError("Unknown log_format: :$s. $VALID_FORMATS"))
     36 end
     37 
     38 
     39 # --- Helper functions ---
     40 
     41 """
     42     get_module_name(mod) -> String
     43 
     44 Extract module name as a string, returning "unknown" for `nothing`.
     45 """
     46 get_module_name(mod::Module) = string(nameof(mod))
     47 get_module_name(::Nothing) = "unknown"
     48 
     49 """
     50     reformat_msg(log_record; displaysize=(50,100)) -> String
     51 
     52 Convert log record message to a string. Strings pass through; other types
     53 are rendered via `show` with display size limits.
     54 """
     55 function reformat_msg(log_record; displaysize::Tuple{Int,Int}=(50,100))::String
     56     msg = log_record.message
     57     msg isa AbstractString && return String(msg)
     58     buf = IOBuffer()
     59     show(IOContext(buf, :limit => true, :compact => true, :displaysize => displaysize),
     60          "text/plain", msg)
     61     return String(take!(buf))
     62 end
     63 
     64 """
     65     msg_to_singleline(message::AbstractString) -> String
     66 
     67 Collapse a multi-line message to a single line, using ` | ` as separator.
     68 """
     69 function msg_to_singleline(message::AbstractString)::String
     70     message |>
     71         str -> replace(str, r"\"\"\"[\r\n\s]*(.+?)[\r\n\s]*\"\"\""s => s"\1") |>
     72         str -> replace(str, r"\n\s*" => " | ") |>
     73         str -> replace(str, r"\|\s*\|" => "|") |>
     74         str -> replace(str, r"\s*\|\s*" => " | ") |>
     75         str -> replace(str, r"\|\s*$" => "") |>
     76         strip |> String
     77 end
     78 
     79 """
     80     json_escape(s::AbstractString) -> String
     81 
     82 Escape a string for inclusion in a JSON value (without surrounding quotes).
     83 """
     84 function json_escape(s::AbstractString)::String
     85     s = replace(s, '\\' => "\\\\")
     86     s = replace(s, '"' => "\\\"")
     87     s = replace(s, '\n' => "\\n")
     88     s = replace(s, '\r' => "\\r")
     89     s = replace(s, '\t' => "\\t")
     90     return s
     91 end
     92 
     93 """
     94     logfmt_escape(s::AbstractString) -> String
     95 
     96 Format a value for logfmt output. Quotes the value if it contains spaces, equals, or quotes.
     97 """
     98 function logfmt_escape(s::AbstractString)::String
     99     needs_quoting = contains(s, ' ') || contains(s, '"') || contains(s, '=')
    100     if needs_quoting
    101         return "\"" * replace(s, '"' => "\\\"") * "\""
    102     end
    103     return s
    104 end
    105 
    106 
    107 # --- LogSink infrastructure ---
    108 
    109 abstract type LogSink end
    110 
    111 # Keep the active sink alive so the finalizer does not close it prematurely
    112 # while the global logger is still writing to its IO handles.
    113 const _active_sink = Ref{Union{Nothing, LogSink}}(nothing)
    114 
    115 """
    116     get_log_filenames(filename; file_loggers, create_files) -> Vector{String}
    117 
    118 Generate log file paths. When `create_files=true`, creates `filename_level.log` per level.
    119 When `false`, repeats `filename` for all levels.
    120 """
    121 function get_log_filenames(filename::AbstractString;
    122         file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug],
    123         create_files::Bool=false)
    124     if create_files
    125         return [string(filename, "_", string(f), ".log") for f in file_loggers]
    126     else
    127         return repeat([filename], length(file_loggers))
    128     end
    129 end
    130 
    131 function get_log_filenames(files::Vector{<:AbstractString};
    132         file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug])
    133     n = length(file_loggers)
    134     length(files) != n && throw(ArgumentError(
    135         "Expected exactly $n file paths (one per logger: $(join(file_loggers, ", "))), got $(length(files))"))
    136     return files
    137 end
    138 
    139 """
    140     FileSink <: LogSink
    141 
    142 File-based log sink with per-stream locking for thread safety.
    143 
    144 When all files point to the same path (single-file mode), IO handles and locks are
    145 deduplicated — one IO and one lock shared across all slots.
    146 """
    147 mutable struct FileSink <: LogSink
    148     files::Vector{String}
    149     ios::Vector{IO}
    150     locks::Vector{ReentrantLock}
    151 
    152     function FileSink(filename::AbstractString;
    153             file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug],
    154             create_files::Bool=false)
    155         files = get_log_filenames(filename; file_loggers=file_loggers, create_files=create_files)
    156         if create_files
    157             @info "Creating $(length(files)) log files:\n$(join(string.(" \u2B91 ", files), "\n"))"
    158         else
    159             @info "Single log sink: all levels writing to $filename"
    160         end
    161         # Deduplicate: open each unique path once, share IO + lock
    162         unique_paths = unique(files)
    163         path_to_io = Dict(p => open(p, "a") for p in unique_paths)
    164         path_to_lock = Dict(p => ReentrantLock() for p in unique_paths)
    165         ios = [path_to_io[f] for f in files]
    166         locks = [path_to_lock[f] for f in files]
    167         obj = new(files, ios, locks)
    168         finalizer(close, obj)
    169         return obj
    170     end
    171 
    172     function FileSink(files::Vector{<:AbstractString};
    173             file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug])
    174         actual_files = get_log_filenames(files; file_loggers=file_loggers)
    175         unique_paths = unique(actual_files)
    176         path_to_io = Dict(p => open(p, "a") for p in unique_paths)
    177         path_to_lock = Dict(p => ReentrantLock() for p in unique_paths)
    178         ios = [path_to_io[f] for f in actual_files]
    179         locks = [path_to_lock[f] for f in actual_files]
    180         obj = new(actual_files, ios, locks)
    181         finalizer(close, obj)
    182         return obj
    183     end
    184 end
    185 
    186 function Base.close(sink::FileSink)
    187     for io in unique(sink.ios)
    188         io !== stdout && io !== stderr && isopen(io) && close(io)
    189     end
    190 end
    191 # --------------------------------------------------------------------------------------------------
    192 
    193 
    194 # ==================================================================================================
    195 # custom_format — dispatch hub. Called by FormatLogger callbacks.
    196 # ==================================================================================================
    197 
    198 """
    199     custom_format(io, fmt::LogFormat, log_record::NamedTuple; kwargs...)
    200 
    201 Format and write a log record to `io` using the given format. Generates a single
    202 timestamp and delegates to the appropriate `format_log` method.
    203 """
    204 function custom_format(io, fmt::LogFormat, log_record::NamedTuple;
    205         displaysize::Tuple{Int,Int}=(50,100),
    206         log_date_format::AbstractString="yyyy-mm-dd",
    207         log_time_format::AbstractString="HH:MM:SS",
    208         shorten_path::Symbol=:relative_path)
    209 
    210     timestamp = now()
    211     format_log(io, fmt, log_record, timestamp;
    212         displaysize=displaysize,
    213         log_date_format=log_date_format,
    214         log_time_format=log_time_format,
    215         shorten_path=shorten_path)
    216 end
    217 
    218 
    219 # ==================================================================================================
    220 # create_demux_logger — builds the TeeLogger pipeline
    221 # ==================================================================================================
    222 
    223 function create_demux_logger(sink::FileSink,
    224         file_loggers::Vector{Symbol},
    225         module_absolute_message_filter,
    226         module_specific_message_filter,
    227         fmt_file::LogFormat,
    228         fmt_stdout::LogFormat,
    229         format_kwargs::NamedTuple;
    230         cascading_loglevels::Bool=false)
    231 
    232     logger_configs = Dict(
    233         :error => (module_absolute_message_filter, Logging.Error),
    234         :warn  => (module_absolute_message_filter, Logging.Warn),
    235         :info  => (module_specific_message_filter, Logging.Info),
    236         :debug => (module_absolute_message_filter, Logging.Debug)
    237     )
    238 
    239     logger_list = []
    240 
    241     for (io_index, logger_key) in enumerate(file_loggers)
    242         if !haskey(logger_configs, logger_key)
    243             @warn "Unknown logger type: $logger_key — skipping"
    244             continue
    245         end
    246         if io_index > length(sink.ios)
    247             error("Not enough IO streams in sink for logger: $logger_key")
    248         end
    249 
    250         message_filter, log_level = logger_configs[logger_key]
    251         io = sink.ios[io_index]
    252         lk = sink.locks[io_index]
    253 
    254         # Thread-safe format callback
    255         format_cb = (cb_io, log_record) -> lock(lk) do
    256             custom_format(cb_io, fmt_file, log_record; format_kwargs...)
    257         end
    258 
    259         inner = EarlyFilteredLogger(message_filter, FormatLogger(format_cb, io))
    260 
    261         if cascading_loglevels
    262             # Old behavior: MinLevelLogger catches this level and above
    263             push!(logger_list, MinLevelLogger(inner, log_level))
    264         else
    265             # New behavior: exact level only
    266             exact_filter = log -> log.level == log_level
    267             push!(logger_list, EarlyFilteredLogger(exact_filter, inner))
    268         end
    269     end
    270 
    271     # Stdout logger — always Info+, uses specific module filter, no file locking
    272     stdout_format_cb = (io, log_record) -> custom_format(io, fmt_stdout, log_record;
    273         format_kwargs...)
    274     stdout_logger = MinLevelLogger(
    275         EarlyFilteredLogger(module_specific_message_filter,
    276             FormatLogger(stdout_format_cb, stdout)),
    277         Logging.Info)
    278     push!(logger_list, stdout_logger)
    279 
    280     return TeeLogger(logger_list...)
    281 end
    282 
    283 
    284 # ==================================================================================================
    285 # custom_logger — public API
    286 # ==================================================================================================
    287 
    288 """
    289     custom_logger(filename; kw...)
    290 
    291 Set up a custom global logger with per-level file output, module filtering, and configurable formatting.
    292 
    293 When `create_log_files=true`, creates one log file per level (e.g. `filename_error.log`).
    294 Otherwise all levels write to the same file.
    295 
    296 # Arguments
    297 - `filename::AbstractString`: base name for the log files
    298 - `filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from stdout and info-level file logs
    299 - `filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from all logs
    300 - `file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug]`: which levels to capture
    301 - `log_date_format::AbstractString="yyyy-mm-dd"`: date format in timestamps
    302 - `log_time_format::AbstractString="HH:MM:SS"`: time format in timestamps
    303 - `displaysize::Tuple{Int,Int}=(50,100)`: display size for non-string messages
    304 - `log_format::Symbol=:oneline`: file log format (`:pretty`, `:oneline`, `:syslog`, `:json`, `:logfmt`, `:log4j_standard`)
    305 - `log_format_stdout::Symbol=:pretty`: stdout format (same options)
    306 - `shorten_path::Symbol=:relative_path`: path shortening strategy (`:oneline` format only)
    307 - `cascading_loglevels::Bool=false`: when `true`, each file captures its level and above; when `false`, each file captures only its exact level
    308 - `create_log_files::Bool=false`: create separate files per level
    309 - `overwrite::Bool=false`: overwrite existing log files
    310 - `create_dir::Bool=false`: create log directory if missing
    311 - `verbose::Bool=false`: warn about filtering non-imported modules
    312 
    313 # Example
    314 ```julia
    315 custom_logger("/tmp/myapp";
    316     filtered_modules_all=[:HTTP, :TranscodingStreams],
    317     create_log_files=true,
    318     overwrite=true,
    319     log_format=:oneline)
    320 ```
    321 """
    322 function custom_logger(
    323         sink::LogSink;
    324         filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing,
    325         filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing,
    326         file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug],
    327         log_date_format::AbstractString="yyyy-mm-dd",
    328         log_time_format::AbstractString="HH:MM:SS",
    329         displaysize::Tuple{Int,Int}=(50,100),
    330         log_format::Symbol=:oneline,
    331         log_format_stdout::Symbol=:pretty,
    332         shorten_path::Symbol=:relative_path,
    333         cascading_loglevels::Bool=false,
    334         verbose::Bool=false)
    335 
    336     # Resolve format types (validates symbols, handles :log4j deprecation)
    337     fmt_file = resolve_format(log_format)
    338     fmt_stdout = resolve_format(log_format_stdout)
    339 
    340     # Normalize file_loggers to Vector
    341     file_loggers_vec = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers)
    342 
    343     # Warn about filtering non-imported modules
    344     if verbose
    345         imported_modules = filter(
    346             x -> isdefined(Main, x) && typeof(getfield(Main, x)) <: Module && x !== :Main,
    347             names(Main, imported=true))
    348         all_filters = Symbol[x for x in unique(vcat(
    349             something(filtered_modules_specific, Symbol[]),
    350             something(filtered_modules_all, Symbol[]))) if !isnothing(x)]
    351         if !isempty(all_filters)
    352             missing_mods = filter(x -> x ∉ imported_modules, all_filters)
    353             if !isempty(missing_mods)
    354                 @warn "Filtering non-imported modules: $(join(string.(missing_mods), ", "))"
    355             end
    356         end
    357     end
    358 
    359     # Module filters
    360     module_absolute_filter = create_module_filter(filtered_modules_all)
    361     module_specific_filter = create_module_filter(filtered_modules_specific)
    362 
    363     format_kwargs = (displaysize=displaysize,
    364                      log_date_format=log_date_format,
    365                      log_time_format=log_time_format,
    366                      shorten_path=shorten_path)
    367 
    368     demux = create_demux_logger(sink, file_loggers_vec,
    369         module_absolute_filter, module_specific_filter,
    370         fmt_file, fmt_stdout, format_kwargs;
    371         cascading_loglevels=cascading_loglevels)
    372 
    373     # Keep sink alive to prevent GC from closing IO handles
    374     _active_sink[] = sink
    375 
    376     global_logger(demux)
    377     return demux
    378 end
    379 
    380 """
    381     create_module_filter(modules) -> Function
    382 
    383 Return a filter function that drops log messages from the specified modules.
    384 Uses `startswith` to catch submodules (e.g. `:HTTP` catches `HTTP.ConnectionPool`).
    385 """
    386 function create_module_filter(modules)
    387     return function(log)
    388         isnothing(modules) && return true
    389         mod = string(log._module)
    390         for m in modules
    391             startswith(mod, string(m)) && return false
    392         end
    393         return true
    394     end
    395 end
    396 
    397 # Convenience constructor: filename or vector of filenames
    398 function custom_logger(
    399         filename::Union{AbstractString, Vector{<:AbstractString}};
    400         create_log_files::Bool=false,
    401         overwrite::Bool=false,
    402         create_dir::Bool=false,
    403         file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug],
    404         kwargs...)
    405 
    406     file_loggers_array = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers)
    407 
    408     files = if filename isa AbstractString
    409         get_log_filenames(filename; file_loggers=file_loggers_array, create_files=create_log_files)
    410     else
    411         get_log_filenames(filename; file_loggers=file_loggers_array)
    412     end
    413 
    414     # Create directories if needed
    415     log_dirs = unique(dirname.(files))
    416     missing_dirs = filter(d -> !isempty(d) && !isdir(d), log_dirs)
    417     if !isempty(missing_dirs)
    418         if create_dir
    419             @warn "Creating log directories: $(join(missing_dirs, ", "))"
    420             mkpath.(missing_dirs)
    421         else
    422             @error "Log directories do not exist: $(join(missing_dirs, ", "))"
    423         end
    424     end
    425 
    426     overwrite && foreach(f -> rm(f, force=true), unique(files))
    427 
    428     sink = if filename isa AbstractString
    429         FileSink(filename; file_loggers=file_loggers_array, create_files=create_log_files)
    430     else
    431         FileSink(filename; file_loggers=file_loggers_array)
    432     end
    433 
    434     custom_logger(sink; file_loggers=file_loggers, kwargs...)
    435 end
    436 
    437 # Convenience for batch/script mode
    438 function custom_logger(; kwargs...)
    439     if !isempty(PROGRAM_FILE)
    440         logbase = splitext(abspath(PROGRAM_FILE))[1]
    441         custom_logger(logbase; kwargs...)
    442     else
    443         @error "custom_logger() with no arguments requires a script context (PROGRAM_FILE is empty in the REPL)"
    444     end
    445 end
    446 
    447 
    448 # --- Helper: colors for pretty format ---
    449 
    450 function get_color(level)
    451     RESET = "\033[0m"
    452     BOLD = "\033[1m"
    453     LIGHT_BLUE = "\033[94m"
    454     RED = "\033[31m"
    455     GREEN = "\033[32m"
    456     YELLOW = "\033[33m"
    457 
    458     return level == Logging.Debug ? LIGHT_BLUE :
    459            level == Logging.Info  ? GREEN :
    460            level == Logging.Warn  ? "$YELLOW$BOLD" :
    461            level == Logging.Error ? "$RED$BOLD" :
    462            RESET
    463 end
    464 
    465 
    466 # --------------------------------------------------------------------------------------------------
    467 """
    468     shorten_path_str(path::AbstractString; max_length::Int=40, strategy::Symbol=:truncate_middle)
    469 
    470 Shorten a file path string to a specified maximum length using various strategies.
    471 
    472 # Arguments
    473 - `path::AbstractString`: The input path to be shortened
    474 - `max_length::Int=40`: Maximum desired length of the output path
    475 - `strategy::Symbol=:truncate_middle`: Strategy to use for shortening. Options:
    476   * `:no`: Return path unchanged
    477   * `:truncate_middle`: Truncate middle of path components while preserving start/end
    478   * `:truncate_to_last`: Keep only the last n components of the path
    479   * `:truncate_from_right`: Progressively remove characters from right side of components
    480   * `:truncate_to_unique`: Reduce components to unique prefixes
    481 
    482 # Returns
    483 - `String`: The shortened path
    484 
    485 # Examples
    486 ```julia
    487 # Using different strategies
    488 julia> shorten_path_str("/very/long/path/to/file.txt", max_length=20)
    489 "/very/…/path/to/file.txt"
    490 
    491 julia> shorten_path_str("/usr/local/bin/program", strategy=:truncate_to_last, max_length=20)
    492 "/bin/program"
    493 
    494 julia> shorten_path_str("/home/user/documents/very_long_filename.txt", strategy=:truncate_middle)
    495 "/home/user/doc…ents/very_…name.txt"
    496 ```
    497 """
    498 function shorten_path_str(path::AbstractString;
    499     max_length::Int=40,
    500     strategy::Symbol=:truncate_middle
    501     )::AbstractString
    502 
    503     if strategy == :no
    504         return path
    505     elseif strategy == :relative_path
    506         return "./" * relpath(path, pwd())
    507     end
    508 
    509     # Return early if path is already short enough
    510     if length(path) ≤ max_length
    511         return path
    512     end
    513 
    514     # Split path into components
    515     parts = split(path, '/')
    516     is_absolute = startswith(path, '/')
    517 
    518     # Handle empty path or root directory
    519     if isempty(parts) || (length(parts) == 1 && isempty(parts[1]))
    520         return is_absolute ? "/" : ""
    521     end
    522 
    523     # Remove empty strings from split
    524     parts = filter(!isempty, parts)
    525 
    526     if strategy == :truncate_to_last
    527         # Keep only the last few components
    528         n = 2  # number of components to keep
    529         if length(parts) > n
    530             shortened = parts[end-n+1:end]
    531             result = join(shortened, "/")
    532             return is_absolute ? "/$result" : result
    533         end
    534 
    535     elseif strategy == :truncate_middle
    536         # For each component, truncate the middle if it's too long
    537         function shorten_component(comp::AbstractString; max_comp_len::Int=10)
    538             if length(comp) ≤ max_comp_len
    539                 return comp
    540             end
    541             keep = max_comp_len ÷ 2 - 1
    542             return string(comp[1:keep], "…", comp[end-keep+1:end])
    543         end
    544 
    545         shortened = map(p -> shorten_component(p), parts)
    546         result = join(shortened, "/")
    547         if length(result) > max_length
    548             # If still too long, drop some middle directories
    549             middle_start = length(parts) ÷ 3
    550             middle_end = 2 * length(parts) ÷ 3
    551             shortened = [parts[1:middle_start]..., "…", parts[middle_end:end]...]
    552             result = join(shortened, "/")
    553         end
    554         return is_absolute ? "/$result" : result
    555 
    556     elseif strategy == :truncate_from_right
    557         # Start removing characters from right side of each component
    558         shortened = copy(parts)
    559         while join(shortened, "/") |> length > max_length && any(length.(shortened) .> 3)
    560             # Find longest component
    561             idx = argmax(length.(shortened))
    562             if length(shortened[idx]) > 3
    563                 shortened[idx] = shortened[idx][1:end-1]
    564             end
    565         end
    566         result = join(shortened, "/")
    567         return is_absolute ? "/$result" : result
    568 
    569     elseif strategy == :truncate_to_unique
    570         # Simplified unique prefix strategy
    571         function unique_prefix(str::AbstractString, others::Vector{String}; min_len::Int=1)
    572             for len in min_len:length(str)
    573                 prefix = str[1:len]
    574                 if !any(s -> s != str && startswith(s, prefix), others)
    575                     return prefix
    576                 end
    577             end
    578             return str
    579         end
    580 
    581         # Get unique prefixes for each component
    582         shortened = String[]
    583         for (i, part) in enumerate(parts)
    584             if i == 1 || i == length(parts)
    585                 push!(shortened, part)
    586             else
    587                 prefix = unique_prefix(part, String.(parts))
    588                 push!(shortened, prefix)
    589             end
    590         end
    591 
    592         result = join(shortened, "/")
    593         return is_absolute ? "/$result" : result
    594     end
    595 
    596     # Default fallback: return truncated original path
    597     return string(path[1:max_length-3], "…")
    598 end
    599 # --------------------------------------------------------------------------------------------------
    600 
    601 
    602 # --- Constants for format_log methods ---
    603 
    604 const SYSLOG_SEVERITY = Dict(
    605     Logging.Info  => 6,
    606     Logging.Warn  => 4,
    607     Logging.Error => 3,
    608     Logging.Debug => 7
    609 )
    610 
    611 const JULIA_BIN = Base.julia_cmd().exec[1]
    612 
    613 
    614 # ==================================================================================================
    615 # format_log methods — one per LogFormat type
    616 # All write directly to `io`. All accept a pre-computed `timestamp::DateTime`.
    617 # ==================================================================================================
    618 
    619 function format_log(io, ::PrettyFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    620         displaysize::Tuple{Int,Int}=(50,100),
    621         log_date_format::AbstractString="yyyy-mm-dd",
    622         log_time_format::AbstractString="HH:MM:SS",
    623         kwargs...)
    624 
    625     BOLD = "\033[1m"
    626     EMPH = "\033[2m"
    627     RESET = "\033[0m"
    628 
    629     date = format(timestamp, log_date_format)
    630     time_str = format(timestamp, log_time_format)
    631     ts = "$BOLD$(time_str)$RESET $EMPH$date$RESET"
    632 
    633     level_str = string(log_record.level)
    634     color = get_color(log_record.level)
    635     mod_name = get_module_name(log_record._module)
    636     source = " @ $mod_name[$(log_record.file):$(log_record.line)]"
    637     first_line = "┌ [$ts] $color$level_str$RESET | $source"
    638 
    639     formatted = reformat_msg(log_record; displaysize=displaysize)
    640     lines = split(formatted, "\n")
    641 
    642     println(io, first_line)
    643     for (i, line) in enumerate(lines)
    644         prefix = i < length(lines) ? "│ " : "└ "
    645         println(io, prefix, line)
    646     end
    647 end
    648 
    649 function format_log(io, ::OnelineFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    650         displaysize::Tuple{Int,Int}=(50,100),
    651         shorten_path::Symbol=:relative_path,
    652         kwargs...)
    653 
    654     ts = format(timestamp, "yyyy-mm-dd HH:MM:SS")
    655     level = rpad(uppercase(string(log_record.level)), 5)
    656     mod_name = get_module_name(log_record._module)
    657     file = shorten_path_str(log_record.file; strategy=shorten_path)
    658     prefix = shorten_path === :relative_path ? "[$(pwd())] " : ""
    659     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    660 
    661     println(io, "$prefix$ts $level $mod_name[$file:$(log_record.line)] $msg")
    662 end
    663 
    664 function format_log(io, ::SyslogFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    665         displaysize::Tuple{Int,Int}=(50,100),
    666         kwargs...)
    667 
    668     ts = Dates.format(timestamp, "yyyy-mm-ddTHH:MM:SS")
    669     severity = get(SYSLOG_SEVERITY, log_record.level, 6)
    670     pri = (1 * 8) + severity
    671     hostname = gethostname()
    672     pid = getpid()
    673     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    674 
    675     println(io, "<$pri>1 $ts $hostname $JULIA_BIN $pid - - $msg")
    676 end
    677 
    678 function format_log(io, ::JsonFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    679         displaysize::Tuple{Int,Int}=(50,100),
    680         kwargs...)
    681 
    682     ts = Dates.format(timestamp, "yyyy-mm-ddTHH:MM:SS")
    683     level = json_escape(uppercase(string(log_record.level)))
    684     mod_name = json_escape(get_module_name(log_record._module))
    685     file = json_escape(string(log_record.file))
    686     line = log_record.line
    687     msg = json_escape(reformat_msg(log_record; displaysize=displaysize))
    688 
    689     println(io, "{\"timestamp\":\"$ts\",\"level\":\"$level\",\"module\":\"$mod_name\",\"file\":\"$file\",\"line\":$line,\"message\":\"$msg\"}")
    690 end
    691 
    692 function format_log(io, ::LogfmtFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    693         displaysize::Tuple{Int,Int}=(50,100),
    694         kwargs...)
    695 
    696     ts = Dates.format(timestamp, "yyyy-mm-ddTHH:MM:SS")
    697     level = lowercase(string(log_record.level))
    698     mod_name = get_module_name(log_record._module)
    699     file = logfmt_escape(string(log_record.file))
    700     msg = logfmt_escape(reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline)
    701 
    702     println(io, "ts=$ts level=$level module=$mod_name file=$file line=$(log_record.line) msg=$msg")
    703 end
    704 
    705 function format_log(io, ::Log4jStandardFormat, log_record::NamedTuple, timestamp::Dates.DateTime;
    706         displaysize::Tuple{Int,Int}=(50,100),
    707         kwargs...)
    708 
    709     ts = format(timestamp, "yyyy-mm-dd HH:MM:SS")
    710     millis = lpad(Dates.millisecond(timestamp), 3, '0')
    711     level = rpad(uppercase(string(log_record.level)), 5)
    712     thread_id = Threads.threadid()
    713     mod_name = get_module_name(log_record._module)
    714     msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline
    715 
    716     println(io, "$ts,$millis $level [$thread_id] $mod_name - $msg")
    717 end