Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions guides/metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Metadata

[Logger metadata](`Logger#module-metadata`) is structured data attached to log
messages. Metadata can be passed to `Logger` with the message:

```elixir
Logger.info("Hello", user_id: 123)
```

or set beforehand with `Logger.metadata/1`:

```elixir
Logger.metadata(user_id: 123)
```

> #### Not Just Keyword List {: .tip}
>
> Big Logger don't want you to know, but maps totally work as metadata too:
>
> ```elixir
> iex> Logger.metadata(%{hello: "world"})
> :ok
> iex> Logger.metadata()
> [hello: "world"]
> ```

Metadata is tricky to use correctly because it behaves very differently in
development versus production environments. In development, the default console
logger outputs minimal metadata, and even when configured to show more, console
space is limited, so developers are pushed to embed important data directly in
log messages. Production logging solutions, however, very much prefer structured
metadata for filtering and searching, paired with static log messages that
enable effective fingerprinting and grouping of similar events.

This guide focuses on the latter approach: using static log messages paired with
rich metadata:

```elixir
Logger.error("Unexpected API response", status_code: 422, user_id: 123)
```

When working with metadata, logging libraries typically grapple with two key
challenges: serialization and scrubbing.

## Serialization

Metadata can hold Elixir terms of any type, but to send them somewhere and
display them to users, they must be serialized. Unfortunately, there's no
universally good way to handle this! Elixir's default
`Logger.Formatter#module-metadata` supports only a handful of types. The
de-facto expectation, however, is that specialized logging libraries can handle
any term and display it reasonably well. Consequently, every logging library
implements a step where it makes the hard decisions about what to do with
tuples, structs, and other complex data types. This process is sometimes called
encoding or sanitization.

One solution that works well and can be easily integrated into your project is
the
[`LoggerJSON.Formatters.RedactorEncoder.encode/2`](https://hexdocs.pm/logger_json/LoggerJSON.Formatter.RedactorEncoder.html#encode/2)
function. It accepts any Elixir term and makes it JSON-serializable:

```elixir
iex> LoggerJSON.Formatter.RedactorEncoder.encode(%{tuple: {:ok, "foo"}, pid: self()}, [])
%{pid: "#PID<0.219.0>", tuple: [:ok, "foo"]}
```

## Scrubbing

Scrubbing is the process of removing sensitive fields from metadata. Data like
passwords, API keys, or credit card numbers should never be sent to your logging
service unnecessarily. While many logging services implement scrubbing on the
receiving end, some libraries handle this on the client side as well.

The challenge with scrubbing is that it must be configurable. Applications store
diverse types of secrets, and no set of default rules can catch them all.
Fortunately, the same solution used for serialization works here too.
[`LoggerJSON.Formatters.RedactorEncoder.encode/2`](https://hexdocs.pm/logger_json/LoggerJSON.Formatter.RedactorEncoder.html#encode/2)
accepts a list of "redactors" that will be called to scrub potentially sensitive
data. It includes a powerful
[`LoggerJSON.Redactors.RedactKeys`](https://hexdocs.pm/logger_json/LoggerJSON.Redactors.RedactKeys.html)
redactor that redacts all values stored under specified keys:

```elixir
iex> LoggerJSON.Formatter.RedactorEncoder.encode(
%{user: "Marion", password: "SCP-3125"},
[{LoggerJSON.Redactors.RedactKeys, ["password"]}]
)
%{user: "Marion", password: "[REDACTED]"}
```

## Conclusion

While [`LoggerJSON`](https://hex.pm/packages/logger_json)'s primary goal isn't to solve our metadata struggles, it
comes with a set of tools that can be very handy. Even if you don't want to
depend on it directly, it can provide you with a good starting point for your
own solution. And `LoggerHandlerKit.Act.metadata_serialization/1` can help you
with test cases!
83 changes: 82 additions & 1 deletion lib/logger_handler_kit/act.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ defmodule LoggerHandlerKit.Act do
Each function represents a case of interest, potentially with different flavors to
highlight various cases.

The functions are divided into three groups: Basic, OTP, and SASL.
The functions are divided into four groups: Basic, OTP, SASL and Metadata.

## Basic

Expand Down Expand Up @@ -45,6 +45,10 @@ defmodule LoggerHandlerKit.Act do
In real life, SASL logs look like reports from supervisors about things that you
would expect: child process restarts and such. They are skipped by Elixir by
default, but a thorough handler might have an interest in them.

## Metadata

These cases focus on challenges that arise from metadata.
"""

import ExUnit.Assertions
Expand Down Expand Up @@ -1662,4 +1666,81 @@ defmodule LoggerHandlerKit.Act do
assert_receive({:EXIT, ^pid, _})
:ok
end

@doc """
Sets the `extra` key in Logger metadata to a sample value of some interesting type.

Metadata can contain arbitrary Elixir terms, and the primary challenge that
loggers face when exporting it is serialization. There is no universally good
way to represent an Elixir term as text, so handlers or formatters must make
hard choices. Some examples:

* Binary strings can contain non-printable characters.
* Structs by default don't implement the `String.Chars` protocol. When they do, the implementation might be designed for a different purpose than logging.
* Tuples can be inspected as text but lose their structure (in JSON), or serialized as lists which preserves structure but misleads about the original type.
* Charlists are indistinguishable from lists in JSON serialization.

The default text formatter [skips](`Logger.Formatter#module-metadata`) many of these complex cases.
"""
@doc group: "Metadata"
@dialyzer :no_improper_lists
@metadata_types %{
boolean: true,
string: "hello world",
binary: <<1, 2, 3>>,
atom: :foo,
integer: 42,
datetime: ~U[2025-06-01T12:34:56.000Z],
struct: %LoggerHandlerKit.FakeStruct{hello: "world"},
tuple: {:ok, "hello"},
keyword: [hello: "world"],
improper_keyword: [{:a, 1} | {:b, 2}],
fake_keyword: [{:a, 1}, {:b, 2, :c}],
list: [1, 2, 3],
improper_list: [1, 2 | 3],
map: %{:hello => "world", "foo" => "bar"},
function: &__MODULE__.metadata_serialization/1
}
@spec metadata_serialization(
:boolean
| :string
| :binary
| :atom
| :integer
| :datetime
| :struct
| :tuple
| :keyword
| :improper_keyword
| :fake_keyword
| :list
| :improper_list
| :map
| :function
| :anonymous_function
| :pid
| :ref
| :port
) :: :ok
def metadata_serialization(:pid), do: Logger.metadata(extra: self())

def metadata_serialization(:anonymous_function),
do: Logger.metadata(extra: fn -> "hello world" end)

def metadata_serialization(:ref), do: Logger.metadata(extra: make_ref())
def metadata_serialization(:port), do: Logger.metadata(extra: Port.list() |> hd())

def metadata_serialization(:all) do
all =
Map.merge(@metadata_types, %{
pid: self(),
anonymous_function: fn -> "hello world" end,
ref: make_ref(),
port: Port.list() |> hd()
})

Logger.metadata(extra: all)
end

def metadata_serialization(case), do: Logger.metadata(extra: Map.fetch!(@metadata_types, case))
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ defmodule LoggerHandlerKit.MixProject do
extras: [
"README.md",
"guides/translation.md",
"guides/unhandled.md"
"guides/unhandled.md",
"guides/metadata.md"
],
groups_for_modules: [
Helpers: [
Expand Down
Loading