diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1c56e0..beff446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 09954e5..1e7ee9e 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/config/test.exs b/config/test.exs index f8fa549..4896893 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/logger_json.ex b/lib/logger_json.ex index b194370..eae56d3 100644 --- a/lib/logger_json.ex +++ b/lib/logger_json.ex @@ -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 diff --git a/lib/logger_json/formatter.ex b/lib/logger_json/formatter.ex index ca3a5fc..f6a6a6f 100644 --- a/lib/logger_json/formatter.ex +++ b/lib/logger_json/formatter.ex @@ -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 diff --git a/lib/logger_json/formatter/redactor_encoder.ex b/lib/logger_json/formatter/redactor_encoder.ex index b84aabd..a71631f 100644 --- a/lib/logger_json/formatter/redactor_encoder.ex +++ b/lib/logger_json/formatter/redactor_encoder.ex @@ -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 @@ -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 @@ -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 diff --git a/lib/logger_json/formatters/basic.ex b/lib/logger_json/formatters/basic.ex index f14faf7..608e15f 100644 --- a/lib/logger_json/formatters/basic.ex +++ b/lib/logger_json/formatters/basic.ex @@ -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, []) @@ -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 @@ -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 diff --git a/lib/logger_json/formatters/datadog.ex b/lib/logger_json/formatters/datadog.ex index 541a9c9..c3edcfa 100644 --- a/lib/logger_json/formatters/datadog.ex +++ b/lib/logger_json/formatters/datadog.ex @@ -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) @@ -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 @@ -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 diff --git a/lib/logger_json/formatters/elastic.ex b/lib/logger_json/formatters/elastic.ex index b36d53c..646174a 100644 --- a/lib/logger_json/formatters/elastic.ex +++ b/lib/logger_json/formatters/elastic.ex @@ -130,21 +130,23 @@ defmodule LoggerJSON.Formatters.Elastic do """ import LoggerJSON.Formatter.{MapBuilder, DateTime, Message, Metadata, RedactorEncoder} - require Jason.Helpers + alias LoggerJSON.Formatter - @behaviour LoggerJSON.Formatter + @behaviour Formatter @ecs_version "8.11.0" + @encoder Formatter.encoder() + @processed_metadata_keys ~w[file line mfa domain error_logger otel_span_id span_id otel_trace_id trace_id conn]a - @impl LoggerJSON.Formatter + @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, []) @@ -168,7 +170,7 @@ defmodule LoggerJSON.Formatters.Elastic do |> maybe_merge(format_http_request(meta)) |> maybe_put(:"span.id", format_span_id(meta)) |> maybe_put(:"trace.id", format_trace_id(meta)) - |> Jason.encode_to_iodata!(encoder_opts) + |> @encoder.encode_to_iodata!(encoder_opts) [line, "\n"] end @@ -281,13 +283,13 @@ defmodule LoggerJSON.Formatters.Elastic do # - event.duration (note: ns, not μs): https://www.elastic.co/guide/en/ecs/current/ecs-event.html#field-event-duration defp format_http_request(%{conn: %Plug.Conn{} = conn, duration_us: duration_us}) do %{ - "client.ip": LoggerJSON.Formatter.Plug.remote_ip(conn), + "client.ip": Formatter.Plug.remote_ip(conn), "http.version": Plug.Conn.get_http_protocol(conn), "http.request.method": conn.method, - "http.request.referrer": LoggerJSON.Formatter.Plug.get_header(conn, "referer"), + "http.request.referrer": Formatter.Plug.get_header(conn, "referer"), "http.response.status_code": conn.status, "url.path": conn.request_path, - "user_agent.original": LoggerJSON.Formatter.Plug.get_header(conn, "user-agent") + "user_agent.original": Formatter.Plug.get_header(conn, "user-agent") } |> maybe_put(:"event.duration", to_nanosecs(duration_us)) end @@ -311,6 +313,8 @@ defmodule LoggerJSON.Formatters.Elastic do defp safe_chardata_to_string(other), do: other - 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 diff --git a/lib/logger_json/formatters/google_cloud.ex b/lib/logger_json/formatters/google_cloud.ex index 6207a74..56c01a8 100644 --- a/lib/logger_json/formatters/google_cloud.ex +++ b/lib/logger_json/formatters/google_cloud.ex @@ -88,20 +88,22 @@ defmodule LoggerJSON.Formatters.GoogleCloud do "time" => "2024-04-11T21:34:53.503Z" } """ - import Jason.Helpers, only: [json_map: 1] import LoggerJSON.Formatter.{MapBuilder, DateTime, Message, Metadata, Code, RedactorEncoder} + require LoggerJSON.Formatter, as: Formatter - @behaviour LoggerJSON.Formatter + @behaviour Formatter + + @encoder Formatter.encoder() @processed_metadata_keys ~w[pid 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) redactors = Keyword.get(opts, :redactors, []) service_context = Keyword.get_lazy(opts, :service_context, fn -> %{service: to_string(node())} end) project_id = Keyword.get(opts, :project_id) @@ -130,7 +132,7 @@ defmodule LoggerJSON.Formatters.GoogleCloud do |> maybe_put(:httpRequest, format_http_request(meta)) |> maybe_merge(encode(message, redactors)) |> maybe_merge(encode(metadata, redactors)) - |> Jason.encode_to_iodata!(encoder_opts) + |> @encoder.encode_to_iodata!(encoder_opts) [line, "\n"] end @@ -241,37 +243,64 @@ defmodule LoggerJSON.Formatters.GoogleCloud do defp format_crash_report_location(_meta), do: nil if Code.ensure_loaded?(Plug.Conn) do - defp format_http_request(%{conn: %Plug.Conn{} = conn} = assigns) do - request_method = conn.method |> to_string() |> String.upcase() - request_url = Plug.Conn.request_url(conn) - status = conn.status - 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") - latency = http_request_latency(assigns) - - json_map( - protocol: Plug.Conn.get_http_protocol(conn), - requestMethod: request_method, - requestUrl: request_url, - status: status, - userAgent: user_agent, - remoteIp: remote_ip, - referer: referer, - latency: latency - ) + Formatter.with Jason do + require Jason.Helpers + + defp format_http_request(%{conn: %Plug.Conn{} = conn} = assigns) do + request_method = conn.method |> to_string() |> String.upcase() + request_url = Plug.Conn.request_url(conn) + status = conn.status + user_agent = Formatter.Plug.get_header(conn, "user-agent") + remote_ip = Formatter.Plug.remote_ip(conn) + referer = Formatter.Plug.get_header(conn, "referer") + latency = http_request_latency(assigns) + + Jason.Helpers.json_map( + protocol: Plug.Conn.get_http_protocol(conn), + requestMethod: request_method, + requestUrl: request_url, + status: status, + userAgent: user_agent, + remoteIp: remote_ip, + referer: referer, + latency: latency + ) + end + else + defp format_http_request(%{conn: %Plug.Conn{} = conn} = assigns) do + request_method = conn.method |> to_string() |> String.upcase() + request_url = Plug.Conn.request_url(conn) + status = conn.status + user_agent = Formatter.Plug.get_header(conn, "user-agent") + remote_ip = Formatter.Plug.remote_ip(conn) + referer = Formatter.Plug.get_header(conn, "referer") + latency = http_request_latency(assigns) + + %{ + protocol: Plug.Conn.get_http_protocol(conn), + requestMethod: request_method, + requestUrl: request_url, + status: status, + userAgent: user_agent, + remoteIp: remote_ip, + referer: referer, + latency: latency + } + end end end defp format_http_request(_meta), do: nil - defp http_request_latency(%{duration_us: duration_us}) do - duration_s = Float.round(duration_us / 1_000_000, 9) - "#{duration_s}s" - end + if Code.ensure_loaded?(Plug.Conn) do + defp http_request_latency(%{duration_us: duration_us}) do + duration_s = Float.round(duration_us / 1_000_000, 9) + "#{duration_s}s" + end - defp http_request_latency(_assigns) do - nil + defp http_request_latency(_assigns) do + nil + end end defp format_affected_user(%{user_id: user_id}), do: "user:" <> user_id @@ -323,19 +352,41 @@ defmodule LoggerJSON.Formatters.GoogleCloud do # coveralls-ignore-stop # https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogEntryOperation - defp format_operation(%{request_id: request_id, pid: pid}), do: json_map(id: request_id, producer: inspect(pid)) - defp format_operation(%{pid: pid}), do: json_map(producer: inspect(pid)) + Formatter.with Jason do + require Jason.Helpers + + defp format_operation(%{request_id: request_id, pid: pid}), + do: Jason.Helpers.json_map(id: request_id, producer: inspect(pid)) + + defp format_operation(%{pid: pid}), do: Jason.Helpers.json_map(producer: inspect(pid)) + else + defp format_operation(%{request_id: request_id, pid: pid}), do: %{id: request_id, producer: inspect(pid)} + defp format_operation(%{pid: pid}), do: %{producer: inspect(pid)} + end + # Erlang logger always has `pid` in the metadata but we keep this clause "just in case" # coveralls-ignore-next-line defp format_operation(_meta), do: nil # https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogEntrySourceLocation - defp format_source_location(%{file: file, line: line, mfa: {m, f, a}}) do - json_map( - file: IO.chardata_to_string(file), - line: line, - function: format_function(m, f, a) - ) + Formatter.with Jason do + require Jason.Helpers + + defp format_source_location(%{file: file, line: line, mfa: {m, f, a}}) do + Jason.Helpers.json_map( + file: IO.chardata_to_string(file), + line: line, + function: format_function(m, f, a) + ) + end + else + defp format_source_location(%{file: file, line: line, mfa: {m, f, a}}) do + %{ + file: IO.chardata_to_string(file), + line: line, + function: format_function(m, f, a) + } + end end defp format_source_location(_meta), diff --git a/lib/logger_json/redactor.ex b/lib/logger_json/redactor.ex index 496004e..fa52dd9 100644 --- a/lib/logger_json/redactor.ex +++ b/lib/logger_json/redactor.ex @@ -2,8 +2,8 @@ defmodule LoggerJSON.Redactor do @moduledoc """ This module provides a behaviour which allows to redact sensitive information from logs. - *Note*: redactor will not be applied on `Jason.Fragment` structs. For more information - about encoding and redacting see `LoggerJSON.Formatter.RedactorEncoder.encode/2`. + *Note*: redactor will not be applied on `Jason.Fragment` structs if the encoder is `Jason`. + For more information about encoding and redacting see `LoggerJSON.Formatter.RedactorEncoder.encode/2`. """ @doc """ diff --git a/mix.exs b/mix.exs index 79e88ca..2c23b54 100644 --- a/mix.exs +++ b/mix.exs @@ -35,7 +35,7 @@ defmodule LoggerJSON.Mixfile do defp deps do [ - {:jason, "~> 1.4"}, + {:jason, "~> 1.4", optional: true}, {:plug, "~> 1.15", optional: true}, {:decimal, ">= 0.0.0", optional: true}, {:ecto, "~> 3.11", optional: true}, diff --git a/test/logger_json/formatter/redactor_encoder_test.exs b/test/logger_json/formatter/redactor_encoder_test.exs index f4e9abc..8ddae0b 100644 --- a/test/logger_json/formatter/redactor_encoder_test.exs +++ b/test/logger_json/formatter/redactor_encoder_test.exs @@ -7,6 +7,7 @@ defmodule LoggerJSON.Formatter.RedactorEncoderTest do defmodule PasswordStruct, do: defstruct(password: "foo") + @encoder LoggerJSON.Formatter.encoder() @redactors [{LoggerJSON.Redactors.RedactKeys, ["password"]}] describe "encode/2" do @@ -54,10 +55,10 @@ defmodule LoggerJSON.Formatter.RedactorEncoderTest do assert encode(%PasswordStruct{password: "hello"}, @redactors) == %{password: "[REDACTED]"} end - # Jason.Encoder protocol can be used in many other scenarios, + # Jason.Encoder or JSON.Encoder protocols can be used in many other scenarios, # like DB/API response serliazation, so it's better not to # assume that it's what the users expects to see in logs. - test "strips structs when Jason.Encoder is derived for them" do + test "strips structs when encoder is derived for them" do assert encode(%NameStruct{name: "B"}, @redactors) == %{name: "B"} end @@ -81,7 +82,7 @@ defmodule LoggerJSON.Formatter.RedactorEncoderTest do assert encode(%{1 => 2}, []) == %{1 => 2} assert encode(%{:a => 1}, []) == %{:a => 1} assert encode(%{{"a", "b"} => 1}, []) == %{"{\"a\", \"b\"}" => 1} - assert encode(%{%{a: 1, b: 2} => 3}, []) == %{"%{a: 1, b: 2}" => 3} + assert encode(%{%{a: 1, b: 2} => 3}, []) in [%{"%{a: 1, b: 2}" => 3}, %{"%{b: 2, a: 1}" => 3}] assert encode(%{[{:a, :b}] => 3}, []) == %{"[a: :b]" => 3} end @@ -138,11 +139,11 @@ defmodule LoggerJSON.Formatter.RedactorEncoderTest do assert encode([foo: ["foo", %{password: "bar"}]], @redactors) == %{foo: ["foo", %{password: "[REDACTED]"}]} end - property "converts any term so that it can be encoded with Jason" do + property "converts any term so that it can be encoded" do check all value <- term() do value |> encode([]) - |> Jason.encode!() + |> @encoder.encode!() end end end diff --git a/test/logger_json/formatter_test.exs b/test/logger_json/formatter_test.exs new file mode 100644 index 0000000..4226f1f --- /dev/null +++ b/test/logger_json/formatter_test.exs @@ -0,0 +1,59 @@ +defmodule LoggerJSON.FormatterTest do + use LoggerJSON.Case, async: true + + require LoggerJSON.Formatter + + @encoder Application.compile_env!(:logger_json, :encoder) + @encoder_protocol Module.concat(@encoder, "Encoder") + @default_encoder_opts if(@encoder == JSON, do: &JSON.protocol_encode/2, else: []) + + describe "default_encoder_opts/0" do + test "returns value based on :encoder env" do + assert LoggerJSON.Formatter.default_encoder_opts() == @default_encoder_opts + end + end + + describe "encoder/0" do + test "returns value based on :encoder env" do + assert LoggerJSON.Formatter.encoder() == @encoder + end + end + + describe "encoder_protocol/0" do + test "returns value based on :encoder env" do + assert LoggerJSON.Formatter.encoder_protocol() == @encoder_protocol + end + end + + describe "with/2" do + test "runs do block if it matches encoder" do + result = + LoggerJSON.Formatter.with @encoder do + quote do + :ok + end + else + quote do + :error + end + end + + assert result == :ok + end + + test "runs else block if it does not match encoder" do + result = + LoggerJSON.Formatter.with Something do + quote do + :error + end + else + quote do + :ok + end + end + + assert result == :ok + end + end +end diff --git a/test/logger_json/formatters/basic_test.exs b/test/logger_json/formatters/basic_test.exs index ffc2243..8ef6de1 100644 --- a/test/logger_json/formatters/basic_test.exs +++ b/test/logger_json/formatters/basic_test.exs @@ -4,6 +4,8 @@ defmodule LoggerJSON.Formatters.BasicTest do alias LoggerJSON.Formatters.Basic require Logger + @encoder LoggerJSON.Formatter.encoder() + setup do formatter = {Basic, metadata: :all} :logger.update_handler_config(:default, :formatter, formatter) @@ -242,14 +244,16 @@ defmodule LoggerJSON.Formatters.BasicTest do } end - test "passing options to encoder" do - formatter = {Basic, encoder_opts: [pretty: true]} - :logger.update_handler_config(:default, :formatter, formatter) + if @encoder == Jason do + test "passing options to encoder" do + formatter = {Basic, encoder_opts: [pretty: true]} + :logger.update_handler_config(:default, :formatter, formatter) - assert capture_log(fn -> - Logger.debug("Hello") - end) =~ - ~r/\n\s{2}"message": "Hello"/ + assert capture_log(fn -> + Logger.debug("Hello") + end) =~ + ~r/\n\s{2}"message": "Hello"/ + end end test "reads metadata from the given application env" do diff --git a/test/logger_json/formatters/datadog_test.exs b/test/logger_json/formatters/datadog_test.exs index 215c530..a92c732 100644 --- a/test/logger_json/formatters/datadog_test.exs +++ b/test/logger_json/formatters/datadog_test.exs @@ -4,6 +4,8 @@ defmodule LoggerJSON.Formatters.DatadogTest do alias LoggerJSON.Formatters.Datadog require Logger + @encoder LoggerJSON.Formatter.encoder() + setup do formatter = {Datadog, metadata: :all} :logger.update_handler_config(:default, :formatter, formatter) @@ -455,14 +457,16 @@ defmodule LoggerJSON.Formatters.DatadogTest do } = log_entry end - test "passing options to encoder" do - formatter = {Datadog, encoder_opts: [pretty: true]} - :logger.update_handler_config(:default, :formatter, formatter) + if @encoder == Jason do + test "passing options to encoder" do + formatter = {Datadog, encoder_opts: [pretty: true]} + :logger.update_handler_config(:default, :formatter, formatter) - assert capture_log(fn -> - Logger.debug("Hello") - end) =~ - ~r/\n\s{2}"message": "Hello"/ + assert capture_log(fn -> + Logger.debug("Hello") + end) =~ + ~r/\n\s{2}"message": "Hello"/ + end end test "reads metadata from the given application env" do diff --git a/test/logger_json/formatters/elastic_test.exs b/test/logger_json/formatters/elastic_test.exs index d7e40c9..88d5c68 100644 --- a/test/logger_json/formatters/elastic_test.exs +++ b/test/logger_json/formatters/elastic_test.exs @@ -4,6 +4,8 @@ defmodule LoggerJSON.Formatters.ElasticTest do alias LoggerJSON.Formatters.Elastic require Logger + @encoder LoggerJSON.Formatter.encoder() + setup do formatter = {Elastic, metadata: :all} :logger.update_handler_config(:default, :formatter, formatter) @@ -489,14 +491,16 @@ defmodule LoggerJSON.Formatters.ElasticTest do assert message =~ ~r/Task #PID<\d+.\d+.\d+> started from #{inspect(test_pid)} terminating/ end - test "passing options to encoder" do - formatter = {Elastic, encoder_opts: [pretty: true]} - :logger.update_handler_config(:default, :formatter, formatter) + if @encoder == Jason do + test "passing options to encoder" do + formatter = {Elastic, encoder_opts: [pretty: true]} + :logger.update_handler_config(:default, :formatter, formatter) - assert capture_log(fn -> - Logger.debug("Hello") - end) =~ - ~r/\n\s{2}"message": "Hello"/ + assert capture_log(fn -> + Logger.debug("Hello") + end) =~ + ~r/\n\s{2}"message": "Hello"/ + end end test "reads metadata from the given application env" do diff --git a/test/logger_json/formatters/google_cloud_test.exs b/test/logger_json/formatters/google_cloud_test.exs index 9f379b1..b6b0ad3 100644 --- a/test/logger_json/formatters/google_cloud_test.exs +++ b/test/logger_json/formatters/google_cloud_test.exs @@ -4,6 +4,8 @@ defmodule LoggerJSON.Formatters.GoogleCloudTest do alias LoggerJSON.Formatters.GoogleCloud require Logger + @encoder LoggerJSON.Formatter.encoder() + setup do formatter = {GoogleCloud, metadata: :all, project_id: "myproj-101"} :logger.update_handler_config(:default, :formatter, formatter) @@ -14,7 +16,7 @@ defmodule LoggerJSON.Formatters.GoogleCloudTest do assert capture_log(fn -> Logger.debug(message) end) - |> Jason.decode!() + |> @encoder.decode!() end end @@ -23,14 +25,14 @@ defmodule LoggerJSON.Formatters.GoogleCloudTest do assert capture_log(fn -> Logger.debug(message) end) - |> Jason.decode!() + |> @encoder.decode!() end check all message <- StreamData.keyword_of(StreamData.term()) do assert capture_log(fn -> Logger.debug(message) end) - |> Jason.decode!() + |> @encoder.decode!() end end @@ -530,14 +532,16 @@ defmodule LoggerJSON.Formatters.GoogleCloudTest do } = log_entry end - test "passing options to encoder" do - formatter = {GoogleCloud, encoder_opts: [pretty: true]} - :logger.update_handler_config(:default, :formatter, formatter) + if @encoder == Jason do + test "passing options to encoder" do + formatter = {GoogleCloud, encoder_opts: [pretty: true]} + :logger.update_handler_config(:default, :formatter, formatter) - assert capture_log(fn -> - Logger.debug("Hello") - end) =~ - ~r/\n\s{2}"message": "Hello"/ + assert capture_log(fn -> + Logger.debug("Hello") + end) =~ + ~r/\n\s{2}"message": "Hello"/ + end end test "reads metadata from the given application env" do diff --git a/test/support/logger_case.ex b/test/support/logger_case.ex index 192d978..ff23557 100644 --- a/test/support/logger_case.ex +++ b/test/support/logger_case.ex @@ -3,6 +3,8 @@ defmodule LoggerJSON.Case do use ExUnit.CaseTemplate import ExUnit.CaptureIO + @encoder LoggerJSON.Formatter.encoder() + using _ do quote do import LoggerJSON.Case @@ -22,7 +24,7 @@ defmodule LoggerJSON.Case do def decode_or_print_error(data) do try do - Jason.decode!(data) + @encoder.decode!(data) rescue _reason -> IO.puts(data) diff --git a/test/support/name_struct.ex b/test/support/name_struct.ex index 11bf835..b142fd1 100644 --- a/test/support/name_struct.ex +++ b/test/support/name_struct.ex @@ -6,6 +6,7 @@ defmodule NameStruct do are not compiled with the application so not protocol consolidation would happen. """ - @derive Jason.Encoder + @derive LoggerJSON.Formatter.encoder_protocol() + defstruct [:name] end