this repo has no description
1+++
2title = "Log all the things"
3date = 2022-01-02
4
5[taxonomies]
6tags = [
7 "beam",
8 "observability"
9]
10
11[[extra.thanks]]
12name = "Kai Wern Choong"
13+++
14
15In Elixir 1.11 landed set of new features that allows for more powerful logging
16by utilising Erlang's [`logger`][erl-log] features. Here I will try to describe
17new possibilities and how You can use them to improve your logs.
18
19<!-- more -->
20
21## New log levels {#levels}
22
23Elixir gained 4 new log levels to total 8 (from most verbose to least verbose):
24
25- debug
26- info
27- **notice** *
28- warning (renamed from warn)
29- error
30- **critical** *
31- **alert** *
32- **emergency** *
33
34<small>* new levels</small>
35
36This allow to provide finer graded verbosity control, due to compatibility
37reasons, in Elixir backends we need to translate these levels back to "old" set
38of 4. The current table looks like:
39
40| Call level | What Elixir backend will see |
41| -- | -- |
42| `debug` | `debug` |
43| `info` | `info` |
44| `notice` | **`info`** * |
45| `warning` (or `warn`) | `warn` |
46| `error` | `error` |
47| `critical` | **`error`** * |
48| `alert` | **`error`** * |
49| `emergency` | **`error`** * |
50
51<small>* "translated" messages</small>
52
53We can set verbosity to all levels. This may be confusing during the transition
54period, but we cannot change the behaviour until Elixir 2 (which is not
55happening any time soon).
56
57Usage of the new levels is "obvious":
58
59```elixir
60Logger.notice("Hello")
61```
62
63Will produce message with `notice` level of verbosity.
64
65Additionally the `logger.level` option in configuration supports 2 additional
66verbosity levels that you can use in your config:
67
68- `:all` - all messages will be logged, logically exactly the same as `:debug`
69- `:none` - no messages will be logged
70
71## Per module log level {#per-module-level}
72
73This is change that can be quite handy during debugging sessions. With this
74change we have 4 new functions in `Logger` module:
75
76- [`get_module_level/1`](https://hexdocs.pm/logger/Logger.html#get_module_level/1)
77- [`put_module_level/2`](https://hexdocs.pm/logger/Logger.html#put_module_level/2)
78- [`delete_module_level/1`](https://hexdocs.pm/logger/Logger.html#delete_module_level/1)
79- [`delete_all_module_level/0`](https://hexdocs.pm/logger/Logger.html#delete_all_module_level/0)
80
81These allow us to manipulate verbosity level on per-module basis. What is
82non-obvious and is super handy is that it allows both lowering **and raising**
83verbosity for given module. This mean that:
84
85```elixir
86require Logger
87
88Logger.configure(level: :error)
89
90defmodule Foo do
91 def run do
92 Logger.debug("I am still there")
93 end
94end
95
96Foo.run() # Does not log anything
97
98# Set `debug` level for `Foo` module only
99Logger.put_module_level(Foo, :debug)
100Foo.run()
101# `I am still there` is logged
102Logger.debug("I will not be printed")
103# Nothing got logged as top-level verbositi is still set to `:error`
104```
105
106Of course it will not work if you decide to use [compile time purging][logger-purge]
107
108## Logger handlers {#handlers}
109
110---
111
112**Warning!** This is not fully implemented in both - Erlang and Elixir. Writing
113your own handlers without additional knowledge can cause overload problems.
114
115---
116
117Erlang together with their logging implementation needed to provide a way to
118ingest these logs somehow. This is done via Erlang logger handlers (in this
119article called *handlers* in contrast to Elixir backends called *backends*
120there).
121
122Handlers are modules that export at least 1 function `log/2` that takes 2
123arguments:
124
125- `log_event` which is a map with 3 fields:
126 - `:level` - verbosity level
127 - `:msg` - tuple describing message:
128 - `{:io.format(), [term()]}` - format string and list of terms that should
129 be passed to `:io_lib.format/2` function
130 - `{:report, map() | keyword()}` - report that can be formatted into string
131 by `report_cb/{1,2}` set in metadata map (see below)
132 - `{:string, :unicode.chardata()}` - raw string that should be printed as
133 a message
134 - `:meta` - map containing all metadata for given event. All keys should be
135 atoms and values can be anything. Some keys have special meaning, and some
136 of them will be populated automatically by the `Logger` macros and functions.
137 These are:
138 - `:pid` - PID of the process that fired log event
139 - `:gl` - group leader of the process that fired log event
140 - `:mfa` - tuple in form of `{module(), name :: atom(), arity :: non_neg_integer()}`
141 that describe function that fired log event
142 - `:file` - filename of file that defines the code that fired log event
143 - `:line` - line in the given file where the log event was fired
144 - `:domain` - list of atoms that can be used to describe log events
145 hierarchy which then can be used for filtering. All events fired using
146 `Logger` macros and functions will have `:elixir` prepended to their
147 domain list.
148 - `:report_cb` - function that will be used to format `{:report, map() |
149 keyword()}` messages. This can be either 1-ary function, that takes report
150 and returns `{:io.format(), [term()]}` leaving truncation and further
151 formatting up to the main formatter, or 2-ary function that takes report
152 and configuration map `%{depth: pos_integer() | :unlimited, chars_limit:
153 pos_integer() | :unlimited, single_line: boolean()}` and returns already
154 formatted `:unicode.chardata()`. More about it can be found in [separate
155 section](#structured-logging).
156
157Return value of this function is ignored. If there will be any exception raised
158when calling this function, then it will be captured and failing handler will be
159removed. This is important, as if such handler is the only one, then you can be
160left without any logging handler and miss logs.
161
162The important thing about Erlang handlers and Elixir backends is that Erlang
163handlers functions are called **within caller process** while Elixir backends
164are called in separate process. This mean that wrongly written Erlang handler
165can cause quite substantial load on application.
166
167To read on other, optional, callbacks that can be defined by Erlang handler, that
168will not be covered there, I suggest looking into [Erlang documentation][formatter_cb].
169
170## Structured logging {#structured-logging}
171
172One of the biggest new features in the Elixir 1.11 is support for structured
173logging. This mean that the log message do not need to be free-form string, but
174instead we can pass structure, that can provide more machine-readable data for
175processing in log aggregators. In Elixir 1.11 is simple as passing map as a
176first argument to the `Logger` macros:
177
178```elixir
179Logger.info(%{
180 status: :completed,
181 response: :ok
182})
183```
184
185This will produce message that looks like:
186
187```log
18814:08:46.849 [info] [response: :ok, status: :completed]
189```
190
191As we can see, the map (called *report*) is formatted as a keyword list. This is
192default way to present the report data. Unfortunately we cannot access the
193metadata from the Elixir backends, but we have 2 ways to make these messages
194more readable for the human operator:
195
1961. Utilise [`Logger`'s translators](https://hexdocs.pm/logger/Logger.Translator.html)
1971. Using `:report_cb` field in metadata
198
1991st option is described quite good in Elixir documentation and is available
200since Elixir 1.0 as it was used to translate `error_logger` messages in old
201Erlang versions. Here I will describe the 2nd option which provide way for
202**caller** to define how report should be formatted into human-readable string.
203
204`:report_cb` accepts 2 kind of functions as an argument:
205
206- 1-ary function, that takes report as an argument and should return tuple
207 in form of `{:io.format(), [term()]}` that will be later formatted
208 respectively by the formatters.
209- 2-ary function that takes report and configuration map as an arguments and
210 should return formatted string.
211
2121st option is much easier for most use cases, as it do not force you to worry
213about handling width, depth, and multiline logs, as it will all be handled for
214you.
215
216For example, instead of doing:
217
218```elixir
219Logger.info("Started HTTP server on http://localhost:8080")
220```
221
222We can do:
223
224```elixir
225Logger.info(
226 %{
227 protocol: :http,
228 port: 8080,
229 address: "localhost",
230 endpoint: MyEndpoint,
231 handler: Plug.Cowboy
232 },
233 report_cb: &__MODULE__.report_cb/1
234)
235
236# …
237
238def report_cb(%{protocol: protocol, port: port, address: address}) do
239 {"Started ~s server on ~s://~s:~B", [protocol, protocol, address, port]}
240end
241```
242
243While the second entry seems much more verbose, with proper handler, it can
244provide much more detailed output. Just imagine that we would have handler that
245output JSON data and what information we could contain in such message:
246
247```json
248{
249 "msg": "Started HTTP server on http://localhost:8080",
250 "metadata": {
251 "mfa": "MyMod.start/2",
252 "file": "foo.ex",
253 "line": 42
254 }
255}
256```
257
258Now our log aggregation service need to parse `msg` field to extract all
259information that is contained there, like port, address, and protocol. With
260structured logging we can have that message available already there while
261presenting the "human readable" form as well:
262
263```json
264{
265 "text": "Started HTTP server on http://localhost:8080",
266 "msg": {
267 "address": "localhost",
268 "port": 8080,
269 "protocol": "http",
270 "endpoint": "MyEndpoint",
271 "handler": "Plug.Cowboy"
272 },
273 "metadata": {
274 "mfa": "MyMod.start/2",
275 "file": "foo.ex",
276 "line": 42
277 }
278}
279```
280
281You can see there that we can have more information available in the structured
282log that would otherwise needed to be crammed somewhere into the text message,
283even if it is not important in "regular" Ops observability.
284
285This can raise a question - why not use metadata for such functionality, like it
286is available in [`LoggerJSON`][] or [`Ink`][]? The reason is that their reason
287existence is different. Metadata meant for "meta" stuff like location, tracing
288ID, but not for the information about the message itself. It is best shown on
289example. For this use Elixir's implementation of `GenServer` wrapper that
290produces error log entry on unknown message handled by default `handle_info/2`:
291
292```elixir
293Logger.error(
294 # Report
295 %{
296 label: {GenServer, :no_handle_info},
297 report: %{
298 module: __MODULE__,
299 message: msg,
300 name: proc
301 }
302 },
303 # Metadata
304 %{
305 error_logger: %{tag: :error_msg},
306 report_cb: &GenServer.format_report/1
307 }
308)
309```
310
311As we can see there, the report contains informations like:
312
313- `:label` - that describes type of the event
314- `:report` - content of the "main" event
315 - `:module` - module that created the event, it is important to notice, that
316 it is also present in metadata (as part of `:mfa` key), but their meaning is
317 different. Module name here is meant for the operator to know the name of
318 the implementor that failed to handle message, while `:mfa` is meant to
319 describe the location of the code that fired the event.
320 - `:message` - the message itself that hasn't been handled. Notice, that it is
321 not stringified in any way there, it is simply passed "as is" to the
322 report. It is meant to be stringified later by the `:report_cb` function.
323 - `:name` - name of the process. Remember, similarly to `:module`, the PID of
324 the current process is part of the metadata, so in theory we could use value
325 from there, but their meaning is different (additionally this one may be an
326 atom in case if the process is locally registered with name).
327
328Metadata on the other hand contains information that will be useful for
329filtering or formatting of the event.
330
331The rule of thumb you can follow is:
332
333> If it is thing that you will want to filter on, then it probably should be
334> part of the metadata. If you want to aggregate information or just display
335> them, it should be part of the message report.
336
337## Log filtering
338
339Finally we come to first feature that is not directly accessible from the Elixir
340`Logger` API (yet). Erlang's `logger` have powerful functionality for filtering
341log messages which allows us to dynamically decide which message should, or
342should not be logged. These even can alter messages on the fly.
343
344Currently that functionality is available only via `:logger` module. It can be
345used like:
346
347```elixir
348defmodule MyFilter do
349 def filter(log_event, opts) do
350 # …
351 end
352end
353
354:logger.add_primary_filter(:my_filter, {&MyFilter.filter/2, opts})
355# Or
356:logger.add_handler_filter(handler_id, :my_filter, {&MyFilter.filter/2, opts})
357```
358
359Few important things that need to be remembered when writing such filters:
360
361- It is best practice to make such functions public and define filters using
362 remote function capture, like `&__MODULE__.process_disabled/2` (so not
363 anonymous functions either). It will make such filter much easier for VM to
364 handle (it is bigger topic why it is that, I may to cover it in another post).
365- Filters are ran **within the same process that fired log event**, so it is
366 important to make such filters as fast as possible, and do not do any heavy
367 work there.
368
369Filters can be used for 2 different things:
370
371- preventing some messages from being logged
372- modifying a message
373
374While the former is much more common, I will try to describe both use cases
375there, as the latter is also quite useful.
376
377Filters are defined as 2-ary functions where 1st argument is log event, and
378second argument is any term that can be used as a configuration for filter.
379Filter should return one of these 3 values:
380
381- `:stop` - which will immediately discard message and do not run any additional
382 filters.
383- `:ignore` - which mean that given filter didn't recognise the given message
384 and leaves it up to other filters to decide on the action. If all filters
385 return `:ignore` then `:filter_default` option for the handler will be taken.
386 By default it is `:log`, which mean that message will be logged, but default
387 handler has it set to `:stop` by default, which mean, that non-matching
388 messages will be discarded.
389- Just log event (possibly modified) that will cause next filter to be called
390 with altered message. The message returned by the last filter (or in case of
391 `:ignore` return, previous filters) will be the message passed to handler.
392
393### Preventing some messages from being logged
394
395Most common use-case for filters will probably be rejecting messages that aren't
396important for us. [Erlang even prepared some useful filters][logger_filters]:
397
398- `domain` - allow filtering by metadata `:domain` field (remember as I said
399 that metadata is for filtering?). It supports multiple possible relations
400 between the log domain and defined domain.
401- `level` - allow filtering (in or out) messages depending on their level, in
402 both directions. It will allow you to filter messages with higher level for
403 some handlers. Just remember, that it will not receive messages that will not
404 pass primary/module level.
405- `progress` - filters all reports from `supervisor` and
406 `application_controller`. Simply, reduces startup/process shutdown chatter
407 that often is meaningless for most time.
408- `remote_gl` - filters messages coming from group leader on another node.
409 Useful when you want to discard/log messages coming from other nodes in
410 cluster.
411
412### Modifying a message
413
414Sometimes there is need to alter messages in the system. For example we may need
415to prevent sensitive information from being logged. When using "old" Elixir
416approach you could abuse translators, but that was error prone, as first
417successful translator was breaking pipeline, so you couldn't just smash one on
418top and then keep rest working as is. With "new" approach and structured logging
419you can just traverse the report and replace all occurrences of the unsafe data
420with anonymised data. For example:
421
422```elixir
423def filter_out_password(%{msg: {:report, report}} = event, _opts) do
424 %{event | msg: {:report, replace(report)}}
425end
426
427@filtered "[FILTERED]"
428
429defp replace(%{password: _} = map) do
430 for {k, v} <- %{map | password: @filtered}, into: %{} do
431 {k, replace(v)}
432 end
433end
434
435defp replace(%{"password" => _} = map) do
436 for {k, v} <- %{map | "password" => @filtered}, into: %{} do
437 {k, replace(v)}
438 end
439end
440
441defp replace(list) when is_list(list) do
442 for elem <- list do
443 case elem do
444 {:password, _} -> {:password, @filtered}
445 {"password", _} -> {"password", @filtered}
446 {k, v} -> {k, replace(v)}
447 other -> replace(other)
448 end
449 end
450end
451
452defp replace(other), do: other
453```
454
455This snippet will replace all occurrences of `:password` or `"password"` with
456filtered out value.
457
458The disadvantage of such approach - it will make all messages with such fields
459allowed in case if your filter has `:filter_default` set to `:stop`. That mean,
460that if you want to make some of them rejected anyway, then you will need to
461manually add additional step to reject messages that do not fit into your
462patterns. Alternatively you can use `filter_default: :log` and then use opt-out
463logging. There currently is no way to alter the message and make other filters
464decide whether log it or not (as of OTP 24).
465
466## Summary
467
468New features and possibilities with relation to logging in Elixir 1.11 can be
469overwhelming. Fortunately all of the new features are optional and provided in
470addition to "good 'ol `Logger.info("logging")`". But for the people who works on
471the observability in BEAM (EEF Observability WG, Sentry, Logflare, etc.) it
472brings a lot of new powerful capabilities.
473
474I am thrilled to see what will people create using all that power.
475
476[erl-log]: https://erlang.org/doc/man/logger.html
477[syslog]: https://en.wikipedia.org/wiki/Syslog#Severity_level
478[`LoggerJSON`]: https://github.com/Nebo15/logger_json
479[`Ink`]: https://hex.pm/packages/ink
480[logger_filters]: https://erlang.org/doc/man/logger_filters.html
481[logger-purge]: https://hexdocs.pm/logger/Logger.html#module-application-configuration
482[formatter_cb]: https://erlang.org/doc/man/logger.html#formatter-callback-functions