Cmdliner terms for ergonomic logging configuration
at main 304 lines 9.8 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 SPDX-License-Identifier: MIT 4 ---------------------------------------------------------------------------*) 5 6(** Cmdliner terms for ergonomic logging configuration. 7 8 Provides RUST_LOG-style configuration via: 9 - Verbosity flags: [-q], [-v], [-vv], [-vvv] 10 - Log spec: [--log=level,src:level,...] 11 - JSON output: [--json] 12 - Protocol tracing: [--trace FILE] *) 13 14open Cmdliner 15 16(** {1 Verbosity Flags} *) 17 18let quiet = 19 let doc = "Suppress output except errors." in 20 Arg.(value & flag & info [ "q"; "quiet" ] ~doc) 21 22let verbosity = 23 let doc = 24 "Increase verbosity. Use once for info, twice for debug, three times to \ 25 enable protocol tracing." 26 in 27 Arg.(value & flag_all & info [ "v"; "verbose" ] ~doc) 28 29let level_of_verbosity ~quiet ~verbosity = 30 if quiet then Some Logs.Error 31 else 32 match List.length verbosity with 33 | 0 -> Some Logs.Warning 34 | 1 -> Some Logs.Info 35 | _ -> Some Logs.Debug 36 37let enable_tracing ~verbosity = List.length verbosity >= 3 38 39(** {1 Log Spec Parsing} *) 40 41type directive = 42 | Global of Logs.level option 43 | Source of string * Logs.level option 44 45let parse_directive s = 46 let s = String.trim s in 47 if s = "" then None 48 else 49 match String.index_opt s ':' with 50 | None -> ( 51 (* Bare level = global setting *) 52 match Logs.level_of_string s with 53 | Ok lvl -> Some (Global lvl) 54 | Error _ -> 55 Fmt.epr "Warning: invalid log level '%s'@." s; 56 None) 57 | Some i -> ( 58 let src = String.sub s 0 i in 59 let lvl_s = String.sub s (i + 1) (String.length s - i - 1) in 60 match Logs.level_of_string lvl_s with 61 | Ok lvl -> Some (Source (src, lvl)) 62 | Error _ -> 63 Fmt.epr "Warning: invalid log level '%s' for source '%s'@." lvl_s 64 src; 65 None) 66 67let parse_log_spec spec = 68 let directives = String.split_on_char ',' spec in 69 let global = ref None in 70 let sources = ref [] in 71 List.iter 72 (fun d -> 73 match parse_directive d with 74 | Some (Global lvl) -> global := lvl 75 | Some (Source (src, lvl)) -> sources := (src, lvl) :: !sources 76 | None -> ()) 77 directives; 78 (!global, List.rev !sources) 79 80(** {1 Source Filtering} *) 81 82let apply_source_overrides specs = 83 let all_sources = Logs.Src.list () in 84 List.iter 85 (fun (prefix, lvl) -> 86 List.iter 87 (fun src -> 88 let name = Logs.Src.name src in 89 (* Match exact name or prefix with dot separator *) 90 if 91 String.equal name prefix 92 || String.length name > String.length prefix 93 && String.sub name 0 (String.length prefix) = prefix 94 && name.[String.length prefix] = '.' 95 then Logs.Src.set_level src lvl) 96 all_sources) 97 specs 98 99let configure_tracing_sources ~enable = 100 let level = if enable then Some Logs.Debug else Some Logs.Warning in 101 List.iter 102 (fun src -> 103 let name = Logs.Src.name src in 104 (* Match *.tracing sources *) 105 if 106 String.length name > 8 107 && String.sub name (String.length name - 8) 8 = ".tracing" 108 then Logs.Src.set_level src level) 109 (Logs.Src.list ()) 110 111(** {1 Trace File Reporter} *) 112 113let level_string = function 114 | Logs.App -> "app" 115 | Logs.Error -> "error" 116 | Logs.Warning -> "warning" 117 | Logs.Info -> "info" 118 | Logs.Debug -> "debug" 119 120let write_json_trace ppf ~ts ~name ~level_s msg k = 121 Fmt.pf ppf {|{"ts":"%s","src":"%s","level":"%s","msg":"%s"}@.|} ts name 122 level_s (String.escaped msg); 123 k () 124 125let write_tracing_entry ~json ppf ts name level fmt k = 126 if json then 127 Fmt.kstr 128 (fun msg -> 129 write_json_trace ppf ~ts ~name ~level_s:(level_string level) msg k) 130 fmt 131 else Fmt.kpf (fun _ -> k ()) ppf ("%s %s " ^^ fmt ^^ "@.") ts name 132 133let is_tracing_src name = 134 String.length name > 8 135 && String.sub name (String.length name - 8) 8 = ".tracing" 136 137let report_tracing ~json ppf ~name level ~over k msgf = 138 msgf @@ fun ?header:_ ?tags:_ fmt -> 139 let k _ = 140 over (); 141 k () 142 in 143 let ts = Ptime_clock.now () |> Ptime.to_rfc3339 in 144 write_tracing_entry ~json ppf ts name level fmt k 145 146let trace_reporter ~json file_path = 147 let oc = open_out file_path in 148 let ppf = Format.formatter_of_out_channel oc in 149 let report src level ~over k msgf = 150 let name = Logs.Src.name src in 151 if is_tracing_src name then 152 report_tracing ~json ppf ~name level ~over k msgf 153 else ( 154 over (); 155 k ()) 156 in 157 { Logs.report } 158 159(** {1 Cmdliner Terms} *) 160 161let log_term app_name = 162 let env_name = String.uppercase_ascii app_name ^ "_LOG" in 163 let env = 164 Cmd.Env.info env_name 165 ~doc: 166 (Fmt.str 167 "Log configuration for %s. Format: LEVEL[,SRC:LEVEL,...]. Example: \ 168 debug,tls.tracing:warning" 169 app_name) 170 in 171 let doc = 172 "Set log level and per-source overrides. Format: LEVEL[,SRC:LEVEL,...]. \ 173 Examples: $(b,debug), $(b,info,tls.tracing:warning), $(b,conpool:debug). \ 174 Levels: error, warning, info, debug." 175 in 176 Arg.(value & opt (some string) None & info [ "log" ] ~env ~doc ~docv:"SPEC") 177 178let trace_file = 179 let doc = 180 "Write protocol traces to $(docv). Automatically enables tracing sources." 181 in 182 Arg.(value & opt (some string) None & info [ "trace" ] ~doc ~docv:"FILE") 183 184let json = 185 let doc = "Output as JSON (affects both data output and logs)." in 186 Arg.(value & flag & info [ "json" ] ~doc) 187 188let err_invalid_tag s = 189 Error (`Msg (Fmt.str "Invalid tag format '%s', expected KEY=VALUE" s)) 190 191let parse_tag s = 192 match String.index_opt s '=' with 193 | None -> err_invalid_tag s 194 | Some i -> 195 let key = String.sub s 0 i in 196 let value = String.sub s (i + 1) (String.length s - i - 1) in 197 Ok (key, value) 198 199let log_tag_conv = Arg.conv (parse_tag, fun ppf (k, v) -> Fmt.pf ppf "%s=%s" k v) 200 201let log_tags = 202 let doc = 203 "Add a tag to JSON log output. Can be repeated. Format: KEY=VALUE. \ 204 Example: $(b,--log-tag env=prod --log-tag region=us-east-1)." 205 in 206 Arg.(value & opt_all log_tag_conv [] & info [ "log-tag" ] ~doc ~docv:"TAG") 207 208(** {1 Setup} *) 209 210type json_reporter = 211 app:string -> base:(string * string) list -> unit -> Logs.reporter 212 213let default_json_reporter : json_reporter = 214 fun ~app ~base () -> Json_logs.reporter ~app ~base () 215 216(** {1 Test Setup} *) 217 218let setup_test ?(level = Logs.Debug) () = 219 (* Check TEST_LOG environment variable for level override. 220 Supports RUST_LOG-style syntax: "level" or "level,src:level,src:level" *) 221 let global_level, source_overrides = 222 match Sys.getenv_opt "TEST_LOG" with 223 | None -> (Some level, []) 224 | Some spec -> 225 let global, sources = parse_log_spec spec in 226 let lvl = match global with Some l -> Some l | None -> Some level in 227 (lvl, sources) 228 in 229 Fmt_tty.setup_std_outputs (); 230 Logs.set_level global_level; 231 Logs.set_reporter (Logs_fmt.reporter ()); 232 apply_source_overrides source_overrides 233 234type log_flags = { quiet : bool; json : bool } 235 236let setup_log ~json_reporter app_name style_renderer { quiet; json } verbosity 237 log_spec trace_file base_tags = 238 Fmt_tty.setup_std_outputs ?style_renderer (); 239 (* Parse --log / <APP>_LOG spec *) 240 let global_override, source_overrides = 241 match log_spec with Some spec -> parse_log_spec spec | None -> (None, []) 242 in 243 (* Set reporter: JSON (via json_reporter) or Fmt (with optional trace file) *) 244 let main_reporter = 245 if json then json_reporter ~app:app_name ~base:base_tags () 246 else Logs_fmt.reporter () 247 in 248 let reporter = 249 match trace_file with 250 | Some path -> 251 (* Combine main reporter with trace file reporter *) 252 let trace_rep = trace_reporter ~json path in 253 let report src level ~over k msgf = 254 (* Send to both reporters *) 255 trace_rep.Logs.report src level 256 ~over:(fun () -> ()) 257 (fun () -> main_reporter.Logs.report src level ~over k msgf) 258 msgf 259 in 260 { Logs.report } 261 | None -> main_reporter 262 in 263 Logs.set_reporter reporter; 264 (* Set global level: --log override > -q/-v flags *) 265 let level = 266 match global_override with 267 | Some lvl -> Some lvl 268 | None -> level_of_verbosity ~quiet ~verbosity 269 in 270 Logs.set_level level; 271 (* Configure tracing sources: -vvv or --trace enables them *) 272 let enable_trace = enable_tracing ~verbosity || Option.is_some trace_file in 273 configure_tracing_sources ~enable:enable_trace; 274 (* Apply per-source overrides from --log *) 275 apply_source_overrides source_overrides 276 277let setup ?json_reporter app_name = 278 let json_reporter = 279 match json_reporter with 280 | None -> Some default_json_reporter (* default: use json-logs *) 281 | Some None -> None (* explicitly disabled *) 282 | Some (Some r) -> Some r (* custom reporter *) 283 in 284 let flags_term = 285 Term.(const (fun quiet json -> { quiet; json }) $ quiet $ json) 286 in 287 match json_reporter with 288 | None -> 289 (* JSON disabled: no --json flag or --log-tag exposed *) 290 let setup_log' style_renderer flags verbosity log_spec trace_file = 291 setup_log 292 ~json_reporter:(fun ~app:_ ~base:_ () -> Logs_fmt.reporter ()) 293 app_name style_renderer flags verbosity log_spec trace_file [] 294 in 295 Term.( 296 const setup_log' $ Fmt_cli.style_renderer () 297 $ Term.(const (fun quiet -> { quiet; json = false }) $ quiet) 298 $ verbosity $ log_term app_name $ trace_file) 299 | Some r -> 300 (* JSON enabled: expose --json flag and --log-tag *) 301 Term.( 302 const (setup_log ~json_reporter:r app_name) 303 $ Fmt_cli.style_renderer () $ flags_term $ verbosity $ log_term app_name 304 $ trace_file $ log_tags)