Yet another structured logging library
0

Configure Feed

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

progress on erlang logger

author
ollie
date (Jul 25, 2026, 10:35 PM +0200) commit 57636f2f parent c63e78d5 change-id tmolvrql
+413 -167
+4 -13
dev/witness_dev.gleam
··· 1 - import gleam/io 2 1 import witness 3 2 4 3 pub fn main() -> Nil { ··· 17 16 /// 18 17 fn configure_logging() { 19 18 // create a new config 20 - witness.empty_config() 21 - // log formatted text to stdout 22 - |> witness.with_sink(witness.Text, fn(level, message) { 23 - io.println(witness.level_to_badge(level) <> ": " <> message) 24 - }) 25 - // log as json 26 - |> witness.with_sink(witness.Json, fn(_, message) { 27 - // ideally you'd log this to a file using, for example, the erlang logger 28 - io.println(message) 29 - }) 30 - // apply this config globally 31 - |> witness.set_config() 19 + witness.new("witness-dev") 20 + |> witness.with_console(witness.Debug, witness.Text) 21 + // |> witness.add_file(witness.Warning, witness.Json, "./testing.log") 22 + |> witness.configure 32 23 }
+7 -2
manifest.toml
··· 1 - # This file was generated by Gleam 2 - # You typically do not need to edit this file 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 3 8 4 9 packages = [ 5 10 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
+161 -145
src/witness.gleam
··· 14 14 // See the Licence for the specific language governing permissions and limitations. 15 15 16 16 import gleam/bool 17 - import gleam/dict 18 17 import gleam/float 18 + import gleam/function 19 19 import gleam/int 20 - import gleam/io 21 - import gleam/json 22 20 import gleam/list 23 - import gleam/option 24 21 import gleam/string 25 - import gleam/time/calendar 26 - import gleam/time/timestamp 27 22 28 23 pub type Level { 29 24 Debug ··· 50 45 message message: String, 51 46 fields fields: List(Field), 52 47 ) -> Nil { 53 - let config = get_config(default_config()) 48 + let config = get_config(default_config) 54 49 55 50 // prepend global fields 56 51 let fields = list.append(config.global_fields, fields) ··· 58 53 // prepend process level fields 59 54 let fields = list.append(get_process_fields([]), fields) 60 55 61 - // log to em 62 - config.sinks 63 - |> dict.each(fn(format, sinks) { 64 - let message = format_message(level, format, message, fields) 65 - 66 - list.map(sinks, fn(sink) { sink(level, message) }) 67 - }) 68 - 69 - Nil 56 + do_log(level, message, config.namespace, fields) 70 57 } 71 58 72 - fn format_message( 59 + @external(erlang, "witness_ffi", "do_log") 60 + @external(javascript, "./witness.ffi.mjs", "do_log") 61 + fn do_log( 73 62 level: Level, 74 - format: Format, 75 63 message: String, 64 + namespace: String, 76 65 fields: List(Field), 77 - ) -> String { 78 - case format { 79 - Json -> { 80 - [ 81 - string( 82 - "timestamp", 83 - timestamp.to_rfc3339(timestamp.system_time(), calendar.utc_offset), 84 - ), 85 - string("level", level_to_string(level)), 86 - string("message", message), 87 - ..fields 88 - ] 89 - |> list.map(field_to_json) 90 - |> json.object 91 - |> json.to_string 92 - } 93 - Text -> { 94 - let message = 95 - timestamp_to_short_string(timestamp.system_time()) <> " " <> message 96 - 97 - fields 98 - |> list.fold(message, fn(acc, field) { 99 - acc <> "\n " <> fields_to_string(field) 100 - }) 101 - } 102 - } 103 - } 104 - 105 - /// ----------------------------------------- 106 - /// Turn a log level into a "[LEVEL]" string. 107 - /// 108 - pub fn level_to_badge(level: Level) -> String { 109 - case level { 110 - Debug -> "[DEBUG]" 111 - Info -> "[INFO]" 112 - Warning -> "[WARN]" 113 - Error -> "[ERROR]" 114 - } 115 - } 116 - 117 - fn timestamp_to_short_string(timestamp: timestamp.Timestamp) -> String { 118 - let #(_, calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds: _)) = 119 - timestamp.to_calendar(timestamp, calendar.local_offset()) 120 - 121 - string.pad_start(int.to_string(hours), to: 2, with: "0") 122 - <> ":" 123 - <> string.pad_start(int.to_string(minutes), to: 2, with: "0") 124 - <> ":" 125 - <> string.pad_start(int.to_string(seconds), to: 2, with: "0") 126 - } 127 - 128 - fn level_to_string(level: Level) -> String { 129 - case level { 130 - Debug -> "debug" 131 - Info -> "info" 132 - Warning -> "warning" 133 - Error -> "error" 134 - } 135 - } 66 + ) -> Nil 136 67 137 68 // FIELDS ----------------------------------------------------------------------- 138 69 ··· 152 83 WInt(name: String, value: Int) 153 84 WFloat(name: String, value: Float) 154 85 WBool(name: String, value: Bool) 155 - } 156 - 157 - fn field_to_json(field: Field) -> #(String, json.Json) { 158 - case field { 159 - WString(name:, value:) -> #(name, json.string(value)) 160 - WInt(name:, value:) -> #(name, json.int(value)) 161 - WFloat(name:, value:) -> #(name, json.float(value)) 162 - WBool(name:, value:) -> #(name, json.bool(value)) 163 - } 164 - } 165 - 166 - fn fields_to_string(field: Field) -> String { 167 - case field { 168 - WString(name:, value:) -> { 169 - name <> ": " <> value 170 - } 171 - WInt(name:, value:) -> { 172 - name <> ": " <> int.to_string(value) 173 - } 174 - WFloat(name:, value:) -> { 175 - name <> ": " <> float.to_string(value) 176 - } 177 - WBool(name:, value:) -> { 178 - name <> ": " <> bool.to_string(value) 179 - } 180 - } 181 86 } 182 87 183 88 /// -------------------------------------------------- ··· 238 143 239 144 // CONFIG ----------------------------------------------------------------------- 240 145 146 + // /// ----------------------------------------------------------------------------- 147 + // /// The default config logging to `io.println` with format `witness.Text` 148 + // /// 149 + // pub fn default_config() -> Config { 150 + // dict.new() 151 + // |> dict.upsert( 152 + // Text, 153 + // upsert_sink(fn(level, message) { 154 + // io.println(level_to_badge(level) <> ": " <> message) 155 + // }), 156 + // ) 157 + // |> Config([]) 158 + // } 159 + // 160 + // fn upsert_sink(sink: Sink) -> fn(option.Option(List(Sink))) -> List(Sink) { 161 + // fn(x) { 162 + // case x { 163 + // option.Some(x) -> [sink, ..x] 164 + // option.None -> [sink] 165 + // } 166 + // } 167 + // } 168 + 241 169 /// ----------------------------------------------------------------------------- 242 - /// The default config logging to `io.println` with format `witness.Text` 170 + /// Create a new config. 171 + /// The namespace should be the name of your application / library. 172 + /// 173 + /// # Erlang Warning 174 + /// Since each `file` and `custom` sink / handler needs a unique id, atoms will 175 + /// be created dynamically. 176 + /// 177 + /// Generating too many atoms will result in the atom table getting filled and 178 + /// causing the entire virtual machine to crash. 243 179 /// 244 - pub fn default_config() -> Config { 245 - dict.new() 246 - |> dict.upsert( 247 - Text, 248 - upsert_sink(fn(level, message) { 249 - io.println(level_to_badge(level) <> ": " <> message) 250 - }), 251 - ) 252 - |> Config([]) 180 + /// 181 + pub fn new(namespace namespace: String) -> Config { 182 + Config(namespace:, sinks: [], global_fields: []) 253 183 } 254 184 255 - fn upsert_sink(sink: Sink) -> fn(option.Option(List(Sink))) -> List(Sink) { 256 - fn(x) { 257 - case x { 258 - option.Some(x) -> [sink, ..x] 259 - option.None -> [sink] 260 - } 261 - } 185 + pub opaque type Config { 186 + Config(namespace: String, sinks: List(Sink), global_fields: List(Field)) 187 + } 188 + 189 + const default_config = Config( 190 + namespace: "default", 191 + sinks: [Console(level: Debug, format: Text)], 192 + global_fields: [], 193 + ) 194 + 195 + /// ----------------------------------------------------------------------------- 196 + /// Apply your configuration globally. 197 + /// 198 + /// # Erlang 199 + /// **Only call this once at startup!** 200 + /// 201 + /// Since each `file` and `custom` sink / handler needs a unique id, atoms will 202 + /// be created dynamically. 203 + /// 204 + /// Generating too many atoms will result in the atom table getting filled and 205 + /// causing the entire virtual machine to crash. 206 + /// 207 + @external(erlang, "witness_ffi", "configure") 208 + pub fn configure(config: Config) -> Nil { 209 + todo as "javascript configure" 262 210 } 263 211 264 - pub opaque type Config { 265 - Config(sinks: dict.Dict(Format, List(Sink)), global_fields: List(Field)) 212 + pub opaque type Sink { 213 + File(level: Level, format: Format, path: String) 214 + Console(level: Level, format: Format) 215 + Custom(level: Level, format: Format, handler: fn(String) -> Nil) 266 216 } 267 217 268 218 /// The currently available formats ··· 308 258 /// |> witness.set_config 309 259 /// ``` 310 260 /// 311 - @external(erlang, "witness_ffi", "set_config") 261 + @external(erlang, "witness_ffi", "configure") 312 262 @external(javascript, "./witness.ffi.mjs", "set_config") 313 263 pub fn set_config(config: Config) -> Nil 314 264 265 + @target(erlang) 315 266 /// ----------------------------------------------------------------------------- 316 - /// Get a new empty config 267 + /// Add a file logger to the logger configuration. Each file logger will get its 268 + /// own `logger_std_h` handler added to the `logger` configuration. 317 269 /// 318 - /// # Example 270 + pub fn add_file( 271 + config: Config, 272 + level: Level, 273 + format: Format, 274 + path: String, 275 + ) -> Config { 276 + Config(..config, sinks: [File(level:, format:, path:), ..config.sinks]) 277 + } 278 + 279 + /// ----------------------------------------------------------------------------- 280 + /// Add console logging to your configuration. 319 281 /// 320 - /// ```gleam 321 - /// witness.empty_config() 322 - /// |> with_sink(witness.Json, fn(level, message) { io.println(message) }) 323 - /// |> witness.set_config 324 - /// ``` 282 + /// There may only be one console logger, adding a new one will replace the old one. 325 283 /// 326 - pub fn empty_config() -> Config { 327 - Config(dict.new(), []) 284 + pub fn with_console(config: Config, level: Level, format: Format) -> Config { 285 + // remove potentially existing console sink 286 + let sinks = 287 + list.filter(config.sinks, fn(sink) { 288 + case sink { 289 + Console(..) -> False 290 + _ -> True 291 + } 292 + }) 293 + 294 + Config(..config, sinks: [Console(level:, format:), ..sinks]) 328 295 } 329 - 330 - pub type Sink = 331 - fn(Level, String) -> Nil 332 296 333 297 /// ----------------------------------------------------------------------------- 334 - /// Add a sink with a specific formatting to a config. 298 + /// Add a custom handler to your configuration. 335 299 /// 336 300 /// # Example 337 301 /// 338 302 /// ```gleam 339 - /// witness.empty_config() 340 - /// |> with_sink(witness.Json, fn(level, message) { io.println(message) }) 341 - /// |> witness.set_config 303 + /// witness.new("my-app") 304 + /// |> witness.add_custom(witness.Error, witness.Text, fn(message) { 305 + /// todo as "do something with the log message" 306 + /// }) 307 + /// |> witness.configure 342 308 /// ``` 343 309 /// 344 - pub fn with_sink(config: Config, format: Format, sink: Sink) -> Config { 345 - let sinks = 346 - config.sinks 347 - |> dict.upsert(format, upsert_sink(sink)) 348 - 349 - Config(..config, sinks:) 310 + /// # Performance concerns 311 + /// This code runs on the calling process, i.e. the code that calls `witness.this`. 312 + /// If you intend to do heavy work in a handler, you should consider offloading it 313 + /// to another process. 314 + /// 315 + pub fn add_custom( 316 + config: Config, 317 + level: Level, 318 + format: Format, 319 + handler: fn(String) -> Nil, 320 + ) -> Config { 321 + Config(..config, sinks: [Custom(level:, format:, handler:), ..config.sinks]) 350 322 } 351 323 352 324 /// ----------------------------------------------------------------------------- ··· 414 386 fn get_process_fields(fields: List(Field)) -> List(Field) { 415 387 fields 416 388 } 389 + 390 + // ------------------------------------------------------------------------------ 391 + // FORMAT 392 + // ------------------------------------------------------------------------------ 393 + 394 + @internal 395 + pub fn format_text(fields: List(Field), acc: String) -> String { 396 + let pad = string.repeat(" ", 4) 397 + 398 + let format = fn(name, value: a, to_string: fn(a) -> String) { 399 + pad <> name <> ": " <> to_string(value) 400 + } 401 + 402 + case fields { 403 + [] -> acc 404 + [WString(name:, value:), ..fields] -> 405 + format_text(fields, acc <> "\n" <> format(name, value, function.identity)) 406 + [WInt(name:, value:), ..fields] -> 407 + format_text(fields, acc <> "\n" <> format(name, value, int.to_string)) 408 + [WFloat(name:, value:), ..fields] -> 409 + format_text(fields, acc <> "\n" <> format(name, value, float.to_string)) 410 + [WBool(name:, value:), ..fields] -> 411 + format_text(fields, acc <> "\n" <> format(name, value, bool.to_string)) 412 + } 413 + } 414 + 415 + @internal 416 + pub fn format_json(fields: List(Field), acc: String) -> String { 417 + let format = fn(name, value: a, to_string: fn(a) -> String) { 418 + name <> ": " <> to_string(value) 419 + } 420 + 421 + case fields { 422 + [] -> acc 423 + [WString(name:, value:), ..fields] -> 424 + format_json(fields, acc <> "\n" <> format(name, value, function.identity)) 425 + [WInt(name:, value:), ..fields] -> 426 + format_json(fields, acc <> "\n" <> format(name, value, int.to_string)) 427 + [WFloat(name:, value:), ..fields] -> 428 + format_json(fields, acc <> "\n" <> format(name, value, float.to_string)) 429 + [WBool(name:, value:), ..fields] -> 430 + format_json(fields, acc <> "\n" <> format(name, value, bool.to_string)) 431 + } 432 + }
+241 -7
src/witness_ffi.erl
··· 14 14 % See the Licence for the specific language governing permissions and limitations. 15 15 16 16 -module(witness_ffi). 17 - -export([get_config/1, set_config/1, set_process_fields/1, get_process_fields/1]). 17 + -export([get_config/1, set_process_fields/1, get_process_fields/1, do_log/4, format/2, configure/1 , log/2]). 18 + 19 + %% ------------------------------------------------------------------------------ 20 + %% Gleam ffi 21 + %% ------------------------------------------------------------------------------ 18 22 19 23 get_config(Default) -> 20 - case persistent_term:get(witness_config, undefined) of 24 + case ets:whereis(witness_config) of 21 25 undefined -> Default; 22 - Config -> Config 26 + _ -> case ets:lookup(witness_config, config) of 27 + [{config, Config}] -> Config; 28 + _ -> Default 29 + end 23 30 end. 24 31 25 - set_config(Config) -> 26 - persistent_term:put(witness_config, Config), 27 - nil. 28 - 29 32 set_process_fields(Fields) -> put(witness_process_fields, Fields). 30 33 31 34 get_process_fields(Default) -> ··· 33 36 undefined -> Default; 34 37 Fields -> Fields 35 38 end. 39 + 40 + % Log to the erlang logger 41 + do_log(Level, Message, Namespace, Fields) -> 42 + logger:log(Level, Message, #{domain => [witness, Namespace], fields => Fields}). 43 + 44 + %% ------------------------------------------------------------------------------ 45 + %% Erlang logger configuration 46 + %% ------------------------------------------------------------------------------ 47 + 48 + configure({config, _Namespace, Sinks, _GlobalFields} = Config) -> 49 + case ets:whereis(witness_config) of 50 + undefined -> ets:new(witness_config, [named_table]); 51 + _ -> witness_config 52 + end, 53 + 54 + ets:insert(witness_config, {config, Config}), 55 + 56 + logger:update_primary_config(#{ 57 + level => all, 58 + filter_default => log, 59 + filters => filters(), 60 + metadata => #{} 61 + }), 62 + % remove existing handlers 63 + lists:foreach(fun logger:remove_handler/1, logger:get_handler_ids()), 64 + 65 + % add our handlers 66 + % maybe replace this with a manual (indexed) mapping 67 + % to limit the amount of atoms that can potentially be created 68 + lists:foreach(fun add_sink/1, Sinks), 69 + 70 + % logger:update_handler_config(default, #{ 71 + % formatter => {witness_ffi, Config}, 72 + % filter_default => log 73 + % }), 74 + nil. 75 + 76 + -spec add_sink(Sink) -> ok | {error, term()} when 77 + Sink :: witness:sink(). 78 + 79 + add_sink({console, Level, Format}) -> 80 + Id = witness_console, 81 + logger:add_handler(Id, 82 + logger_std_h, 83 + #{level => Level, 84 + filters => filters(), 85 + formatter => 86 + {witness_ffi, 87 + #{format => Format, 88 + level => Level 89 + }} 90 + }); 91 + 92 + add_sink({file, Level, Format, Path}) -> 93 + Id = list_to_atom("witness_file_" 94 + ++ integer_to_list(erlang:phash2(Path))), 95 + logger:add_handler(Id, 96 + witness_ffi, 97 + #{level => Level, 98 + filters => filters(), 99 + formatter => 100 + {witness_ffi, 101 + #{format => Format, 102 + level => Level 103 + }}, 104 + config => #{file => binary_to_list(Path)} 105 + }); 106 + 107 + 108 + add_sink({custom, Level, Format, Handler}) -> 109 + Id = list_to_atom("witness_custom_" 110 + ++ integer_to_list(erlang:unique_integer())), 111 + logger:add_handler(Id, 112 + witness_ffi, 113 + #{level => Level, 114 + filters => filters(), 115 + formatter => 116 + {witness_ffi, 117 + #{format => Format, 118 + level => Level 119 + }}, 120 + config => #{handler => Handler} 121 + }). 122 + 123 + filters() -> 124 + [{domain, {fun logger_filters:domain/2, {stop, sub, [otp, sasl]}}}, 125 + {domain, {fun logger_filters:domain/2, {stop, sub, [supervisor_report]}}}, 126 + {progress_filter, {fun logger_filters:progress/2, stop}}]. 127 + 128 + %% ------------------------------------------------------------------------------ 129 + %% logger_formatter implementation 130 + %% ------------------------------------------------------------------------------ 131 + 132 + -type formatter_config() :: #{format => witness:format(), 133 + level => witness:level()}. 134 + 135 + -spec format(LogEvent, Config) -> unicode:chardata() when 136 + LogEvent :: logger:log_event(), 137 + Config :: formatter_config(). 138 + 139 + % logger_formatter api 140 + format(LogEvent, Config) -> 141 + try do_format(LogEvent, Config) of 142 + Formatted -> Formatted 143 + catch 144 + Error:Reason -> 145 + Level = maps:get(level, LogEvent, error), 146 + [~"[WITNESS FORMATTER ERROR: ", 147 + atom_to_list(Error), 148 + ~" ", 149 + io_lib:format("~p", Reason), 150 + ~"]\n", 151 + timestamp(meta(LogEvent)), 152 + ~" ", 153 + atom_to_list(Level), 154 + ~": ", 155 + case maps:get(msg, LogEvent, undefined) of 156 + undefined -> ~"Unexpected message"; 157 + {string, Msg} -> Msg; 158 + {report, _Report} -> ~"Report data (formatter crashed)"; 159 + Other -> io_lib:format("~p", [Other]) 160 + end, 161 + $\n] 162 + % fallback_format(#{formatter_failed => Reason, original_event => LogEvent}) 163 + end. 164 + 165 + % fallback formatter in case the main one crashes 166 + fallback_format(Event) -> gleam@string:inspect(Event). 167 + 168 + -spec do_format(LogEvent, Config) -> unicode:chardata() when 169 + LogEvent :: logger:log_event(), 170 + Config :: formatter_config(). 171 + 172 + do_format(#{level := Level, msg := {string, Message}, meta := Meta}, 173 + #{format := text, level := SinkLevel}) -> 174 + 175 + Timestamp = timestamp(Meta), 176 + 177 + FormattedFields = witness:format_text(fields(Meta), <<>>), 178 + [level_to_badge(Level), ~" ", Timestamp, ~": ", Message, FormattedFields, $\n]; 179 + 180 + do_format(#{level := Level, msg := {string, Message}, meta := Meta}, 181 + #{format := json, level := SinkLevel}) -> [~"json", $\n]; 182 + 183 + do_format(#{level := Level, msg := {report, Report}, meta := Meta}, 184 + #{format := text, level := SinkLevel}) -> 185 + Timestamp = timestamp(Meta), 186 + 187 + {MaybeMsg, ReportFields} = report_to_fields(Report), 188 + MetaFields = fields(Meta), 189 + Fields = ReportFields ++ MetaFields, 190 + 191 + FormattedFields = witness:format_text(Fields, <<>>), 192 + 193 + [level_to_badge(Level), ~" ", Timestamp, ~": ", MaybeMsg, FormattedFields, $\n]; 194 + 195 + do_format(#{level := Level, msg := {report, Report}, meta := Meta}, 196 + #{format := json, level := SinkLevel}) -> "json-report\n"; 197 + 198 + do_format(#{level := Level, msg := {IoFormat, Terms}, meta := Meta}, 199 + #{format := Format, level := SinkLevel}) -> "io-format\n"; 200 + 201 + do_format(#{level := Level, msg := Msg, meta := Meta}, _Config) -> 202 + fallback_format(#{ format_failed => unmatched, level => Level, msg => Msg, meta => Meta}). 203 + 204 + 205 + fields(#{fields := Fields}) -> Fields; 206 + fields(Meta) -> []. 207 + 208 + report_to_fields(Report) when is_map(Report) -> report_to_fields(maps:to_list(Report)); 209 + report_to_fields(Report) when is_list(Report) -> 210 + Msg = case lists:keyfind(~"msg", 1, Report) of 211 + {_, Found} -> Found; 212 + false -> ~"Report" 213 + end, 214 + ReportWithoutMsg = lists:keydelete(~"msg", 1, Report), 215 + Fields = lists:map(fun to_field/1, ReportWithoutMsg), 216 + {Msg, Fields}. 217 + 218 + 219 + -spec to_field({any(), any()}) -> witness:field(). 220 + 221 + to_field({Name, Value}) when is_atom(Name), is_bitstring(Value) -> witness:string(atom_to_binary(Name), Value); 222 + to_field({Name, Value}) when is_atom(Name), is_integer(Value) -> witness:int(atom_to_binary(Name), Value); 223 + to_field({Name, Value}) when is_atom(Name), is_float(Value) -> witness:float(atom_to_binary(Name), Value); 224 + to_field({Name, Value}) when is_atom(Name), is_boolean(Value) -> witness:bool(atom_to_binary(Name), Value); 225 + to_field({Name, Value}) when is_atom(Name) -> witness:string(atom_to_binary(Name), gleam@string:inspect(Value)); 226 + to_field({Name, Value}) -> witness:string(gleam@string:inspect(Name), gleam@string:inspect(Value)). 227 + 228 + 229 + 230 + -spec level_to_badge(Level) -> unicode:chardata() when 231 + Level :: logger:level(). 232 + 233 + level_to_badge(Level) -> 234 + case Level of 235 + emergency -> "[EMERG]"; 236 + alert -> "[ALERT]"; 237 + critical -> "[CRIT]"; 238 + error -> "[ERROR]"; 239 + warning -> "[WARN]"; 240 + notice -> "[NOTICE]"; 241 + info -> "[INFO]"; 242 + debug -> "[DEBUG]" 243 + end. 244 + 245 + -spec meta(logger:log_event()) -> logger:metadata(). 246 + meta(#{meta := Meta}) -> Meta. 247 + 248 + -spec timestamp(logger:metadata()) -> io_lib:chars(). 249 + timestamp(Meta) -> 250 + TimeMicros = maps:get(time, Meta, erlang:system_time(microsecond)), 251 + {{_Year, _Month, _Day}, {Hour, Min, Sec}} = 252 + calendar:system_time_to_local_time(TimeMicros, microsecond), 253 + % Format as: 2025-10-11 01:18:16 254 + io_lib:format("~2..0B:~2..0B:~2..0B", [Hour, Min, Sec]). 255 + 256 + %% ------------------------------------------------------------------------------ 257 + %% logger_handler implementation 258 + %% Should only be used for custom witness sinks. 259 + %% ------------------------------------------------------------------------------ 260 + 261 + -spec log(LogEvent, Config) -> nil when 262 + LogEvent :: logger:log_event(), 263 + Config :: logger_handler:config(). 264 + 265 + log(LogEvent, #{config := #{handler := Handler }, 266 + formatter := {FormatterModule, FormatterConfig} 267 + }) when is_function(Handler) -> 268 + Formatted = FormatterModule:format(LogEvent, FormatterConfig), 269 + Handler(Formatted).