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