2026-03-24-logger-cleanup.md (48905B)
1 # Logger Cleanup & Format Expansion Implementation Plan 2 3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 5 **Goal:** Rewrite CustomLogger.jl to fix robustness issues, add JSON/logfmt/log4j_standard formats, rename :log4j to :oneline, add exact-level filtering, and refactor to multiple dispatch. 6 7 **Architecture:** Replace if/elseif format dispatch with Julia multiple dispatch on `LogFormat` subtypes. Each format implements `format_log(io, fmt, log_record, timestamp; kwargs...)`. FileSink gains per-stream `ReentrantLock`s with IO deduplication for single-file mode. `cascading_loglevels` kwarg controls `MinLevelLogger` vs exact `EarlyFilteredLogger`. 8 9 **Tech Stack:** Julia 1.10+, LoggingExtras.jl (EarlyFilteredLogger, FormatLogger, MinLevelLogger, TeeLogger), Dates 10 11 **Spec:** `docs/superpowers/specs/2026-03-24-logger-cleanup-design.md` 12 13 --- 14 15 ### Task 1: Format types, resolve_format, and helper functions 16 17 **Files:** 18 - Modify: `src/CustomLogger.jl` (replace lines 1-17 with new infrastructure, add helpers before format functions) 19 - Test: `test/UnitTests/customlogger.jl` 20 21 - [ ] **Step 1: Write failing tests for resolve_format and helpers** 22 23 Add at the **top** of the `@testset "CustomLogger"` block in `test/UnitTests/customlogger.jl`: 24 25 ```julia 26 @testset "resolve_format" begin 27 @test BazerUtils.resolve_format(:pretty) isa BazerUtils.PrettyFormat 28 @test BazerUtils.resolve_format(:oneline) isa BazerUtils.OnelineFormat 29 @test BazerUtils.resolve_format(:syslog) isa BazerUtils.SyslogFormat 30 @test BazerUtils.resolve_format(:json) isa BazerUtils.JsonFormat 31 @test BazerUtils.resolve_format(:logfmt) isa BazerUtils.LogfmtFormat 32 @test BazerUtils.resolve_format(:log4j_standard) isa BazerUtils.Log4jStandardFormat 33 @test_throws ArgumentError BazerUtils.resolve_format(:invalid_format) 34 # :log4j is deprecated alias for :oneline 35 @test BazerUtils.resolve_format(:log4j) isa BazerUtils.OnelineFormat 36 end 37 38 @testset "get_module_name" begin 39 @test BazerUtils.get_module_name(nothing) == "unknown" 40 @test BazerUtils.get_module_name(Base) == "Base" 41 @test BazerUtils.get_module_name(Main) == "Main" 42 end 43 44 @testset "json_escape" begin 45 @test BazerUtils.json_escape("hello") == "hello" 46 @test BazerUtils.json_escape("line1\nline2") == "line1\\nline2" 47 @test BazerUtils.json_escape("say \"hi\"") == "say \\\"hi\\\"" 48 @test BazerUtils.json_escape("back\\slash") == "back\\\\slash" 49 @test BazerUtils.json_escape("tab\there") == "tab\\there" 50 end 51 52 @testset "logfmt_escape" begin 53 @test BazerUtils.logfmt_escape("simple") == "simple" 54 @test BazerUtils.logfmt_escape("has space") == "\"has space\"" 55 @test BazerUtils.logfmt_escape("has\"quote") == "\"has\\\"quote\"" 56 @test BazerUtils.logfmt_escape("has=equals") == "\"has=equals\"" 57 end 58 ``` 59 60 - [ ] **Step 2: Run tests to verify they fail** 61 62 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 63 Expected: FAIL — `resolve_format`, `get_module_name`, `json_escape`, `logfmt_escape` not defined 64 65 - [ ] **Step 3: Implement format types, resolve_format, and helpers** 66 67 Replace lines 1–15 of `src/CustomLogger.jl` (everything ABOVE `abstract type LogSink end` on line 16 — keep `LogSink` and everything after it intact until Task 2) with: 68 69 ```julia 70 # ================================================================================================== 71 # CustomLogger.jl — Custom multi-sink logger with per-level filtering and pluggable formats 72 # ================================================================================================== 73 74 75 # --- Format types (multiple dispatch instead of if/elseif) --- 76 77 abstract type LogFormat end 78 struct PrettyFormat <: LogFormat end 79 struct OnelineFormat <: LogFormat end 80 struct SyslogFormat <: LogFormat end 81 struct JsonFormat <: LogFormat end 82 struct LogfmtFormat <: LogFormat end 83 struct Log4jStandardFormat <: LogFormat end 84 85 const VALID_FORMATS = "Valid options: :pretty, :oneline, :syslog, :json, :logfmt, :log4j_standard" 86 87 """ 88 resolve_format(s::Symbol) -> LogFormat 89 90 Map a format symbol to its LogFormat type. `:log4j` is a deprecated alias for `:oneline`. 91 """ 92 function resolve_format(s::Symbol)::LogFormat 93 s === :pretty && return PrettyFormat() 94 s === :oneline && return OnelineFormat() 95 s === :log4j && (Base.depwarn( 96 ":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.", 97 :log4j); return OnelineFormat()) 98 s === :syslog && return SyslogFormat() 99 s === :json && return JsonFormat() 100 s === :logfmt && return LogfmtFormat() 101 s === :log4j_standard && return Log4jStandardFormat() 102 throw(ArgumentError("Unknown log_format: :$s. $VALID_FORMATS")) 103 end 104 105 106 # --- Helper functions --- 107 108 """ 109 get_module_name(mod) -> String 110 111 Extract module name as a string, returning "unknown" for `nothing`. 112 """ 113 get_module_name(mod::Module) = string(nameof(mod)) 114 get_module_name(::Nothing) = "unknown" 115 116 """ 117 reformat_msg(log_record; displaysize=(50,100)) -> String 118 119 Convert log record message to a string. Strings pass through; other types 120 are rendered via `show` with display size limits. 121 """ 122 function reformat_msg(log_record; displaysize::Tuple{Int,Int}=(50,100))::String 123 msg = log_record.message 124 msg isa AbstractString && return String(msg) 125 buf = IOBuffer() 126 show(IOContext(buf, :limit => true, :compact => true, :displaysize => displaysize), 127 "text/plain", msg) 128 return String(take!(buf)) 129 end 130 131 """ 132 msg_to_singleline(message::AbstractString) -> String 133 134 Collapse a multi-line message to a single line, using ` | ` as separator. 135 """ 136 function msg_to_singleline(message::AbstractString)::String 137 message |> 138 str -> replace(str, r"\"\"\"[\r\n\s]*(.+?)[\r\n\s]*\"\"\""s => s"\1") |> 139 str -> replace(str, r"\n\s*" => " | ") |> 140 str -> replace(str, r"\|\s*\|" => "|") |> 141 str -> replace(str, r"\s*\|\s*" => " | ") |> 142 str -> replace(str, r"\|\s*$" => "") |> 143 strip |> String 144 end 145 146 """ 147 json_escape(s::AbstractString) -> String 148 149 Escape a string for inclusion in a JSON value (without surrounding quotes). 150 """ 151 function json_escape(s::AbstractString)::String 152 s = replace(s, '\\' => "\\\\") 153 s = replace(s, '"' => "\\\"") 154 s = replace(s, '\n' => "\\n") 155 s = replace(s, '\r' => "\\r") 156 s = replace(s, '\t' => "\\t") 157 return s 158 end 159 160 """ 161 logfmt_escape(s::AbstractString) -> String 162 163 Format a value for logfmt output. Quotes the value if it contains spaces, equals, or quotes. 164 """ 165 function logfmt_escape(s::AbstractString)::String 166 needs_quoting = contains(s, ' ') || contains(s, '"') || contains(s, '=') 167 if needs_quoting 168 return "\"" * replace(s, '"' => "\\\"") * "\"" 169 end 170 return s 171 end 172 ``` 173 174 - [ ] **Step 4: Run tests to verify they pass** 175 176 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 177 Expected: The new testsets pass. Existing tests may fail because old code was replaced — that's OK, we fix it in subsequent tasks. 178 179 - [ ] **Step 5: Commit** 180 181 ```bash 182 git add src/CustomLogger.jl test/UnitTests/customlogger.jl 183 git commit -m "feat: add format types, resolve_format, and helper functions" 184 ``` 185 186 --- 187 188 ### Task 2: Refactor FileSink (finalizer, locks, IO deduplication) 189 190 **Files:** 191 - Modify: `src/CustomLogger.jl` (the `FileSink` struct and related functions) 192 - Test: `test/UnitTests/customlogger.jl` 193 194 - [ ] **Step 1: Write failing tests for FileSink** 195 196 Add after the `logfmt_escape` testset: 197 198 ```julia 199 @testset "FileSink" begin 200 tmp = tempname() 201 # Single file mode: deduplicates IO handles 202 sink = BazerUtils.FileSink(tmp; create_files=false) 203 @test length(sink.ios) == 4 204 @test length(unique(objectid.(sink.ios))) == 1 # all same IO 205 @test length(sink.locks) == 4 206 @test length(unique(objectid.(sink.locks))) == 1 # all same lock 207 @test all(io -> io !== stdout && io !== stderr, sink.ios) 208 close(sink) 209 rm(tmp, force=true) 210 211 # Multi file mode: separate IO handles 212 sink2 = BazerUtils.FileSink(tmp; create_files=true) 213 @test length(sink2.ios) == 4 214 @test length(unique(objectid.(sink2.ios))) == 4 # all different IO 215 @test length(unique(objectid.(sink2.locks))) == 4 # all different locks 216 close(sink2) 217 rm.(BazerUtils.get_log_filenames(tmp; create_files=true), force=true) 218 219 # close guard: closing twice doesn't error 220 sink3 = BazerUtils.FileSink(tempname(); create_files=false) 221 close(sink3) 222 @test_nowarn close(sink3) # second close is safe 223 end 224 ``` 225 226 - [ ] **Step 2: Run tests to verify they fail** 227 228 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 229 Expected: FAIL — `sink.locks` doesn't exist, IO dedup not implemented 230 231 - [ ] **Step 3: Implement refactored FileSink** 232 233 Replace the `get_log_filenames` functions and `FileSink` struct (from after the helpers to the `Base.close` function) with: 234 235 ```julia 236 # --- LogSink infrastructure --- 237 238 abstract type LogSink end 239 240 """ 241 get_log_filenames(filename; file_loggers, create_files) -> Vector{String} 242 243 Generate log file paths. When `create_files=true`, creates `filename_level.log` per level. 244 When `false`, repeats `filename` for all levels. 245 """ 246 function get_log_filenames(filename::AbstractString; 247 file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug], 248 create_files::Bool=false) 249 if create_files 250 return [string(filename, "_", string(f), ".log") for f in file_loggers] 251 else 252 return repeat([filename], length(file_loggers)) 253 end 254 end 255 256 function get_log_filenames(files::Vector{<:AbstractString}; 257 file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug]) 258 n = length(file_loggers) 259 length(files) != n && throw(ArgumentError( 260 "Expected exactly $n file paths (one per logger: $(join(file_loggers, ", "))), got $(length(files))")) 261 return files 262 end 263 264 """ 265 FileSink <: LogSink 266 267 File-based log sink with per-stream locking for thread safety. 268 269 When all files point to the same path (single-file mode), IO handles and locks are 270 deduplicated — one IO and one lock shared across all slots. 271 """ 272 struct FileSink <: LogSink 273 files::Vector{String} 274 ios::Vector{IO} 275 locks::Vector{ReentrantLock} 276 277 function FileSink(filename::AbstractString; 278 file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug], 279 create_files::Bool=false) 280 files = get_log_filenames(filename; file_loggers=file_loggers, create_files=create_files) 281 if create_files 282 @info "Creating $(length(files)) log files:\n$(join(string.(" \u2B91 ", files), "\n"))" 283 else 284 @info "Single log sink: all levels writing to $filename" 285 end 286 # Deduplicate: open each unique path once, share IO + lock 287 unique_paths = unique(files) 288 path_to_io = Dict(p => open(p, "a") for p in unique_paths) 289 path_to_lock = Dict(p => ReentrantLock() for p in unique_paths) 290 ios = [path_to_io[f] for f in files] 291 locks = [path_to_lock[f] for f in files] 292 obj = new(files, ios, locks) 293 finalizer(close, obj) 294 return obj 295 end 296 297 function FileSink(files::Vector{<:AbstractString}; 298 file_loggers::Vector{Symbol}=[:error, :warn, :info, :debug]) 299 actual_files = get_log_filenames(files; file_loggers=file_loggers) 300 unique_paths = unique(actual_files) 301 path_to_io = Dict(p => open(p, "a") for p in unique_paths) 302 path_to_lock = Dict(p => ReentrantLock() for p in unique_paths) 303 ios = [path_to_io[f] for f in actual_files] 304 locks = [path_to_lock[f] for f in actual_files] 305 obj = new(actual_files, ios, locks) 306 finalizer(close, obj) 307 return obj 308 end 309 end 310 311 function Base.close(sink::FileSink) 312 for io in unique(sink.ios) 313 io !== stdout && io !== stderr && isopen(io) && close(io) 314 end 315 end 316 ``` 317 318 - [ ] **Step 4: Run tests to verify they pass** 319 320 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 321 Expected: FileSink tests pass. 322 323 - [ ] **Step 5: Commit** 324 325 ```bash 326 git add src/CustomLogger.jl test/UnitTests/customlogger.jl 327 git commit -m "feat: refactor FileSink with locks, IO dedup, finalizer, close guard" 328 ``` 329 330 --- 331 332 ### Task 3: Implement format_log methods for all 6 formats 333 334 **Files:** 335 - Modify: `src/CustomLogger.jl` (ADD new `format_log` methods AFTER the old format functions — keep old functions in place until Task 7 cleanup) 336 - Modify: `src/BazerUtils.jl` (add `Logging.Error` import needed for `get_color`) 337 - Test: `test/UnitTests/customlogger.jl` 338 339 - [ ] **Step 1: Write failing tests for format_log** 340 341 Add after the `FileSink` testset: 342 343 ```julia 344 @testset "format_log methods" begin 345 T = Dates.DateTime(2024, 1, 15, 14, 30, 0) 346 log_record = (level=Base.CoreLogging.Info, message="test message", 347 _module=BazerUtils, file="/src/app.jl", line=42, group=:test, id=:test) 348 nothing_record = (level=Base.CoreLogging.Info, message="nothing mod", 349 _module=nothing, file="test.jl", line=1, group=:test, id=:test) 350 351 @testset "PrettyFormat" begin 352 buf = IOBuffer() 353 BazerUtils.format_log(buf, BazerUtils.PrettyFormat(), log_record, T; 354 displaysize=(50,100)) 355 output = String(take!(buf)) 356 @test contains(output, "test message") 357 @test contains(output, "14:30:00") 358 @test contains(output, "BazerUtils") 359 @test contains(output, "┌") 360 @test contains(output, "└") 361 end 362 363 @testset "PrettyFormat _module=nothing" begin 364 buf = IOBuffer() 365 BazerUtils.format_log(buf, BazerUtils.PrettyFormat(), nothing_record, T; 366 displaysize=(50,100)) 367 output = String(take!(buf)) 368 @test contains(output, "unknown") 369 end 370 371 @testset "OnelineFormat" begin 372 buf = IOBuffer() 373 BazerUtils.format_log(buf, BazerUtils.OnelineFormat(), log_record, T; 374 displaysize=(50,100), shorten_path=:no) 375 output = String(take!(buf)) 376 @test contains(output, "INFO") 377 @test contains(output, "2024-01-15 14:30:00") 378 @test contains(output, "BazerUtils") 379 @test contains(output, "test message") 380 end 381 382 @testset "OnelineFormat _module=nothing" begin 383 buf = IOBuffer() 384 BazerUtils.format_log(buf, BazerUtils.OnelineFormat(), nothing_record, T; 385 displaysize=(50,100), shorten_path=:no) 386 output = String(take!(buf)) 387 @test contains(output, "unknown") 388 end 389 390 @testset "SyslogFormat" begin 391 buf = IOBuffer() 392 BazerUtils.format_log(buf, BazerUtils.SyslogFormat(), log_record, T; 393 displaysize=(50,100)) 394 output = String(take!(buf)) 395 @test contains(output, "<14>") # facility=1, severity=6 -> (1*8)+6=14 396 @test contains(output, "2024-01-15T14:30:00") 397 @test contains(output, "test message") 398 end 399 400 @testset "JsonFormat" begin 401 buf = IOBuffer() 402 BazerUtils.format_log(buf, BazerUtils.JsonFormat(), log_record, T; 403 displaysize=(50,100)) 404 output = strip(String(take!(buf))) 405 @test startswith(output, "{") 406 @test endswith(output, "}") 407 @test contains(output, "\"timestamp\":\"2024-01-15T14:30:00\"") 408 @test contains(output, "\"level\":\"Info\"") 409 @test contains(output, "\"module\":\"BazerUtils\"") 410 @test contains(output, "\"message\":\"test message\"") 411 @test contains(output, "\"line\":42") 412 # Verify it parses as valid JSON 413 parsed = JSON.parse(output) 414 @test parsed["level"] == "Info" 415 @test parsed["line"] == 42 416 end 417 418 @testset "JsonFormat escaping" begin 419 escape_record = (level=Base.CoreLogging.Warn, message="line1\nline2 \"quoted\"", 420 _module=nothing, file="test.jl", line=1, group=:test, id=:test) 421 buf = IOBuffer() 422 BazerUtils.format_log(buf, BazerUtils.JsonFormat(), escape_record, T; 423 displaysize=(50,100)) 424 output = strip(String(take!(buf))) 425 parsed = JSON.parse(output) 426 @test parsed["message"] == "line1\nline2 \"quoted\"" 427 @test parsed["module"] == "unknown" 428 end 429 430 @testset "LogfmtFormat" begin 431 buf = IOBuffer() 432 BazerUtils.format_log(buf, BazerUtils.LogfmtFormat(), log_record, T; 433 displaysize=(50,100)) 434 output = strip(String(take!(buf))) 435 @test contains(output, "ts=2024-01-15T14:30:00") 436 @test contains(output, "level=Info") 437 @test contains(output, "module=BazerUtils") 438 @test contains(output, "msg=\"test message\"") 439 end 440 441 @testset "LogfmtFormat _module=nothing" begin 442 buf = IOBuffer() 443 BazerUtils.format_log(buf, BazerUtils.LogfmtFormat(), nothing_record, T; 444 displaysize=(50,100)) 445 output = strip(String(take!(buf))) 446 @test contains(output, "module=unknown") 447 end 448 449 @testset "SyslogFormat _module=nothing" begin 450 buf = IOBuffer() 451 BazerUtils.format_log(buf, BazerUtils.SyslogFormat(), nothing_record, T; 452 displaysize=(50,100)) 453 output = String(take!(buf)) 454 @test contains(output, "nothing mod") 455 @test !contains(output, "nothing[") # should not show "nothing" as module in brackets 456 end 457 458 @testset "Log4jStandardFormat" begin 459 buf = IOBuffer() 460 BazerUtils.format_log(buf, BazerUtils.Log4jStandardFormat(), log_record, T; 461 displaysize=(50,100)) 462 output = strip(String(take!(buf))) 463 # Pattern: timestamp LEVEL [threadid] module - message 464 @test contains(output, "2024-01-15 14:30:00,000") 465 @test contains(output, "INFO ") 466 @test contains(output, "BazerUtils") 467 @test contains(output, " - ") 468 @test contains(output, "test message") 469 end 470 471 @testset "Log4jStandardFormat _module=nothing" begin 472 buf = IOBuffer() 473 BazerUtils.format_log(buf, BazerUtils.Log4jStandardFormat(), nothing_record, T; 474 displaysize=(50,100)) 475 output = strip(String(take!(buf))) 476 @test contains(output, "unknown") 477 @test contains(output, "nothing mod") 478 end 479 end 480 ``` 481 482 **IMPORTANT:** Also add `import Dates` to `test/runtests.jl` imports (after `import HTTP`). 483 484 - [ ] **Step 2: Run tests to verify they fail** 485 486 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 487 Expected: FAIL — `format_log` not defined 488 489 - [ ] **Step 3: Implement all format_log methods** 490 491 Add to `src/CustomLogger.jl` AFTER the `shorten_path_str` function (at the end of the file). Keep old format functions (`format_pretty`, `format_log4j`, `format_syslog`, `get_color`, etc.) in place for now — they will be removed in Task 7: 492 493 ```julia 494 # --- Constants --- 495 496 const SYSLOG_SEVERITY = Dict( 497 Logging.Info => 6, # Informational 498 Logging.Warn => 4, # Warning 499 Logging.Error => 3, # Error 500 Logging.Debug => 7 # Debug 501 ) 502 503 const JULIA_BIN = Base.julia_cmd().exec[1] 504 505 # --- ANSI color helpers (for PrettyFormat) --- 506 507 function get_color(level) 508 BOLD = "\033[1m" 509 LIGHT_BLUE = "\033[94m" 510 RED = "\033[31m" 511 GREEN = "\033[32m" 512 YELLOW = "\033[33m" 513 return level == Logging.Debug ? LIGHT_BLUE : 514 level == Logging.Info ? GREEN : 515 level == Logging.Warn ? "$YELLOW$BOLD" : 516 level == Logging.Error ? "$RED$BOLD" : 517 "\033[0m" 518 end 519 520 521 # ================================================================================================== 522 # format_log methods — one per LogFormat type 523 # All write directly to `io`. All accept a pre-computed `timestamp::DateTime`. 524 # ================================================================================================== 525 526 function format_log(io, ::PrettyFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 527 displaysize::Tuple{Int,Int}=(50,100), 528 log_date_format::AbstractString="yyyy-mm-dd", 529 log_time_format::AbstractString="HH:MM:SS", 530 kwargs...) 531 532 BOLD = "\033[1m" 533 EMPH = "\033[2m" 534 RESET = "\033[0m" 535 536 date = format(timestamp, log_date_format) 537 time_str = format(timestamp, log_time_format) 538 ts = "$BOLD$(time_str)$RESET $EMPH$date$RESET" 539 540 level_str = string(log_record.level) 541 color = get_color(log_record.level) 542 mod_name = get_module_name(log_record._module) 543 source = " @ $mod_name[$(log_record.file):$(log_record.line)]" 544 first_line = "┌ [$ts] $color$level_str$RESET | $source" 545 546 formatted = reformat_msg(log_record; displaysize=displaysize) 547 lines = split(formatted, "\n") 548 549 println(io, first_line) 550 for (i, line) in enumerate(lines) 551 prefix = i < length(lines) ? "│ " : "└ " 552 println(io, prefix, line) 553 end 554 end 555 556 function format_log(io, ::OnelineFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 557 displaysize::Tuple{Int,Int}=(50,100), 558 shorten_path::Symbol=:relative_path, 559 kwargs...) 560 561 ts = format(timestamp, "yyyy-mm-dd HH:MM:SS") 562 level = rpad(uppercase(string(log_record.level)), 5) 563 mod_name = get_module_name(log_record._module) 564 file = shorten_path_str(log_record.file; strategy=shorten_path) 565 prefix = shorten_path === :relative_path ? "[$(pwd())] " : "" 566 msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline 567 568 println(io, "$prefix$ts $level $mod_name[$file:$(log_record.line)] $msg") 569 end 570 571 function format_log(io, ::SyslogFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 572 displaysize::Tuple{Int,Int}=(50,100), 573 kwargs...) 574 575 ts = Dates.format(timestamp, ISODateTimeFormat) 576 severity = get(SYSLOG_SEVERITY, log_record.level, 6) 577 pri = (1 * 8) + severity # facility=1 (user-level) 578 hostname = gethostname() 579 pid = getpid() 580 msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline 581 582 println(io, "<$pri>1 $ts $hostname $JULIA_BIN $pid - - $msg") 583 end 584 585 function format_log(io, ::JsonFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 586 displaysize::Tuple{Int,Int}=(50,100), 587 kwargs...) 588 589 ts = Dates.format(timestamp, ISODateTimeFormat) 590 level = json_escape(string(log_record.level)) 591 mod_name = json_escape(get_module_name(log_record._module)) 592 file = json_escape(string(log_record.file)) 593 line = log_record.line 594 msg = json_escape(reformat_msg(log_record; displaysize=displaysize)) 595 596 println(io, "{\"timestamp\":\"$ts\",\"level\":\"$level\",\"module\":\"$mod_name\",\"file\":\"$file\",\"line\":$line,\"message\":\"$msg\"}") 597 end 598 599 function format_log(io, ::LogfmtFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 600 displaysize::Tuple{Int,Int}=(50,100), 601 kwargs...) 602 603 ts = Dates.format(timestamp, ISODateTimeFormat) 604 level = string(log_record.level) 605 mod_name = get_module_name(log_record._module) 606 file = logfmt_escape(string(log_record.file)) 607 msg = logfmt_escape(reformat_msg(log_record; displaysize=displaysize)) 608 609 println(io, "ts=$ts level=$level module=$mod_name file=$file line=$(log_record.line) msg=$msg") 610 end 611 612 function format_log(io, ::Log4jStandardFormat, log_record::NamedTuple, timestamp::Dates.DateTime; 613 displaysize::Tuple{Int,Int}=(50,100), 614 kwargs...) 615 616 # Apache Log4j PatternLayout: %d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%t] %c - %m%n 617 ts = format(timestamp, "yyyy-mm-dd HH:MM:SS") 618 millis = lpad(Dates.millisecond(timestamp), 3, '0') 619 level = rpad(uppercase(string(log_record.level)), 5) 620 thread_id = Threads.threadid() 621 mod_name = get_module_name(log_record._module) 622 msg = reformat_msg(log_record; displaysize=displaysize) |> msg_to_singleline 623 624 println(io, "$ts,$millis $level [$thread_id] $mod_name - $msg") 625 end 626 ``` 627 628 - [ ] **Step 4: Update BazerUtils.jl imports** 629 630 Add `Logging.Error` to the Logging import line in `src/BazerUtils.jl`: 631 632 ```julia 633 import Logging: global_logger, Logging, Logging.Debug, Logging.Info, Logging.Warn, Logging.Error 634 ``` 635 636 - [ ] **Step 5: Run tests to verify format_log tests pass** 637 638 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 639 Expected: All `format_log methods` tests pass. 640 641 - [ ] **Step 6: Commit** 642 643 ```bash 644 git add src/CustomLogger.jl src/BazerUtils.jl test/UnitTests/customlogger.jl test/runtests.jl 645 git commit -m "feat: implement format_log methods for all 6 formats" 646 ``` 647 648 --- 649 650 ### Task 4: Rewrite custom_format, create_demux_logger, and custom_logger 651 652 This task replaces the core orchestration. `custom_format` uses dispatch instead of if/elseif. `create_demux_logger` gains `cascading_loglevels` and thread-safe locking. `custom_logger` gets updated kwargs. 653 654 **Files:** 655 - Modify: `src/CustomLogger.jl` (replace `custom_format`, `create_demux_logger`, `custom_logger`) 656 657 **IMPORTANT: Steps 1-4 below are ATOMIC.** Apply all four before running tests. The intermediate states between steps will not compile because they reference each other's new signatures. 658 659 - [ ] **Step 1: Rewrite custom_format** 660 661 Replace the old `custom_format` function (the one with the if/elseif chain) with: 662 663 ```julia 664 # ================================================================================================== 665 # custom_format — dispatch hub. Called by FormatLogger callbacks. 666 # ================================================================================================== 667 668 """ 669 custom_format(io, fmt::LogFormat, log_record::NamedTuple; kwargs...) 670 671 Format and write a log record to `io` using the given format. Generates a single 672 timestamp and delegates to the appropriate `format_log` method. 673 """ 674 function custom_format(io, fmt::LogFormat, log_record::NamedTuple; 675 displaysize::Tuple{Int,Int}=(50,100), 676 log_date_format::AbstractString="yyyy-mm-dd", 677 log_time_format::AbstractString="HH:MM:SS", 678 shorten_path::Symbol=:relative_path) 679 680 timestamp = now() 681 format_log(io, fmt, log_record, timestamp; 682 displaysize=displaysize, 683 log_date_format=log_date_format, 684 log_time_format=log_time_format, 685 shorten_path=shorten_path) 686 end 687 ``` 688 689 - [ ] **Step 2: Rewrite create_demux_logger** 690 691 Replace the old `create_demux_logger` with: 692 693 ```julia 694 # ================================================================================================== 695 # create_demux_logger — builds the TeeLogger pipeline 696 # ================================================================================================== 697 698 function create_demux_logger(sink::FileSink, 699 file_loggers::Vector{Symbol}, 700 module_absolute_message_filter, 701 module_specific_message_filter, 702 fmt_file::LogFormat, 703 fmt_stdout::LogFormat, 704 format_kwargs::NamedTuple; 705 cascading_loglevels::Bool=false) 706 707 logger_configs = Dict( 708 :error => (module_absolute_message_filter, Logging.Error), 709 :warn => (module_absolute_message_filter, Logging.Warn), 710 :info => (module_specific_message_filter, Logging.Info), 711 :debug => (module_absolute_message_filter, Logging.Debug) 712 ) 713 714 logger_list = [] 715 716 for (io_index, logger_key) in enumerate(file_loggers) 717 if !haskey(logger_configs, logger_key) 718 @warn "Unknown logger type: $logger_key — skipping" 719 continue 720 end 721 if io_index > length(sink.ios) 722 error("Not enough IO streams in sink for logger: $logger_key") 723 end 724 725 message_filter, log_level = logger_configs[logger_key] 726 io = sink.ios[io_index] 727 lk = sink.locks[io_index] 728 729 # Thread-safe format callback 730 format_cb = (cb_io, log_record) -> lock(lk) do 731 custom_format(cb_io, fmt_file, log_record; format_kwargs...) 732 end 733 734 inner = EarlyFilteredLogger(message_filter, FormatLogger(format_cb, io)) 735 736 if cascading_loglevels 737 # Old behavior: MinLevelLogger catches this level and above 738 push!(logger_list, MinLevelLogger(inner, log_level)) 739 else 740 # New behavior: exact level only 741 exact_filter = log -> log.level == log_level 742 push!(logger_list, EarlyFilteredLogger(exact_filter, inner)) 743 end 744 end 745 746 # Stdout logger — always Info+, uses specific module filter, no file locking 747 stdout_format_cb = (io, log_record) -> custom_format(io, fmt_stdout, log_record; 748 format_kwargs...) 749 stdout_logger = MinLevelLogger( 750 EarlyFilteredLogger(module_specific_message_filter, 751 FormatLogger(stdout_format_cb, stdout)), 752 Logging.Info) 753 push!(logger_list, stdout_logger) 754 755 return TeeLogger(logger_list...) 756 end 757 ``` 758 759 - [ ] **Step 3: Rewrite custom_logger (main method)** 760 761 Replace the old `custom_logger(sink::LogSink; ...)` with: 762 763 ```julia 764 # ================================================================================================== 765 # custom_logger — public API 766 # ================================================================================================== 767 768 """ 769 custom_logger(filename; kw...) 770 771 Set up a custom global logger with per-level file output, module filtering, and configurable formatting. 772 773 When `create_log_files=true`, creates one log file per level (e.g. `filename_error.log`). 774 Otherwise all levels write to the same file. 775 776 # Arguments 777 - `filename::AbstractString`: base name for the log files 778 - `filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from stdout and info-level file logs 779 - `filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing`: modules to filter from all logs 780 - `file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug]`: which levels to capture 781 - `log_date_format::AbstractString="yyyy-mm-dd"`: date format in timestamps 782 - `log_time_format::AbstractString="HH:MM:SS"`: time format in timestamps 783 - `displaysize::Tuple{Int,Int}=(50,100)`: display size for non-string messages 784 - `log_format::Symbol=:oneline`: file log format (`:pretty`, `:oneline`, `:syslog`, `:json`, `:logfmt`, `:log4j_standard`) 785 - `log_format_stdout::Symbol=:pretty`: stdout format (same options) 786 - `shorten_path::Symbol=:relative_path`: path shortening strategy (`:oneline` format only) 787 - `cascading_loglevels::Bool=false`: when `true`, each file captures its level and above; when `false`, each file captures only its exact level 788 - `create_log_files::Bool=false`: create separate files per level 789 - `overwrite::Bool=false`: overwrite existing log files 790 - `create_dir::Bool=false`: create log directory if missing 791 - `verbose::Bool=false`: warn about filtering non-imported modules 792 793 # Example 794 ```julia 795 custom_logger("/tmp/myapp"; 796 filtered_modules_all=[:HTTP, :TranscodingStreams], 797 create_log_files=true, 798 overwrite=true, 799 log_format=:oneline) 800 ``` 801 """ 802 function custom_logger( 803 sink::LogSink; 804 filtered_modules_specific::Union{Nothing, Vector{Symbol}}=nothing, 805 filtered_modules_all::Union{Nothing, Vector{Symbol}}=nothing, 806 file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug], 807 log_date_format::AbstractString="yyyy-mm-dd", 808 log_time_format::AbstractString="HH:MM:SS", 809 displaysize::Tuple{Int,Int}=(50,100), 810 log_format::Symbol=:oneline, 811 log_format_stdout::Symbol=:pretty, 812 shorten_path::Symbol=:relative_path, 813 cascading_loglevels::Bool=false, 814 verbose::Bool=false) 815 816 # Resolve format types (validates symbols, handles :log4j deprecation) 817 fmt_file = resolve_format(log_format) 818 fmt_stdout = resolve_format(log_format_stdout) 819 820 # Normalize file_loggers to Vector 821 file_loggers_vec = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers) 822 823 # Warn about filtering non-imported modules 824 if verbose 825 imported_modules = filter( 826 x -> typeof(getfield(Main, x)) <: Module && x !== :Main, 827 names(Main, imported=true)) 828 all_filters = Symbol[x for x in unique(vcat( 829 something(filtered_modules_specific, Symbol[]), 830 something(filtered_modules_all, Symbol[]))) if !isnothing(x)] 831 if !isempty(all_filters) 832 missing = filter(x -> x ∉ imported_modules, all_filters) 833 if !isempty(missing) 834 @warn "Filtering non-imported modules: $(join(string.(missing), ", "))" 835 end 836 end 837 end 838 839 # Module filters 840 module_absolute_filter = create_module_filter(filtered_modules_all) 841 module_specific_filter = create_module_filter(filtered_modules_specific) 842 843 format_kwargs = (displaysize=displaysize, 844 log_date_format=log_date_format, 845 log_time_format=log_time_format, 846 shorten_path=shorten_path) 847 848 demux = create_demux_logger(sink, file_loggers_vec, 849 module_absolute_filter, module_specific_filter, 850 fmt_file, fmt_stdout, format_kwargs; 851 cascading_loglevels=cascading_loglevels) 852 853 global_logger(demux) 854 return demux 855 end 856 857 """ 858 create_module_filter(modules) -> Function 859 860 Return a filter function that drops log messages from the specified modules. 861 Uses `startswith` to catch submodules (e.g. `:HTTP` catches `HTTP.ConnectionPool`). 862 """ 863 function create_module_filter(modules) 864 return function(log) 865 isnothing(modules) && return true 866 mod = string(log._module) 867 for m in modules 868 startswith(mod, string(m)) && return false 869 end 870 return true 871 end 872 end 873 ``` 874 875 - [ ] **Step 4: Rewrite convenience constructors** 876 877 Replace the old convenience constructors: 878 879 ```julia 880 # Convenience constructor: filename or vector of filenames 881 function custom_logger( 882 filename::Union{AbstractString, Vector{<:AbstractString}}; 883 create_log_files::Bool=false, 884 overwrite::Bool=false, 885 create_dir::Bool=false, 886 file_loggers::Union{Symbol, Vector{Symbol}}=[:error, :warn, :info, :debug], 887 kwargs...) 888 889 file_loggers_array = file_loggers isa Symbol ? [file_loggers] : collect(file_loggers) 890 891 files = if filename isa AbstractString 892 get_log_filenames(filename; file_loggers=file_loggers_array, create_files=create_log_files) 893 else 894 get_log_filenames(filename; file_loggers=file_loggers_array) 895 end 896 897 # Create directories if needed 898 log_dirs = unique(dirname.(files)) 899 missing_dirs = filter(d -> !isempty(d) && !isdir(d), log_dirs) 900 if !isempty(missing_dirs) 901 if create_dir 902 @warn "Creating log directories: $(join(missing_dirs, ", "))" 903 mkpath.(missing_dirs) 904 else 905 @error "Log directories do not exist: $(join(missing_dirs, ", "))" 906 end 907 end 908 909 overwrite && foreach(f -> rm(f, force=true), unique(files)) 910 911 sink = if filename isa AbstractString 912 FileSink(filename; file_loggers=file_loggers_array, create_files=create_log_files) 913 else 914 FileSink(filename; file_loggers=file_loggers_array) 915 end 916 917 custom_logger(sink; file_loggers=file_loggers, kwargs...) 918 end 919 920 # Convenience for batch/script mode 921 function custom_logger(; kwargs...) 922 if !isempty(PROGRAM_FILE) 923 logbase = splitext(abspath(PROGRAM_FILE))[1] 924 custom_logger(logbase; kwargs...) 925 else 926 @error "custom_logger() with no arguments requires a script context (PROGRAM_FILE is empty in the REPL)" 927 end 928 end 929 ``` 930 931 - [ ] **Step 5: Run full test suite** 932 933 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 934 Expected: The new unit tests pass. Some existing integration tests may fail due to the `:log4j` → `:oneline` default change and `cascading_loglevels=false` default. That's expected — we fix those in Task 6. 935 936 - [ ] **Step 6: Commit** 937 938 ```bash 939 git add src/CustomLogger.jl 940 git commit -m "feat: rewrite custom_format, create_demux_logger, custom_logger with dispatch and cascading_loglevels" 941 ``` 942 943 --- 944 945 ### Task 5: Write tests for new features (cascading_loglevels, new formats, thread safety) 946 947 **Files:** 948 - Modify: `test/UnitTests/customlogger.jl` 949 950 - [ ] **Step 1: Add cascading_loglevels tests** 951 952 Add after existing integration tests: 953 954 ```julia 955 # -- exact level filtering (default: cascading_loglevels=false) 956 log_path_cl = joinpath(tempdir(), "log_cascading") 957 logger_exact = custom_logger( 958 log_path_cl; 959 overwrite=true, create_log_files=true) 960 @error "ONLY_ERROR" 961 @warn "ONLY_WARN" 962 @info "ONLY_INFO" 963 @debug "ONLY_DEBUG" 964 log_files_exact = get_log_names(logger_exact) 965 content_exact = read.(log_files_exact, String) 966 # Positive: each file has its own level 967 @test contains(content_exact[1], "ONLY_ERROR") 968 @test contains(content_exact[2], "ONLY_WARN") 969 @test contains(content_exact[3], "ONLY_INFO") 970 @test contains(content_exact[4], "ONLY_DEBUG") 971 # Negative: each file does NOT have other levels 972 @test !contains(content_exact[1], "ONLY_WARN") 973 @test !contains(content_exact[1], "ONLY_INFO") 974 @test !contains(content_exact[1], "ONLY_DEBUG") 975 @test !contains(content_exact[2], "ONLY_ERROR") 976 @test !contains(content_exact[2], "ONLY_INFO") 977 @test !contains(content_exact[2], "ONLY_DEBUG") 978 @test !contains(content_exact[3], "ONLY_ERROR") 979 @test !contains(content_exact[3], "ONLY_WARN") 980 @test !contains(content_exact[3], "ONLY_DEBUG") 981 @test !contains(content_exact[4], "ONLY_ERROR") 982 @test !contains(content_exact[4], "ONLY_WARN") 983 @test !contains(content_exact[4], "ONLY_INFO") 984 close_logger(logger_exact, remove_files=true) 985 986 # -- cascading level filtering (cascading_loglevels=true, old behavior) 987 logger_cascade = custom_logger( 988 log_path_cl; 989 overwrite=true, create_log_files=true, 990 cascading_loglevels=true) 991 @error "CASCADE_ERROR" 992 @warn "CASCADE_WARN" 993 @info "CASCADE_INFO" 994 @debug "CASCADE_DEBUG" 995 log_files_cascade = get_log_names(logger_cascade) 996 content_cascade = read.(log_files_cascade, String) 997 # Error file: only errors 998 @test contains(content_cascade[1], "CASCADE_ERROR") 999 @test !contains(content_cascade[1], "CASCADE_WARN") 1000 # Warn file: warn + error 1001 @test contains(content_cascade[2], "CASCADE_WARN") 1002 @test contains(content_cascade[2], "CASCADE_ERROR") 1003 # Info file: info + warn + error 1004 @test contains(content_cascade[3], "CASCADE_INFO") 1005 @test contains(content_cascade[3], "CASCADE_WARN") 1006 @test contains(content_cascade[3], "CASCADE_ERROR") 1007 # Debug file: everything 1008 @test contains(content_cascade[4], "CASCADE_DEBUG") 1009 @test contains(content_cascade[4], "CASCADE_INFO") 1010 @test contains(content_cascade[4], "CASCADE_WARN") 1011 @test contains(content_cascade[4], "CASCADE_ERROR") 1012 close_logger(logger_cascade, remove_files=true) 1013 ``` 1014 1015 - [ ] **Step 2: Add integration tests for new formats** 1016 1017 ```julia 1018 # -- JSON format logger 1019 log_path_fmt = joinpath(tempdir(), "log_fmt") 1020 logger_json = custom_logger( 1021 log_path_fmt; 1022 log_format=:json, overwrite=true) 1023 @error "JSON_ERROR" 1024 @info "JSON_INFO" 1025 log_file_json = get_log_names(logger_json)[1] 1026 json_lines = filter(!isempty, split(read(log_file_json, String), "\n")) 1027 for line in json_lines 1028 parsed = JSON.parse(line) 1029 @test haskey(parsed, "timestamp") 1030 @test haskey(parsed, "level") 1031 @test haskey(parsed, "module") 1032 @test haskey(parsed, "message") 1033 end 1034 close_logger(logger_json, remove_files=true) 1035 1036 # -- logfmt format logger 1037 logger_logfmt = custom_logger( 1038 log_path_fmt; 1039 log_format=:logfmt, overwrite=true) 1040 @error "LOGFMT_ERROR" 1041 @info "LOGFMT_INFO" 1042 log_file_logfmt = get_log_names(logger_logfmt)[1] 1043 logfmt_content = read(log_file_logfmt, String) 1044 @test contains(logfmt_content, "level=Error") 1045 @test contains(logfmt_content, "level=Info") 1046 @test contains(logfmt_content, r"ts=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") 1047 @test contains(logfmt_content, "msg=") 1048 close_logger(logger_logfmt, remove_files=true) 1049 1050 # -- log4j_standard format logger 1051 logger_l4js = custom_logger( 1052 log_path_fmt; 1053 log_format=:log4j_standard, overwrite=true) 1054 @error "L4JS_ERROR" 1055 @info "L4JS_INFO" 1056 log_file_l4js = get_log_names(logger_l4js)[1] 1057 l4js_content = read(log_file_l4js, String) 1058 # Pattern: timestamp,millis LEVEL [threadid] module - message 1059 @test contains(l4js_content, r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} ERROR") 1060 @test contains(l4js_content, r"INFO .* - L4JS_INFO") 1061 @test contains(l4js_content, " - ") 1062 close_logger(logger_l4js, remove_files=true) 1063 ``` 1064 1065 - [ ] **Step 3: Add unknown format and deprecation tests** 1066 1067 ```julia 1068 # -- unknown format throws 1069 @test_throws ArgumentError custom_logger( 1070 joinpath(tempdir(), "log_bad"); log_format=:banana, overwrite=true) 1071 1072 # -- :log4j deprecated alias still works 1073 logger_deprecated = custom_logger( 1074 log_path_fmt; 1075 log_format=:log4j, overwrite=true) 1076 @info "DEPRECATED_TEST" 1077 log_file_dep = get_log_names(logger_deprecated)[1] 1078 dep_content = read(log_file_dep, String) 1079 @test contains(dep_content, "DEPRECATED_TEST") 1080 close_logger(logger_deprecated, remove_files=true) 1081 ``` 1082 1083 - [ ] **Step 4: Add thread safety test** 1084 1085 ```julia 1086 # -- thread safety: concurrent logging produces complete lines 1087 log_path_thread = joinpath(tempdir(), "log_thread") 1088 logger_thread = custom_logger( 1089 log_path_thread; 1090 log_format=:json, overwrite=true) 1091 n_tasks = 10 1092 n_msgs = 50 1093 @sync for t in 1:n_tasks 1094 Threads.@spawn begin 1095 for m in 1:n_msgs 1096 @info "task=$t msg=$m" 1097 end 1098 end 1099 end 1100 log_file_thread = get_log_names(logger_thread)[1] 1101 flush(logger_thread.loggers[end].logger.logger.stream) # flush stdout logger is irrelevant 1102 # Flush all file streams 1103 for lg in logger_thread.loggers 1104 s = lg.logger.logger.stream 1105 s isa IOStream && flush(s) 1106 end 1107 thread_lines = filter(!isempty, split(read(log_file_thread, String), "\n")) 1108 # Every line should be valid JSON (no interleaving) 1109 for line in thread_lines 1110 @test startswith(line, "{") 1111 @test endswith(line, "}") 1112 parsed = JSON.parse(line) 1113 @test haskey(parsed, "message") 1114 end 1115 close_logger(logger_thread, remove_files=true) 1116 ``` 1117 1118 - [ ] **Step 5: Run tests (with threads for thread safety test)** 1119 1120 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --threads=4 --project -e 'using Pkg; Pkg.test()'` 1121 Expected: New tests pass. Some old integration tests may still need updating (Task 6). **Note:** `--threads=4` is required so the thread safety test actually exercises concurrent writes. 1122 1123 - [ ] **Step 6: Commit** 1124 1125 ```bash 1126 git add test/UnitTests/customlogger.jl 1127 git commit -m "test: add tests for cascading_loglevels, new formats, thread safety" 1128 ``` 1129 1130 --- 1131 1132 ### Task 6: Update existing tests for breaking changes 1133 1134 The `cascading_loglevels=false` default changes behavior of the `[:debug, :info]` partial-loggers test. The `:log4j` format references in existing tests should use `:oneline` (or keep `:log4j` for the deprecation test, already covered). 1135 1136 **Files:** 1137 - Modify: `test/UnitTests/customlogger.jl` 1138 1139 - [ ] **Step 1: Fix the partial file_loggers test** 1140 1141 The test at the end that uses `file_loggers = [:debug, :info]` expects cascading behavior where the debug file contains INFO messages. With `cascading_loglevels=false`, each file is exact-level. Update the test: 1142 1143 Old assertion: 1144 ```julia 1145 @test contains.(log_content, r"INFO .* INFO MESSAGE") == [true, true] 1146 ``` 1147 1148 Change to: 1149 ```julia 1150 @test contains.(log_content, r"DEBUG .* DEBUG MESSAGE") == [true, false] 1151 @test contains.(log_content, r"INFO .* INFO MESSAGE") == [false, true] 1152 ``` 1153 1154 - [ ] **Step 2: Update existing format tests to use :oneline** 1155 1156 In the "logger with formatting" test block, change `log_format=:log4j` to `log_format=:oneline`. 1157 In the "logger with formatting and truncation" test block, change `log_format=:log4j` to `log_format=:oneline`. 1158 1159 - [ ] **Step 3: Update _module=nothing test to use :oneline** 1160 1161 Change `log_format=:log4j` to `log_format=:oneline` in the `_module=nothing` test. Also update the `custom_format` call to use the new dispatch signature: 1162 1163 ```julia 1164 # -- logger with _module=nothing (issue #10) 1165 logger_single = custom_logger( 1166 log_path; 1167 log_format=:oneline, 1168 overwrite=true) 1169 log_record = (level=Base.CoreLogging.Info, message="test nothing module", 1170 _module=nothing, file="test.jl", line=1, group=:test, id=:test) 1171 buf = IOBuffer() 1172 BazerUtils.custom_format(buf, BazerUtils.OnelineFormat(), log_record; 1173 shorten_path=:no) 1174 output = String(take!(buf)) 1175 @test contains(output, "unknown") 1176 @test contains(output, "test nothing module") 1177 close_logger(logger_single, remove_files=true) 1178 ``` 1179 1180 - [ ] **Step 4: Run full test suite** 1181 1182 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 1183 Expected: ALL tests pass. 1184 1185 - [ ] **Step 5: Commit** 1186 1187 ```bash 1188 git add test/UnitTests/customlogger.jl 1189 git commit -m "test: update existing tests for :oneline rename and cascading_loglevels=false default" 1190 ``` 1191 1192 --- 1193 1194 ### Task 7: Clean up dead code and update docstrings 1195 1196 **Files:** 1197 - Modify: `src/CustomLogger.jl` — remove any leftover old code 1198 - Modify: `src/BazerUtils.jl` — verify imports are clean 1199 1200 - [ ] **Step 1: Remove old format functions and dead code** 1201 1202 DELETE from `src/CustomLogger.jl`: 1203 - Old `format_pretty` function (was kept alongside new `format_log` methods since Task 3) 1204 - Old `format_log4j` function 1205 - Old `format_syslog` function 1206 - Old `custom_format` with if/elseif chain (replaced in Task 4) 1207 - Old `create_demux_logger` signature (replaced in Task 4) 1208 - Old `reformat_msg` with `log_format` kwarg (replaced in Task 1) 1209 - Old `syslog_severity_map` dict with string keys (replaced by `SYSLOG_SEVERITY`) 1210 - Old `julia_bin` const (replaced by `JULIA_BIN`) 1211 - Old `get_color` function (replaced by new `get_color` in Task 3 — verify no duplication) 1212 - Commented-out blocks in old `format_syslog` 1213 - Orphaned section-separator comment blocks (`# ----...`) 1214 1215 - [ ] **Step 2: Verify BazerUtils.jl imports** 1216 1217 Ensure `src/BazerUtils.jl` imports include `Logging.Error` and that no unused imports remain: 1218 1219 ```julia 1220 import Dates: format, now, Dates, ISODateTimeFormat 1221 import Logging: global_logger, Logging, Logging.Debug, Logging.Info, Logging.Warn, Logging.Error 1222 import LoggingExtras: EarlyFilteredLogger, FormatLogger, MinLevelLogger, TeeLogger 1223 import JSON: JSON 1224 import Tables: Tables 1225 import CodecZlib: CodecZlib 1226 ``` 1227 1228 - [ ] **Step 3: Run full test suite** 1229 1230 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 1231 Expected: ALL tests pass. 1232 1233 - [ ] **Step 4: Commit** 1234 1235 ```bash 1236 git add src/CustomLogger.jl src/BazerUtils.jl 1237 git commit -m "chore: remove dead code and clean up imports" 1238 ``` 1239 1240 --- 1241 1242 ### Task 8: Version bump to v0.11.0 1243 1244 **Files:** 1245 - Modify: `Project.toml` 1246 1247 - [ ] **Step 1: Bump version** 1248 1249 Change `version = "0.10.1"` to `version = "0.11.0"` in `Project.toml`. 1250 1251 - [ ] **Step 2: Add TODO comment for log4j deprecation timeline** 1252 1253 Add a comment in `src/CustomLogger.jl` near the `resolve_format` function: 1254 1255 ```julia 1256 # TODO (March 2027): Remove :log4j alias for :oneline. Rename :log4j_standard to :log4j. 1257 # This is a breaking change requiring a major version bump. 1258 ``` 1259 1260 - [ ] **Step 3: Run full test suite one final time** 1261 1262 Run: `cd /Users/loulou/Dropbox/projects_code/julia_packages/BazerUtils.jl && julia --project -e 'using Pkg; Pkg.test()'` 1263 Expected: ALL tests pass. 1264 1265 - [ ] **Step 4: Commit** 1266 1267 ```bash 1268 git add Project.toml src/CustomLogger.jl 1269 git commit -m "chore: bump version to v0.11.0 (breaking: cascading_loglevels default, :log4j renamed)" 1270 ```