Logging, runtime tracing, and the obs performance profiler for OCaml
0

Configure Feed

Select the types of activity you want to include in your feed.

ocaml-observe / lib / observe.ml
13 kB 379 lines
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 SPDX-License-Identifier: ISC 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 - Tracing log sources: [--trace FILE] *) 13 14open Cmdliner 15 16(** {1 Verbosity Flags} *) 17 18let observability_docs = "OBSERVABILITY" 19 20let quiet = 21 let doc = 22 "Suppress non-error log records. This still allows explicit application \ 23 output written by the command itself." 24 in 25 Arg.(value & flag & info [ "q"; "quiet" ] ~docs:observability_docs ~doc) 26 27let verbosity = 28 let doc = 29 "Increase log verbosity. Use once for info, twice for debug, and three \ 30 times to also enable log sources whose name ends in $(b,.tracing)." 31 in 32 Arg.(value & flag_all & info [ "v"; "verbose" ] ~docs:observability_docs ~doc) 33 34let level_of_verbosity ~quiet ~verbosity = 35 if quiet then Some Logs.Error 36 else 37 match List.length verbosity with 38 | 0 -> Some Logs.Warning 39 | 1 -> Some Logs.Info 40 | _ -> Some Logs.Debug 41 42let enable_tracing ~verbosity = List.length verbosity >= 3 43 44(** {1 Log Spec Parsing} *) 45 46type directive = 47 | Global of Logs.level option 48 | Source of string * Logs.level option 49 50let parse_directive s = 51 let s = String.trim s in 52 if s = "" then None 53 else 54 match String.index_opt s ':' with 55 | None -> ( 56 (* Bare level = global setting *) 57 match Logs.level_of_string s with 58 | Ok lvl -> Some (Global lvl) 59 | Error _ -> 60 Fmt.epr "Warning: invalid log level '%s'@." s; 61 None) 62 | Some i -> ( 63 let src = String.sub s 0 i in 64 let lvl_s = String.sub s (i + 1) (String.length s - i - 1) in 65 match Logs.level_of_string lvl_s with 66 | Ok lvl -> Some (Source (src, lvl)) 67 | Error _ -> 68 Fmt.epr "Warning: invalid log level '%s' for source '%s'@." lvl_s 69 src; 70 None) 71 72let parse_log_spec spec = 73 let directives = String.split_on_char ',' spec in 74 let global = ref None in 75 let sources = ref [] in 76 List.iter 77 (fun d -> 78 match parse_directive d with 79 | Some (Global lvl) -> global := lvl 80 | Some (Source (src, lvl)) -> sources := (src, lvl) :: !sources 81 | None -> ()) 82 directives; 83 (!global, List.rev !sources) 84 85(** {1 Source Filtering} *) 86 87let apply_source_overrides specs = 88 let all_sources = Logs.Src.list () in 89 List.iter 90 (fun (prefix, lvl) -> 91 List.iter 92 (fun src -> 93 let name = Logs.Src.name src in 94 (* Match exact name or prefix with dot separator *) 95 if 96 String.equal name prefix 97 || String.length name > String.length prefix 98 && String.sub name 0 (String.length prefix) = prefix 99 && name.[String.length prefix] = '.' 100 then Logs.Src.set_level src lvl) 101 all_sources) 102 specs 103 104let configure_tracing_sources ~enable = 105 let level = if enable then Some Logs.Debug else Some Logs.Warning in 106 List.iter 107 (fun src -> 108 let name = Logs.Src.name src in 109 (* Match *.tracing sources *) 110 if 111 String.length name > 8 112 && String.sub name (String.length name - 8) 8 = ".tracing" 113 then Logs.Src.set_level src level) 114 (Logs.Src.list ()) 115 116(** {1 Trace File Reporter} *) 117 118let level_string = function 119 | Logs.App -> "app" 120 | Logs.Error -> "error" 121 | Logs.Warning -> "warning" 122 | Logs.Info -> "info" 123 | Logs.Debug -> "debug" 124 125let write_json_trace ppf ~ts ~name ~level_s msg k = 126 Fmt.pf ppf {|{"ts":"%s","src":"%s","level":"%s","msg":"%s"}@.|} ts name 127 level_s (String.escaped msg); 128 k () 129 130let write_tracing_entry ~json ppf ts name level fmt k = 131 if json then 132 Fmt.kstr 133 (fun msg -> 134 write_json_trace ppf ~ts ~name ~level_s:(level_string level) msg k) 135 fmt 136 else Fmt.kpf (fun _ -> k ()) ppf ("%s %s " ^^ fmt ^^ "@.") ts name 137 138let is_tracing_src name = 139 String.length name > 8 140 && String.sub name (String.length name - 8) 8 = ".tracing" 141 142let report_tracing ~json ppf ~name level ~over k msgf = 143 msgf @@ fun ?header:_ ?tags:_ fmt -> 144 let k _ = 145 over (); 146 k () 147 in 148 let ts = Ptime_clock.now () |> Ptime.to_rfc3339 in 149 write_tracing_entry ~json ppf ts name level fmt k 150 151let trace_reporter ~json file_path = 152 let oc = open_out file_path in 153 let ppf = Format.formatter_of_out_channel oc in 154 let report src level ~over k msgf = 155 let name = Logs.Src.name src in 156 if is_tracing_src name then 157 report_tracing ~json ppf ~name level ~over k msgf 158 else ( 159 over (); 160 k ()) 161 in 162 { Logs.report } 163 164(** {1 Cmdliner Terms} *) 165 166let log app_name = 167 let env_name = String.uppercase_ascii app_name ^ "_LOG" in 168 let env = 169 Fmt.kstr 170 (fun doc -> Cmd.Env.info env_name ~doc) 171 "Log configuration for %s. Format: LEVEL[,SRC:LEVEL,...]. Example: \ 172 debug,tls.tracing:warning" 173 app_name 174 in 175 let doc = 176 "Set log level and per-source overrides. Format: LEVEL[,SRC:LEVEL,...]. \ 177 Examples: $(b,debug), $(b,info,tls.tracing:warning), $(b,conpool:debug). \ 178 Source names match exactly or by dot-separated prefix. Levels: error, \ 179 warning, info, debug." 180 in 181 Arg.( 182 value 183 & opt (some string) None 184 & info [ "log" ] ~env ~docs:observability_docs ~doc ~docv:"SPEC") 185 186let trace_file = 187 let doc = 188 "Write log records from sources whose name ends in $(b,.tracing) to \ 189 $(docv). This is the old logs-side protocol trace stream: useful for \ 190 verbose hexdumps and parser state logs. It is not a memtrace allocation \ 191 trace and not an ocaml-probe Runtime_events capture." 192 in 193 Arg.( 194 value 195 & opt (some string) None 196 & info [ "trace" ] ~docs:observability_docs ~doc ~docv:"FILE") 197 198let json = 199 let doc = 200 "Enable JSON mode. By default logs are emitted with json-logs; commands \ 201 that produce their own JSON may pass a custom reporter to keep stdout \ 202 reserved for command output." 203 in 204 Arg.(value & flag & info [ "json" ] ~docs:observability_docs ~doc) 205 206let err_invalid_tag s = 207 Error (`Msg (Fmt.str "Invalid tag format '%s', expected KEY=VALUE" s)) 208 209let parse_tag s = 210 match String.index_opt s '=' with 211 | None -> err_invalid_tag s 212 | Some i -> 213 let key = String.sub s 0 i in 214 let value = String.sub s (i + 1) (String.length s - i - 1) in 215 Ok (key, value) 216 217let log_tag_conv = Arg.conv (parse_tag, fun ppf (k, v) -> Fmt.pf ppf "%s=%s" k v) 218 219let log_tags = 220 let doc = 221 "Add a base tag to JSON log output. Can be repeated. Format: KEY=VALUE. \ 222 Tags are attached to every JSON log record. Example: $(b,--log-tag \ 223 env=prod --log-tag region=us-east-1)." 224 in 225 Arg.( 226 value & opt_all log_tag_conv [] 227 & info [ "log-tag" ] ~docs:observability_docs ~doc ~docv:"TAG") 228 229(** {1 Setup} *) 230 231type json_reporter = 232 app:string -> base:(string * string) list -> unit -> Logs.reporter 233 234let default_json_reporter : json_reporter = 235 fun ~app ~base () -> Json_logs.reporter ~app ~base () 236 237let reporter ?(dst = Fmt.stderr) () = Logs_fmt.reporter ~app:dst ~dst () 238 239(** {1 Test Setup} *) 240 241let setup_test ?(level = Logs.Debug) () = 242 (* Check TEST_LOG environment variable for level override. Supports 243 RUST_LOG-style syntax: "level" or "level,src:level,src:level" *) 244 let global_level, source_overrides = 245 match Sys.getenv_opt "TEST_LOG" with 246 | None -> (Some level, []) 247 | Some spec -> 248 let global, sources = parse_log_spec spec in 249 let lvl = match global with Some l -> Some l | None -> Some level in 250 (lvl, sources) 251 in 252 Fmt_tty.setup_std_outputs (); 253 Logs.set_level global_level; 254 Logs.set_reporter (Logs_fmt.reporter ()); 255 apply_source_overrides source_overrides 256 257type log_flags = { quiet : bool; json : bool } 258 259let current_json = ref false 260let json_enabled () = !current_json 261 262(** POSIX NO_COLOR: any non-empty value disables ANSI colour on stdout/stderr. 263 Exposed as a cmdliner flag so it is documented in [--help] and composed into 264 the log setup pipeline alongside [Fmt_cli.style_renderer]. An explicit 265 [--color=always] still wins — same precedence as POSIX. See 266 https://no-color.org. *) 267let no_color_term = 268 let env = 269 Cmd.Env.info "NO_COLOR" 270 ~doc: 271 "If set to any non-empty value, disable ANSI colour in all output. \ 272 Overridden by an explicit --color=always. See https://no-color.org." 273 in 274 Arg.( 275 value & flag 276 & info [ "no-color" ] ~env ~docs:observability_docs 277 ~doc:"Disable ANSI colour.") 278 279let setup_log ~json_reporter app_name style_renderer no_color { quiet; json } 280 verbosity log_spec trace_file base_tags = 281 Observe_memtrace.Memtrace.trace_if_requested ~context:app_name (); 282 (* Adopt an inherited trace context, if a parent handed one down through 283 OBS_TRACEPARENT, so this process's spans descend from the remote span that 284 started the work. A no-op when the variable is absent. *) 285 (match 286 Option.bind 287 (Sys.getenv_opt Probe.Traceparent.env_var) 288 Probe.Traceparent.of_string 289 with 290 | Some tp -> Probe.adopt tp 291 | None -> ()); 292 (* Like MEMTRACE, OBS_COLLECTOR is honored with no tool-local flag: when obs 293 run sets it, this process becomes a producer and streams its runtime_events 294 -- and, unless the program already drives memtrace itself, its allocation 295 trace -- to the collector for the duration of the run. *) 296 (match Observe_producer.Forwarder.start_if_requested ~memtrace:true () with 297 | None -> () 298 | Some stop -> at_exit stop); 299 current_json := json; 300 let style_renderer = 301 match style_renderer with 302 | Some _ -> style_renderer (* explicit --color wins *) 303 | None when no_color -> Some `None 304 | None -> None 305 in 306 Fmt_tty.setup_std_outputs ?style_renderer (); 307 (* Parse --log / <APP>_LOG spec *) 308 let global_override, source_overrides = 309 match log_spec with Some spec -> parse_log_spec spec | None -> (None, []) 310 in 311 (* Set reporter: JSON (via json_reporter) or Fmt (with optional trace file) *) 312 let main_reporter = 313 if json then json_reporter ~app:app_name ~base:base_tags () 314 else Logs_fmt.reporter () 315 in 316 let reporter = 317 match trace_file with 318 | Some path -> 319 (* Combine main reporter with trace file reporter *) 320 let trace_rep = trace_reporter ~json path in 321 let report src level ~over k msgf = 322 (* Send to both reporters *) 323 trace_rep.Logs.report src level 324 ~over:(fun () -> ()) 325 (fun () -> main_reporter.Logs.report src level ~over k msgf) 326 msgf 327 in 328 { Logs.report } 329 | None -> main_reporter 330 in 331 Logs.set_reporter reporter; 332 (* Set global level: --log override > -q/-v flags *) 333 let level = 334 match global_override with 335 | Some lvl -> Some lvl 336 | None -> level_of_verbosity ~quiet ~verbosity 337 in 338 Logs.set_level level; 339 (* Configure tracing sources: -vvv or --trace enables them *) 340 let enable_trace = enable_tracing ~verbosity || Option.is_some trace_file in 341 configure_tracing_sources ~enable:enable_trace; 342 (* Apply per-source overrides from --log *) 343 apply_source_overrides source_overrides 344 345let setup ?json_reporter app_name = 346 let json_reporter = 347 match json_reporter with 348 | None -> Some default_json_reporter (* default: use json-logs *) 349 | Some None -> None (* explicitly disabled *) 350 | Some (Some r) -> Some r (* custom reporter *) 351 in 352 match json_reporter with 353 | None -> 354 (* JSON disabled: no --json flag or --log-tag exposed *) 355 let go style_renderer no_color flags verbosity log_spec trace_file = 356 setup_log 357 ~json_reporter:(fun ~app:_ ~base:_ () -> Logs_fmt.reporter ()) 358 app_name style_renderer no_color flags verbosity log_spec trace_file 359 [] 360 in 361 Term.( 362 const go $ Fmt_cli.style_renderer () $ no_color_term 363 $ Term.(const (fun quiet -> { quiet; json = false }) $ quiet) 364 $ verbosity $ log app_name $ trace_file) 365 | Some r -> 366 (* JSON enabled: expose --json flag and --log-tag *) 367 let flags_term = 368 Term.(const (fun quiet json -> { quiet; json }) $ quiet $ json) 369 in 370 let go style_renderer no_color flags verbosity log_spec trace_file 371 log_tags = 372 setup_log ~json_reporter:r app_name style_renderer no_color flags 373 verbosity log_spec trace_file log_tags 374 in 375 Term.( 376 const go $ Fmt_cli.style_renderer () $ no_color_term $ flags_term 377 $ verbosity $ log app_name $ trace_file $ log_tags) 378 379module Memtrace = Observe_memtrace.Memtrace