Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the usage of JSON for Elixir 1.18+ #146

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
elixir-version: 1.17
check-formatted: true
report-coverage: true
- otp-version: 27
elixir-version: 1.18
check-formatted: true
report-coverage: true
steps:
- uses: actions/checkout@v3
- name: Set up Elixir
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ or during runtime:
:logger.update_handler_config(:default, :formatter, {LoggerJSON.Formatters.Basic, %{metadata: {:all_except, [:conn]}}})
```

It is possible to set during compile-time the JSON encoder:

```elixir
config :logger_json, encoder: Jason
```

For Elixir 1.18+, `JSON` is available and can be set as the encoder:

```elixir
config :logger_json, encoder: JSON
```

For retro-compatibility, `Jason` is the default encoder. Make sure to add it to the project
dependencies if the encoder will not be changed.

## Docs

The docs can be found at [https://hexdocs.pm/logger_json](https://hexdocs.pm/logger_json).
Expand Down
9 changes: 9 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import Config

encoder =
if Version.compare(System.version(), "1.18.0") == :lt do
Jason
else
JSON
end

config :logger_json, encoder: encoder

config :logger,
handle_otp_reports: true,
handle_sasl_reports: false
18 changes: 15 additions & 3 deletions lib/logger_json.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,25 @@ defmodule LoggerJSON do

:logger.update_handler_config(:default, :formatter, {Basic, %{metadata: {:all_except, [:conn]}}})

It is possible to set during compile-time the JSON encoder:

config :logger_json, encoder: Jason

For Elixir 1.18+, `JSON` is available and can be set as the encoder:

config :logger_json, encoder: JSON

For retro-compatibility, `Jason` is the default encoder. Make sure to add it to the project
dependencies if the encoder will not be changed.

### Shared Options

Some formatters require additional configuration options. Here are the options that are common for each formatter:

* `:encoder_opts` - options to be passed directly to the JSON encoder. This allows you to customize the behavior of the JSON
encoder. See the [documentation for Jason](https://hexdocs.pm/jason/Jason.html#encode/2-options) for available options. By
default, no options are passed to the encoder.
* `:encoder_opts` - options to be passed directly to the JSON encoder. This allows you to customize the behavior
of the JSON encoder. If the encoder is `JSON`, it defaults to `JSON.protocol_encode/2`. Otherwise, defaults to
empty keywords. See the [documentation for Jason](https://hexdocs.pm/jason/Jason.html#encode/2-options) for
available options for `Jason` encoder.

* `:metadata` - a list of metadata keys to include in the log entry. By default, no metadata is included.
If `:all`is given, all metadata is included. If `{:all_except, keys}` is given, all metadata except
Expand Down
30 changes: 29 additions & 1 deletion lib/logger_json/formatter.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
defmodule LoggerJSON.Formatter do
@type opts :: [
{:encoder_opts, [Jason.encode_opt()]}
{:encoder_opts, encoder_opts()}
| {:metadata, :all | {:all_except, [atom()]} | [atom()]}
| {:redactors, [{module(), term()}]}
| {atom(), term()}
]

@type encoder_opts :: JSON.encoder() | [Jason.encode_opt()] | term()

@callback format(event :: :logger.log_event(), opts :: opts()) :: iodata()

@encoder Application.compile_env(:logger_json, :encoder, Jason)
@encoder_protocol Application.compile_env(:logger_json, :encoder_protocol) || Module.concat(@encoder, "Encoder")
@default_encoder_opts if(@encoder == JSON, do: &JSON.protocol_encode/2, else: [])

@doc false
@spec default_encoder_opts :: encoder_opts()
def default_encoder_opts, do: @default_encoder_opts

@doc false
@spec encoder :: module()
def encoder, do: @encoder

@doc false
@spec encoder_protocol :: module()
def encoder_protocol, do: @encoder_protocol

@doc false
@spec with(module(), Macro.t()) :: Macro.t()
defmacro with(encoder, block) do
if @encoder == Macro.expand(encoder, __CALLER__) do
block[:do]
else
block[:else]
end
end
end
12 changes: 9 additions & 3 deletions lib/logger_json/formatter/redactor_encoder.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule LoggerJSON.Formatter.RedactorEncoder do
@doc """
Takes a term and makes sure that it can be encoded by Jason.encode!/1 without errors
Takes a term and makes sure that it can be encoded by the encoder without errors
and without leaking sensitive information.

## Encoding rules
Expand All @@ -16,12 +16,14 @@ defmodule LoggerJSON.Formatter.RedactorEncoder do
`atom()` | unchanged | unchanged
`struct()` | converted to map | values are redacted
`keyword()` | converted to map | values are redacted
`%Jason.Fragment{}` | unchanged | unchanged
`%Jason.Fragment{}` | unchanged | unchanged if encoder is `Jason`
everything else | using `inspect/2` | unchanged
"""

@type redactor :: {redactor :: module(), redactor_opts :: term()}

@encoder_protocol LoggerJSON.Formatter.encoder_protocol()

@spec encode(term(), redactors :: [redactor()]) :: term()
def encode(nil, _redactors), do: nil
def encode(true, _redactors), do: true
Expand All @@ -31,7 +33,11 @@ defmodule LoggerJSON.Formatter.RedactorEncoder do
def encode(number, _redactors) when is_number(number), do: number
def encode("[REDACTED]", _redactors), do: "[REDACTED]"
def encode(binary, _redactors) when is_binary(binary), do: encode_binary(binary)
def encode(%Jason.Fragment{} = fragment, _redactors), do: fragment

if @encoder_protocol == Jason.Encoder do
def encode(fragment, _redactors) when is_struct(fragment, Jason.Fragment), do: fragment
end

def encode(%NaiveDateTime{} = naive_datetime, _redactors), do: naive_datetime
def encode(%DateTime{} = datetime, _redactors), do: datetime
def encode(%Date{} = date, _redactors), do: date
Expand Down
53 changes: 37 additions & 16 deletions lib/logger_json/formatters/basic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ defmodule LoggerJSON.Formatters.Basic do
}
"""
import LoggerJSON.Formatter.{MapBuilder, DateTime, Message, Metadata, RedactorEncoder}
require Jason.Helpers
require LoggerJSON.Formatter, as: Formatter

@behaviour LoggerJSON.Formatter
@behaviour Formatter

@encoder Formatter.encoder()

@processed_metadata_keys ~w[file line mfa
otel_span_id span_id
otel_trace_id trace_id
conn]a

@impl true
@impl Formatter
def format(%{level: level, meta: meta, msg: msg}, opts) do
opts = Keyword.new(opts)
encoder_opts = Keyword.get(opts, :encoder_opts, [])
encoder_opts = Keyword.get_lazy(opts, :encoder_opts, &Formatter.default_encoder_opts/0)
metadata_keys_or_selector = Keyword.get(opts, :metadata, [])
metadata_selector = update_metadata_selector(metadata_keys_or_selector, @processed_metadata_keys)
redactors = Keyword.get(opts, :redactors, [])
Expand All @@ -49,7 +51,7 @@ defmodule LoggerJSON.Formatters.Basic do
|> maybe_put(:request, format_http_request(meta))
|> maybe_put(:span, format_span(meta))
|> maybe_put(:trace, format_trace(meta))
|> Jason.encode_to_iodata!(encoder_opts)
|> @encoder.encode_to_iodata!(encoder_opts)

[line, "\n"]
end
Expand All @@ -74,21 +76,40 @@ defmodule LoggerJSON.Formatters.Basic do
end

if Code.ensure_loaded?(Plug.Conn) do
defp format_http_request(%{conn: %Plug.Conn{} = conn}) do
Jason.Helpers.json_map(
connection:
Jason.Helpers.json_map(
Formatter.with Jason do
require Jason.Helpers

defp format_http_request(%{conn: %Plug.Conn{} = conn}) do
Jason.Helpers.json_map(
connection:
Jason.Helpers.json_map(
protocol: Plug.Conn.get_http_protocol(conn),
method: conn.method,
path: conn.request_path,
status: conn.status
),
client:
Jason.Helpers.json_map(
user_agent: Formatter.Plug.get_header(conn, "user-agent"),
ip: Formatter.Plug.remote_ip(conn)
)
)
end
else
defp format_http_request(%{conn: %Plug.Conn{} = conn}) do
%{
connection: %{
protocol: Plug.Conn.get_http_protocol(conn),
method: conn.method,
path: conn.request_path,
status: conn.status
),
client:
Jason.Helpers.json_map(
user_agent: LoggerJSON.Formatter.Plug.get_header(conn, "user-agent"),
ip: LoggerJSON.Formatter.Plug.remote_ip(conn)
)
)
},
client: %{
user_agent: Formatter.Plug.get_header(conn, "user-agent"),
ip: Formatter.Plug.remote_ip(conn)
}
}
end
end
end

Expand Down
103 changes: 72 additions & 31 deletions lib/logger_json/formatters/datadog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@ defmodule LoggerJSON.Formatters.Datadog do
}
"""
import LoggerJSON.Formatter.{MapBuilder, DateTime, Message, Metadata, Code, RedactorEncoder}
require Jason.Helpers
require LoggerJSON.Formatter, as: Formatter

@behaviour LoggerJSON.Formatter
@behaviour Formatter

@encoder Formatter.encoder()

@processed_metadata_keys ~w[pid file line mfa conn]a

@impl true
@impl Formatter
def format(%{level: level, meta: meta, msg: msg}, opts) do
opts = Keyword.new(opts)
encoder_opts = Keyword.get(opts, :encoder_opts, [])
encoder_opts = Keyword.get_lazy(opts, :encoder_opts, &Formatter.default_encoder_opts/0)
redactors = Keyword.get(opts, :redactors, [])
hostname = Keyword.get(opts, :hostname, :system)

Expand All @@ -78,7 +80,7 @@ defmodule LoggerJSON.Formatters.Datadog do
|> maybe_merge(format_http_request(meta))
|> maybe_merge(encode(metadata, redactors))
|> maybe_merge(encode(message, redactors))
|> Jason.encode_to_iodata!(encoder_opts)
|> @encoder.encode_to_iodata!(encoder_opts)

[line, "\n"]
end
Expand Down Expand Up @@ -198,39 +200,78 @@ defmodule LoggerJSON.Formatters.Datadog do

if Code.ensure_loaded?(Plug.Conn) do
defp format_http_request(%{conn: %Plug.Conn{} = conn, duration_us: duration_us} = meta) do
request_url = Plug.Conn.request_url(conn)
user_agent = LoggerJSON.Formatter.Plug.get_header(conn, "user-agent")
remote_ip = LoggerJSON.Formatter.Plug.remote_ip(conn)
referer = LoggerJSON.Formatter.Plug.get_header(conn, "referer")
conn
|> build_http_request_data(meta[:request_id])
|> maybe_put(:duration, to_nanosecs(duration_us))
end

%{
http:
Jason.Helpers.json_map(
defp format_http_request(%{conn: %Plug.Conn{} = conn}), do: format_http_request(%{conn: conn, duration_us: nil})
end

defp format_http_request(_meta), do: nil

if Code.ensure_loaded?(Plug.Conn) do
Formatter.with Jason do
require Jason.Helpers

defp build_http_request_data(%Plug.Conn{} = conn, request_id) do
request_url = Plug.Conn.request_url(conn)
user_agent = Formatter.Plug.get_header(conn, "user-agent")
remote_ip = Formatter.Plug.remote_ip(conn)
referer = Formatter.Plug.get_header(conn, "referer")

%{
http:
Jason.Helpers.json_map(
url: request_url,
status_code: conn.status,
method: conn.method,
referer: referer,
request_id: request_id,
useragent: user_agent,
url_details:
Jason.Helpers.json_map(
host: conn.host,
port: conn.port,
path: conn.request_path,
queryString: conn.query_string,
scheme: conn.scheme
)
),
network: Jason.Helpers.json_map(client: Jason.Helpers.json_map(ip: remote_ip))
}
end
else
defp build_http_request_data(%Plug.Conn{} = conn, request_id) do
request_url = Plug.Conn.request_url(conn)
user_agent = Formatter.Plug.get_header(conn, "user-agent")
remote_ip = Formatter.Plug.remote_ip(conn)
referer = Formatter.Plug.get_header(conn, "referer")

%{
http: %{
url: request_url,
status_code: conn.status,
method: conn.method,
referer: referer,
request_id: meta[:request_id],
request_id: request_id,
useragent: user_agent,
url_details:
Jason.Helpers.json_map(
host: conn.host,
port: conn.port,
path: conn.request_path,
queryString: conn.query_string,
scheme: conn.scheme
)
),
network: Jason.Helpers.json_map(client: Jason.Helpers.json_map(ip: remote_ip))
}
|> maybe_put(:duration, to_nanosecs(duration_us))
url_details: %{
host: conn.host,
port: conn.port,
path: conn.request_path,
queryString: conn.query_string,
scheme: conn.scheme
}
},
network: %{client: %{ip: remote_ip}}
}
end
end

defp format_http_request(%{conn: %Plug.Conn{} = conn}), do: format_http_request(%{conn: conn, duration_us: nil})
end

defp format_http_request(_meta), do: nil

defp to_nanosecs(duration_us) when is_number(duration_us), do: duration_us * 1000
defp to_nanosecs(_), do: nil
if Code.ensure_loaded?(Plug.Conn) do
defp to_nanosecs(duration_us) when is_number(duration_us), do: duration_us * 1000
defp to_nanosecs(_), do: nil
end
end
Loading
Loading