Yet another structured logging library
0

Configure Feed

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

Integrate with erlang logger #1

Open opened by ollie.earth targeting main from erlang-logger

3.0.0#

  • Integrate with the erlang logger. All logs that go through the erlang logger will now also get formatted.
  • Added file sink for the erlang target.
  • Added new_file, file_compress, file_keep and file_size_b to configure the file sink.
  • Added with_console, add_file and add_custom to configure sinks.
  • Dropped gleam_time, gleam_json and gleam_erlang dependencies
  • Replaced persistent_term uses on erlang with ets.

Breaking changes#

  • Removed default_config in favor of new.
  • Removed empty_config in favor of new.
  • Removed with_sink in favor of with_console, add_custom and add_file.
  • Renamed set_config to configure.

Added a whole bunch of erlang#

Labels

None yet.

Participants 2
AT URI
at://did:plc:cezmtk5bb4zipkps3abnjjl6/sh.tangled.repo.pull/3mrklvmy64t22
+517 -135
Diff #1
+27 -17
dev/witness_dev.gleam
··· 2 2 import witness 3 3 4 4 pub fn main() -> Nil { 5 + configure_logging() 5 6 7 + // log a message 8 + witness.this(witness.Warning, "Some log message", [ 9 + witness.string("example", "you can add structured data"), 10 + witness.bool("also_bools", True), 11 + witness.float("and_floats", 1.23), 6 12 7 13 8 14 9 15 10 16 11 - 12 - 13 - 14 - 15 - 16 - 17 17 /// 18 18 fn configure_logging() { 19 19 // 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) 20 + witness.new("witness-dev") 21 + |> witness.with_global_fields([ 22 + witness.string( 23 + "my-global-field", 24 + "something i really need logged everywhere", 25 + ), 26 + ]) 27 + |> witness.with_console(witness.Debug, witness.Text) 28 + |> witness.add_file( 29 + witness.Warning, 30 + witness.Json, 31 + witness.new_file("testing.log"), 32 + ) 33 + |> witness.add_custom(witness.Debug, witness.Json, fn(message) { 34 + // Whatever you do in here has to be executed by the calling process 35 + // i.e. whoever ran `witness.this`, so try to keep processing low. 36 + // 37 + // On erlang you could, for example, `process.send` the log event 38 + // to a handler process somewhere in your supervision tree. 39 + io.println("Hello from my custom handler!\n" <> message) 24 40 }) 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() 41 + |> witness.configure 32 42 }
+14 -3
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 - { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 10 + { name = "gleam_stdlib", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "0C5506589DF4C63DF5D6FFBB834562D6865C6C2AEE0019D7B37886BD6D128141" }, 11 + { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, 12 + ] 13 + 14 + [requirements] 15 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 16 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+278 -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, 18 + format/2, configure/1 , log/2, short_timestamp/0, rfc_3339_timestamp/0]). 18 19 20 + %% ------------------------------------------------------------------------------ 21 + %% Gleam ffi 22 + %% ------------------------------------------------------------------------------ 23 + 19 24 get_config(Default) -> 20 - case persistent_term:get(witness_config, undefined) of 25 + case ets:whereis(witness_config) of 21 26 undefined -> Default; 22 - Config -> Config 27 + _ -> case ets:lookup(witness_config, config) of 28 + [{config, Config}] -> Config; 29 + _ -> Default 30 + end 23 31 end. 24 32 25 - set_config(Config) -> 26 - persistent_term:put(witness_config, Config), 27 - nil. 28 - 29 33 set_process_fields(Fields) -> put(witness_process_fields, Fields). 30 34 31 35 get_process_fields(Default) -> 32 36 33 37 undefined -> Default; 34 38 Fields -> Fields 39 + end. 40 + 41 + % Log to the erlang logger 42 + do_log(Level, Message, Namespace, Fields) -> 43 + logger:log(Level, Message, #{domain => [witness, binary_to_atom(Namespace)], fields => Fields}). 44 + 45 + %% ------------------------------------------------------------------------------ 46 + %% Erlang logger configuration 47 + %% ------------------------------------------------------------------------------ 48 + 49 + configure({config, _Namespace, Sinks, _GlobalFields} = Config) -> 50 + case ets:whereis(witness_config) of 51 + undefined -> ets:new(witness_config, [named_table]); 52 + _ -> witness_config 53 + end, 54 + 55 + ets:insert(witness_config, {config, Config}), 56 + 57 + logger:update_primary_config(#{ 58 + level => all, 59 + filter_default => log, 60 + filters => filters(), 61 + metadata => #{} 62 + }), 63 + % remove existing handlers 64 + lists:foreach(fun logger:remove_handler/1, logger:get_handler_ids()), 65 + 66 + % add our handlers 67 + add_sinks(Sinks, 0). 68 + 69 + -spec add_sinks([witness:sink()], integer()) -> nil. 70 + add_sinks(Sinks, CustomIndex) -> 71 + case Sinks of 72 + [] -> nil; 73 + [Sink = {console, _, _} | Rest] -> add_sink(Sink, 0), add_sinks(Rest, CustomIndex); 74 + [Sink = {file, _, _, _} | Rest] -> add_sink(Sink, 0), add_sinks(Rest, CustomIndex); 75 + [Sink = {custom, _, _, _} | Rest] -> add_sink(Sink, CustomIndex), add_sinks(Rest, CustomIndex + 1) 76 + end. 77 + 78 + -spec add_sink(Sink, Index) -> ok | {error, term()} when 79 + Sink :: witness:sink(), 80 + Index :: integer(). 81 + 82 + add_sink({console, Level, Format}, _) -> 83 + Id = witness_console, 84 + logger:add_handler(Id, 85 + logger_std_h, 86 + #{level => Level, 87 + filters => filters(), 88 + formatter => 89 + {witness_ffi, 90 + #{format => Format, 91 + level => Level 92 + }} 93 + }); 94 + 95 + add_sink({file, Level, Format, 96 + {file_config, Path, MaxBytes, MaxFiles, Compress}}, _) -> 97 + Id = list_to_atom("witness_file_" 98 + ++ integer_to_list(erlang:phash2(Path))), 99 + logger:add_handler(Id, 100 + logger_std_h, 101 + #{level => Level, 102 + filters => filters(), 103 + formatter => {witness_ffi, #{format => Format, level => Level}}, 104 + config => #{file => binary_to_list(Path), 105 + max_no_bytes => MaxBytes, 106 + max_no_files => MaxFiles, 107 + compress_on_rotate => Compress} 108 + }); 109 + 110 + 111 + add_sink({custom, Level, Format, Handler}, Index) -> 112 + Id = list_to_atom("witness_custom_" 113 + ++ integer_to_list(Index)), 114 + logger:add_handler(Id, 115 + witness_ffi, 116 + #{level => Level, 117 + filters => filters(), 118 + formatter => 119 + {witness_ffi, 120 + #{format => Format, 121 + level => Level 122 + }}, 123 + config => #{handler => Handler} 124 + }). 125 + 126 + filters() -> 127 + [{domain, {fun logger_filters:domain/2, {stop, sub, [otp, sasl]}}}, 128 + {domain, {fun logger_filters:domain/2, {stop, sub, [supervisor_report]}}}, 129 + {progress_filter, {fun logger_filters:progress/2, stop}}]. 130 + 131 + %% ------------------------------------------------------------------------------ 132 + %% logger_formatter implementation 133 + %% ------------------------------------------------------------------------------ 134 + 135 + -type formatter_config() :: #{format => witness:format(), 136 + level => witness:level()}. 137 + 138 + -spec format(LogEvent, Config) -> unicode:chardata() when 139 + LogEvent :: logger:log_event(), 140 + Config :: formatter_config(). 141 + 142 + % logger_formatter api 143 + format(LogEvent, Config) -> 144 + try do_format(LogEvent, Config) of 145 + Formatted -> Formatted 146 + catch 147 + Error:Reason -> format_catch(Error, Reason, LogEvent, Config) 148 + end. 149 + 150 + format_catch(Error, Reason, LogEvent, _Config) -> 151 + Level = maps:get(level, LogEvent, error), 152 + [~"[WITNESS FORMATTER ERROR: ", 153 + atom_to_binary(Error), 154 + ~" ", 155 + io_lib:bformat("~p", [Reason]), 156 + ~"]\n", 157 + short_timestamp(meta(LogEvent)), 158 + ~" ", 159 + atom_to_binary(Level), 160 + ~": ", 161 + case maps:get(msg, LogEvent, undefined) of 162 + undefined -> ~"Unexpected message"; 163 + {string, Msg} -> io_lib:bformat("~p", [Msg]); 164 + {report, _Report} -> ~"Report data (formatter crashed)"; 165 + Other -> io_lib:bformat("~p", [Other]) 166 + end, 167 + $\n, 168 + io_lib:bformat("~p", [meta(LogEvent)]) 169 + ]. 170 + 171 + %% ------------------------------------------------------------------------------ 172 + %% text / json format 173 + %% ------------------------------------------------------------------------------ 174 + 175 + -spec do_format(LogEvent, Config) -> unicode:chardata() when 176 + LogEvent :: logger:log_event(), 177 + Config :: formatter_config(). 178 + 179 + do_format(#{level := Level, msg := Event, meta := Meta}, 180 + #{format := Format, level := SinkLevel}) -> 181 + {Message, ReportFields} = case Event of 182 + {string, Msg} when is_binary(Msg) -> {Msg, []}; 183 + {report, Report} -> report_to_fields(Report); 184 + {Format, Data} -> {io_lib:bformat(Format, Data), []}; 185 + Other -> {io_lib:bformat("~p", [Other]), []} 186 + end, 187 + 188 + MetaFields = fields(Meta, SinkLevel == debug), 189 + Fields = MetaFields ++ ReportFields, 190 + 191 + witness:format(Format, Level, Message, Fields); 192 + 193 + %% ------------------------------------------------------------------------------ 194 + %% unmatched fallback 195 + %% ------------------------------------------------------------------------------ 196 + 197 + do_format(#{level := Level, msg := Msg, meta := Meta}, _Config) -> 198 + fallback_format(#{ format_failed => unmatched, level => Level, msg => Msg, meta => Meta}). 199 + 200 + % fallback formatter in case the main one crashes 201 + fallback_format(Event) -> gleam@string:inspect(Event). 202 + 203 + 204 + %% Get fields associated with a LogEvent's metadata 205 + fields(Meta = #{fields := Fields, pid := Pid}, true) when is_pid(Pid) -> 206 + PidField = witness:string(~"pid*", io_lib:bformat("~p", [Pid])), 207 + % Check if this process is registered under any name 208 + PidFields = case process_info(Pid, registered_name) of 209 + [] -> [PidField]; 210 + {registered_name, []} -> [PidField]; 211 + % Add the name to the fields if it is 212 + {registered_name, Name} -> [PidField, witness:string(~"registered_name*", atom_to_binary(Name))] 213 + end, 214 + NamespaceField = namespace_field(Meta), 215 + [NamespaceField] ++ PidFields ++ Fields; 216 + 217 + fields(Meta = #{fields := Fields}, _GetPid) -> 218 + NamespaceField = namespace_field(Meta), 219 + [NamespaceField] ++ Fields; 220 + fields(_Meta, _GetPid) -> []. 221 + 222 + % Get the `namespace` field from the LogEvent metadata() 223 + % `unknown` if it isnt present 224 + -spec namespace_field(logger:metadata()) -> witness:field(). 225 + namespace_field(Meta) -> 226 + Domain = maps:get(domain, Meta, [witness, unknown]), 227 + Namespace = namespace_from_domain(Domain), 228 + witness:string(~"namespace", atom_to_binary(Namespace)). 229 + 230 + namespace_from_domain([witness, Namespace]) when is_atom(Namespace) -> Namespace; 231 + namespace_from_domain(_) -> unknown. 232 + 233 + report_to_fields(Report) when is_map(Report) -> report_to_fields(maps:to_list(Report)); 234 + report_to_fields(Report) when is_list(Report) -> 235 + Msg = case lists:keyfind(~"msg", 1, Report) of 236 + {_, Found} -> Found; 237 + false -> ~"Report" 238 + end, 239 + ReportWithoutMsg = lists:keydelete(~"msg", 1, Report), 240 + Fields = lists:map(fun to_field/1, ReportWithoutMsg), 241 + {Msg, Fields}. 242 + 243 + 244 + % Convert a `Report` value into a witness field 245 + -spec to_field({any(), any()}) -> witness:field(). 246 + 247 + to_field({Name, Value}) when is_atom(Name), is_bitstring(Value) -> witness:string(atom_to_binary(Name), Value); 248 + to_field({Name, Value}) when is_atom(Name), is_integer(Value) -> witness:int(atom_to_binary(Name), Value); 249 + to_field({Name, Value}) when is_atom(Name), is_float(Value) -> witness:float(atom_to_binary(Name), Value); 250 + to_field({Name, Value}) when is_atom(Name), is_boolean(Value) -> witness:bool(atom_to_binary(Name), Value); 251 + to_field({Name, Value}) when is_atom(Name) -> witness:string(atom_to_binary(Name), gleam@string:inspect(Value)); 252 + to_field({Name, Value}) -> witness:string(io_lib:bformat("~p", [Name]), gleam@string:inspect(Value)). 253 + 254 + % Get metadata from a LogEvent 255 + -spec meta(logger:log_event()) -> logger:metadata(). 256 + meta(#{meta := Meta}) -> Meta. 257 + 258 + 259 + % Get a short HH:MM:SS timestamp 260 + short_timestamp() -> short_timestamp(#{}). 261 + 262 + -spec short_timestamp(logger:metadata()) -> binary(). 263 + short_timestamp(Meta) -> 264 + TimeMicros = maps:get(time, Meta, erlang:system_time(microsecond)), 265 + {{_Year, _Month, _Day}, {Hour, Min, Sec}} = 266 + calendar:system_time_to_local_time(TimeMicros, microsecond), 267 + % Format as: 2025-10-11 01:18:16 268 + io_lib:bformat("~2..0B:~2..0B:~2..0B", [Hour, Min, Sec]). 269 + 270 + 271 + % Get a long form rfc3339 timestamp 272 + rfc_3339_timestamp() -> rfc_3339_timestamp (#{}). 273 + 274 + -spec rfc_3339_timestamp(logger:metadata()) -> binary(). 275 + rfc_3339_timestamp(Meta) -> 276 + Time = maps:get(time, Meta, erlang:system_time(microsecond)), 277 + calendar:system_time_to_rfc3339(Time, [{return, binary}, {unit, microsecond}]). 278 + 279 + %% ------------------------------------------------------------------------------ 280 + %% logger_handler implementation 281 + %% Should only be used for custom witness sinks. 282 + %% ------------------------------------------------------------------------------ 283 + 284 + -spec log(LogEvent, Config) -> nil when 285 + LogEvent :: logger:log_event(), 286 + Config :: logger_handler:config(). 287 + 288 + log(LogEvent, #{config := #{handler := Handler}, 289 + formatter := {FormatterModule, FormatterConfig} 290 + }) when is_function(Handler) -> 291 + Formatted = try FormatterModule:format(LogEvent, FormatterConfig) of 292 + Form -> Form 293 + catch 294 + Error:Reason -> format_catch(Error, Reason, LogEvent, FormatterConfig) 295 + end, 296 + try Handler(Formatted) of 297 + nil -> nil 298 + catch 299 + Err:Reas -> io:fwrite("~ts", 300 + [["[WITNESS CUSTOM HANDLER CRASHED: ", 301 + atom_to_list(Err), 302 + " ", 303 + io_lib:format("~p", [Reas]), 304 + "]\n" 305 + ]]), nil 35 306 end.
+1
.gitignore
··· 7 7 .direnv/ 8 8 .elp 9 9 .elp.build_info 10 + testing.log
+41 -2
src/witness.ffi.mjs
··· 1 + import { 2 + Level$isDebug, 3 + Level$isInfo, 4 + Level$isNotice, 5 + Level$isWarning, 6 + Level$isError, 7 + Level$isCritical, 8 + Level$isAlert, 9 + Level$isEmergency 10 + } from "./witness.mjs"; 1 11 12 + var global_config; 2 13 14 + export function get_config(config) { 3 15 4 16 5 17 18 + return global_config; 19 + } 6 20 21 + export function configure(config) { 22 + global_config = config 23 + } 7 24 25 + export function short_timestamp() { 26 + return new Date().toLocaleTimeString("en-GB") 27 + } 8 28 29 + export function iso_8601_timestamp() { 30 + return new Date().toISOString() 31 + } 9 32 10 - export function set_config(config) { 11 - global_config = config 33 + export function print(message, level) { 34 + if(Level$isDebug(level)){ 35 + console.debug(message) 36 + return undefined 37 + } 38 + 39 + if(Level$isInfo(level) || Level$isNotice(level)) { 40 + console.info(message) 41 + return undefined 42 + } 43 + 44 + if(Level$isWarning(level)) { 45 + console.warn(message) 46 + return undefined 47 + } 48 + 49 + console.error(message) 50 + return undefined 12 51 }
-64
erlang-logger.md
··· 1 - # Erlang logger integration 2 - 3 - In this I will go over some of the options I have thought of for the 4 - outstanding integration of `witness` with the erlang `logger`. 5 - 6 - ## DIY 7 - 8 - Add a `witness.erlang_logger` sink which gets the bare log event. 9 - Leaving the formatting entirely to the user of the library. 10 - 11 - ## Basic, single config 12 - 13 - Add something like `witness.configure(ErlangLoggerConfig)` 14 - 15 - Where there `LoggerConfig` looks something like: 16 - ```gleam 17 - LoggerConfig { 18 - level: Level 19 - format: Format // <- existing format options (Text, Json) 20 - destination: Destination 21 - } 22 - 23 - Destination { 24 - File(path: String) 25 - Stdout 26 - } 27 - ``` 28 - It would be used to configure the erlang `logger`. 29 - 30 - ## Advanced, multi config 31 - 32 - Add something like `witness.configure(ErlangLoggerConfig)` 33 - 34 - Where there `LoggerConfig` looks something like: 35 - ```gleam 36 - LoggerConfig { 37 - sinks: List(SinkConfig) 38 - } 39 - 40 - SinkConfig { 41 - level: Level 42 - format: Format // <- existing format options (Text, Json) 43 - destination: Destination 44 - } 45 - 46 - Destination { 47 - File(path: String) 48 - Stdout 49 - } 50 - ``` 51 - It would be used to configure the erlang `logger`. 52 - 53 - I'm imagining an API similar to the following for a common configuration of logging 54 - severe things to a file and everything to stdout. 55 - ```gleam 56 - witness.new() 57 - |> witness.with_file(witness.Warning, witness.Json, "./my-app.log") 58 - |> witness.with_stdout(witness.Debug, witness.Text) 59 - |> witness.configure 60 - ``` 61 - 62 - ## Notes 63 - 64 - If you have alternate ideas, please let me know.
+78 -17
src/witness.gleam
··· 139 139 140 140 @external(erlang, "witness_ffi", "rfc_3339_timestamp") 141 141 @external(javascript, "./witness.ffi.mjs", "iso_8601_timestamp") 142 - fn rfc_3339_date() -> String 142 + fn long_date() -> String 143 143 144 144 fn level_to_badge(level: Level) -> String { 145 145 case level { ··· 275 275 /// 276 276 /// `File` handlers use a hash of the path (`witness_file_<hash>`) and `custom` 277 277 /// handlers are indexed (`witness_custom_<index>`). 278 + /// 279 + /// Your supplied `namespace` will also be turned into an atom! 278 280 /// 279 281 /// Keep in mind that generating too many atoms will result in the atom table 280 282 /// getting filled and causing the entire virtual machine to crash. ··· 284 286 pub fn configure(config: Config) -> Nil 285 287 286 288 pub opaque type Sink { 287 - File(level: Level, format: Format, path: String) 289 + File(level: Level, format: Format, config: FileConfig) 288 290 Console(level: Level, format: Format) 289 291 Custom(level: Level, format: Format, handler: fn(String) -> Nil) 290 292 } ··· 298 300 /// "ts": "2026-05-28T18:30:40.020522296Z", 299 301 /// "level": "info", 300 302 /// "msg": "Some log message", 303 + /// "namespace": "my-application", 301 304 /// "example": "you can add structured data", 302 305 /// "also_bools": true, 303 306 /// "and_floats": 1.23, ··· 308 311 Json 309 312 /// ``` 310 313 /// [INFO] 20:30:40 Some log message 314 + /// namespace: my-application 311 315 /// example: you can add structured data 312 316 /// also_bools: True 313 317 /// and_floats: 1.23 ··· 317 321 Text 318 322 } 319 323 320 - @target(erlang) 321 - /// ----------------------------------------------------------------------------- 322 - /// Add a file logger to the logger configuration. Each file logger will get its 323 - /// own `logger_std_h` handler added to the `logger` configuration. 324 - /// 325 - pub fn add_file( 326 - config: Config, 327 - level: Level, 328 - format: Format, 329 - path: String, 330 - ) -> Config { 331 - Config(..config, sinks: [File(level:, format:, path:), ..config.sinks]) 332 - } 333 - 334 324 /// ----------------------------------------------------------------------------- 335 325 /// Add console logging to your configuration. 336 326 /// ··· 468 458 ) 469 459 Json -> 470 460 format_json([ 471 - string("ts", rfc_3339_date()), 461 + string("ts", long_date()), 472 462 string("level", level_to_string(level)), 473 463 string("msg", message), 474 464 ..fields ··· 551 541 WBool(name: _, value:) -> name <> string.lowercase(bool.to_string(value)) 552 542 } 553 543 } 544 + 545 + // ------------------------------------------------------------------------------ 546 + // FILE SINK 547 + // ------------------------------------------------------------------------------ 548 + 549 + @target(erlang) 550 + /// ----------------------------------------------------------------------------- 551 + /// Add a file logger to the logger configuration. Each file logger will get its 552 + /// own `logger_std_h` handler added to the `logger` configuration. 553 + /// 554 + pub fn add_file( 555 + config: Config, 556 + level: Level, 557 + format: Format, 558 + file: FileConfig, 559 + ) -> Config { 560 + Config(..config, sinks: [File(level:, format:, config: file), ..config.sinks]) 561 + } 562 + 563 + @target(erlang) 564 + const default_file = FileConfig( 565 + path: "witness.log", 566 + // 10MB 567 + max_bytes: 10_000_000, 568 + max_files: 5, 569 + compress: False, 570 + ) 571 + 572 + pub opaque type FileConfig { 573 + FileConfig(path: String, max_bytes: Int, max_files: Int, compress: Bool) 574 + } 575 + 576 + @target(erlang) 577 + /// ----------------------------------------------------------------------------- 578 + /// Create a new file logger config. By default it will rotate the file once it 579 + /// reaches 10MB, and will keep the last 5. Uncompressed. 580 + /// 581 + /// See the `file_` functions to configure something else if you need it. 582 + /// 583 + pub fn new_file(path: String) { 584 + FileConfig(..default_file, path:) 585 + } 586 + 587 + @target(erlang) 588 + /// ----------------------------------------- 589 + /// Configure the maximum file size in Bytes. 590 + /// 591 + /// Default: 10MB 592 + /// 593 + pub fn file_size_b(config: FileConfig, size: Int) { 594 + FileConfig(..config, max_bytes: size) 595 + } 596 + 597 + @target(erlang) 598 + /// --------------------------------- 599 + /// Configure how many files to keep. 600 + /// 601 + /// Default: 5 602 + /// 603 + pub fn file_keep(config: FileConfig, count: Int) { 604 + FileConfig(..config, max_files: count) 605 + } 606 + 607 + @target(erlang) 608 + /// ----------------------------------------------------------------------------- 609 + /// Enable compression for rotated log files. When a file gets rotated it will be 610 + /// compressed with `gzip`, and renamed to `FileName.N.gz` 611 + /// 612 + pub fn file_compress(config: FileConfig) { 613 + FileConfig(..config, compress: True) 614 + }
+61 -21
README.md
··· 4 4 [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://witness.hexdocs.pm/) 5 5 6 6 ```sh 7 - gleam add witness@1 logging@1 7 + gleam add witness@1 8 8 ``` 9 9 ```gleam 10 10 import gleam/io ··· 14 14 configure_logging() 15 15 16 16 // log a message 17 - witness.this(witness.Info, "Some log message", [ 17 + witness.this(witness.Warning, "Some log message", [ 18 18 witness.string("example", "you can add structured data"), 19 19 witness.bool("also_bools", True), 20 20 witness.float("and_floats", 1.23), ··· 26 26 /// 27 27 fn configure_logging() { 28 28 // create a new config 29 - witness.empty_config() 30 - // log formatted text to stdout 31 - |> witness.with_sink(witness.Text, fn(level, message) { 32 - io.println(witness.level_to_badge(level) <> ": " <> message) 29 + witness.new("witness-dev") 30 + |> witness.with_global_fields([ 31 + witness.string( 32 + "my-global-field", 33 + "something i really need logged everywhere", 34 + ), 35 + ]) 36 + |> witness.with_console(witness.Debug, witness.Text) 37 + |> witness.add_file( // <- erlang only 38 + witness.Warning, 39 + witness.Json, 40 + witness.new_file("testing.log"), 41 + ) 42 + |> witness.add_custom(witness.Debug, witness.Json, fn(message) { 43 + // Whatever you do in here has to be executed by the calling process 44 + // i.e. whoever ran `witness.this`, so try to keep processing low. 45 + // 46 + // On erlang you could, for example, `process.send` the log event 47 + // to a handler process somewhere in your supervision tree. 48 + io.println("Hello from my custom handler!\n" <> message) 33 49 }) 34 - // log as json 35 - |> witness.with_sink(witness.Json, fn(_, message) { 36 - // ideally you'd log this to a file using, for example, the erlang logger 37 - io.println(message) 38 - }) 39 - // apply this config globally 40 - |> witness.set_config() 50 + |> witness.configure 41 51 } 42 52 ``` 43 - Will log this json line: 53 + Will log this text to console: 54 + ```plain 55 + [WARN] 16:06:45: Some log message 56 + namespace: witness-dev 57 + pid*: <0.83.0> 58 + my-global-field: somethign i really need globally 59 + example: you can add structured data 60 + also_bools: True 61 + and_floats: 1.23 62 + ints_as_well: 3 44 63 ``` 45 - {"timestamp":"2026-07-22T17:13:17.100815963Z","level":"info","message":"Some log message","example":"you can add structured data","also_bools":true,"and_floats":1.23,"ints_as_well":3} 64 + and this json line to `io.println`, from the custom handler: 65 + ```plain 66 + Hello from my custom handler! 67 + {"ts":"2026-07-26T14:06:45.295Z","level":"warning","msg":"Some log message","namespace":"witness-dev","my-global-field":"somethign i really need globally","example":"you can add structured data","also_bools":true,"and_floats":1.23,"ints_as_well":3} 46 68 ``` 47 - And this text to the console: 69 + And this json line to `testing.log`: 70 + ```json 71 + {"ts":"2026-07-26T16:09:28.963532+02:00","level":"warning","msg":"Some log message","namespace":"witness-dev","my-global-field":"something i really need logged everywhere","example":"you can add structured data","also_bools":true,"and_floats":1.23,"ints_as_well":3} 48 72 ``` 49 - [INFO]: 19:13:17 Some log message 50 - example: you can add structured data 51 - also_bools: True 52 - and_floats: 1.23 53 - ints_as_well: 3 73 + 74 + ### Notes 75 + 76 + The file sink is only available on the erlang target. Since it uses `logger_std_h` 77 + to do the actual writing to file. And the javascript target may not even have a file 78 + to log to. 79 + 80 + #### Erlang 81 + 82 + The `console` and `custom` handlers, which are configured with `witness.Debug`, 83 + also logged the `pid*` of the calling process (i.e. the process that called `witness.this`). 84 + If they were registered under a name, that name would also show up as `registered_name*`. 85 + 86 + All messages logged by witness have a domain of `domain => [witness, <namespace>]` so they may 87 + be filtered out entirely like this: 88 + ```erl 89 + filters => [{domain, {fun logger_filters:domain/2, {stop, sub, [witness]}}}] 90 + ``` 91 + or only specific namespaces like this: 92 + ```erl 93 + filters => [{domain, {fun logger_filters:domain/2, {stop, sub, [witness, <namespace>]}}}] 54 94 ``` 55 95 56 96 Further documentation can be found at <https://witness.hexdocs.pm>.
-3
gleam.toml
··· 14 14 15 15 [dependencies] 16 16 gleam_stdlib = ">= 1.0.0 and < 2.0.0" 17 - gleam_json = ">= 3.1.0 and < 4.0.0" 18 - gleam_time = ">= 1.8.0 and < 2.0.0" 19 17 20 18 [dev-dependencies] 21 19 gleeunit = ">= 1.0.0 and < 2.0.0" 22 - gleam_erlang = ">= 1.3.0 and < 2.0.0"
+17 -1
CHANGELOG.md
··· 1 + # 3.0.0 2 + - Integrate with the erlang logger. All logs that go through the erlang logger will now also get formatted. 3 + - Added `file` sink for the erlang target. 4 + - Added `new_file`, `file_compress`, `file_keep` and `file_size_b` to configure the file sink. 5 + - Added `with_console`, `add_file` and `add_custom` to configure sinks. 6 + - Dropped `gleam_time`, `gleam_json` and `gleam_erlang` dependencies 7 + - Replaced `persistent_term` uses on erlang with `ets`. 8 + 9 + # Breaking changes 10 + - Removed `default_config` in favor of `new`. 11 + - Removed `empty_config` in favor of `new`. 12 + - Removed `with_sink` in favor of `with_console`, `add_custom` and `add_file`. 13 + - Renamed `set_config` to `configure`. 14 + 15 + --- 16 + 1 17 # 2.0.0 2 18 - Add support for javascript 3 - - Drop dependency on `logging` 19 + - Dropped `logging` dependency 4 20 5 21 # Breaking changes 6 22 - `logging.Level` was replaced with `witness.Level`

History

2 rounds 2 comments
Sign up or Login to add to the discussion
14 commits
Expand
progress on erlang logger
add pid to fields when the sink level is
and process name when it is registered
consolidate the text formatter
json format and some refactoring
fix file logger
custom sink works
fix some comments
more stuff
update comments and add some configuration options for the erlang file logger
update readme & remove deps
update changelog
comments
readme
Checking mergeabilityโ€ฆ
Expand 1 comment

Added some more comments throughout witness_ffi.erl and some more stuff to the readme

12 commits
Expand
progress on erlang logger
add pid to fields when the sink level is
and process name when it is registered
consolidate the text formatter
json format and some refactoring
fix file logger
custom sink works
fix some comments
more stuff
update comments and add some configuration options for the erlang file logger
update readme & remove deps
update changelog
Expand 1 comment

I have been talking to u every step but will still help u review to confirm everything is as perfectly great as it should be!