Skip to content

Commit e4cf7d0

Browse files
committed
Base implementation of formatter and translator
1 parent 2c2fa89 commit e4cf7d0

File tree

8 files changed

+199
-1
lines changed

8 files changed

+199
-1
lines changed

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Used by "mix format"
22
[
3+
import_deps: [:stream_data],
34
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
45
]

lib/medea.ex

Whitespace-only changes.

lib/medea/formatter.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Medea.Formatter do
2+
@moduledoc """
3+
A formatter for JSON logs.
4+
"""
5+
6+
import Jason.Helpers, only: [json_map: 1]
7+
8+
alias Medea.Utils
9+
10+
@type time :: Logger.Formatter.time()
11+
12+
@doc """
13+
Format messages into structured JSON logs.
14+
"""
15+
@spec format(Logger.level(), Logger.message(), time(), keyword()) :: IO.chardata()
16+
def format(level, message, time, metadata) do
17+
[level: level, time: format_time(time), message: message, metadata: Utils.clean(metadata)]
18+
|> json_map()
19+
|> Jason.encode_to_iodata!()
20+
rescue
21+
exception ->
22+
reason = Exception.format_banner(:error, exception)
23+
24+
[~s({"error":"could not log '), inspect(message), "' because: ", reason, ~s("})]
25+
end
26+
27+
defp format_time({date, {h, m, s, ms}}) do
28+
{date, {h, m, s}}
29+
|> NaiveDateTime.from_erl!({ms, 3})
30+
|> NaiveDateTime.to_string()
31+
end
32+
end

lib/medea/translator.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
defmodule Medea.Translator do
2+
@moduledoc """
3+
Translates structured log "reports" into encoded JSON iodata.
4+
"""
5+
6+
alias Medea.Utils
7+
8+
@type kind :: :format | :report
9+
@type level :: Logger.level()
10+
@type report :: :logger.report()
11+
12+
@type result ::
13+
{:ok, iodata(), keyword()}
14+
| {:ok, iodata()}
15+
| :skip
16+
| :none
17+
18+
@doc """
19+
Translate a report into encoded JSON iodata or fall through to the default translator.
20+
"""
21+
@spec translate(level(), level(), kind(), report()) :: result()
22+
def translate(_min, _level, :report, {:logger, message}) do
23+
encoded =
24+
message
25+
|> Utils.clean()
26+
|> Jason.encode!()
27+
28+
{:ok, encoded}
29+
end
30+
31+
def translate(_min_lev, _level, _kind, _message), do: :none
32+
end

lib/medea/utils.ex

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule Medea.Utils do
2+
@moduledoc false
3+
4+
def clean(%Date{} = date), do: date
5+
def clean(%DateTime{} = datetime), do: datetime
6+
def clean(%NaiveDateTime{} = naive), do: naive
7+
def clean(%Time{} = time), do: time
8+
9+
def clean(%_{} = struct) do
10+
struct
11+
|> Map.from_struct()
12+
|> clean()
13+
end
14+
15+
def clean(map) when is_map(map) do
16+
for {key, val} <- map, into: %{}, do: {clean(key), clean(val)}
17+
end
18+
19+
def clean([{key, _val} | _] = keyword) when is_atom(key) do
20+
keyword
21+
|> Map.new()
22+
|> clean()
23+
end
24+
25+
def clean(list) when is_list(list) do
26+
for elem <- list, do: clean(elem)
27+
end
28+
29+
def clean(tuple) when is_tuple(tuple) do
30+
tuple
31+
|> Tuple.to_list()
32+
|> clean
33+
end
34+
35+
def clean(binary) when is_binary(binary), do: Logger.Formatter.prune(binary)
36+
def clean(term) when is_pid(term) or is_reference(term), do: inspect(term)
37+
def clean(term) when is_port(term) or is_function(term), do: inspect(term)
38+
def clean(term), do: term
39+
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ defmodule Medea.MixProject do
1818
defp deps do
1919
[
2020
{:jason, "~> 1.4"},
21-
{:stream_data, "~> 0.5", only: [:test]}
21+
{:stream_data, "~> 0.5", only: [:dev, :test]}
2222
]
2323
end
2424
end

test/medea/formatter_test.exs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule Medea.FormatterTest do
2+
use ExUnit.Case, async: true
3+
4+
use ExUnitProperties
5+
6+
alias Medea.Formatter
7+
8+
@datetime {{2022, 10, 06}, {16, 38, 00, 120}}
9+
10+
defp format(level, message, time, metadata \\ []) do
11+
level
12+
|> Formatter.format(message, time, metadata)
13+
|> IO.iodata_to_binary()
14+
|> Jason.decode!()
15+
end
16+
17+
describe "format/4" do
18+
property "encoding any messages as json iodata" do
19+
check all level <- level(), time <- datetime(), message <- message() do
20+
decoded = format(level, message, time)
21+
22+
assert %{"level" => _, "time" => _, "message" => _} = decoded
23+
end
24+
end
25+
26+
test "formatting log datetimes" do
27+
assert %{"time" => "2022-10-06 16:38:00.000"} = format(:info, "boop", @datetime)
28+
end
29+
30+
test "formatting log metadata" do
31+
metadata = [user_id: 1, admin: true, thing: {:a, :b}]
32+
33+
assert %{"metadata" => meta} = format(:info, "boop", @datetime, metadata)
34+
assert %{"user_id" => 1, "admin" => true, "thing" => ["a", "b"]} = meta
35+
end
36+
37+
test "reporting logging errors" do
38+
assert %{"error" => error} = format(:info, {:error, :broken}, @datetime)
39+
40+
assert error =~ "could not log '{:error, :broken}'"
41+
end
42+
end
43+
44+
defp level, do: one_of([:alert, :error, :warning, :notice])
45+
46+
defp message, do: string(:ascii)
47+
48+
defp datetime do
49+
gen all year <- integer(1977..2022),
50+
month <- integer(1..12),
51+
day <- integer(1..28),
52+
hour <- integer(0..23),
53+
minute <- integer(0..59),
54+
second <- integer(0..59),
55+
ms <- integer(1..9999) do
56+
{{year, month, day}, {hour, minute, second, ms}}
57+
end
58+
end
59+
end

test/medea/translator_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule Medea.TranslatorTest do
2+
use ExUnit.Case, async: true
3+
4+
use ExUnitProperties
5+
6+
alias Medea.Translator
7+
8+
describe "translate/4" do
9+
property "all terms are safely encoded to iodata" do
10+
check all message <- message() do
11+
assert {:ok, iodata} = Translator.translate(:info, :info, :report, {:logger, message})
12+
13+
assert is_binary(iodata)
14+
end
15+
end
16+
17+
test "all non-logger formats or reports are ignored" do
18+
assert :none = Translator.translate(:info, :info, :report, {:not, :from, :logger})
19+
end
20+
end
21+
22+
defp message do
23+
one_of([
24+
atom(:alphanumeric),
25+
binary(),
26+
tuple({key(), val()}),
27+
map_of(key(), val()),
28+
keyword_of(val())
29+
])
30+
end
31+
32+
defp key, do: one_of([atom(:alphanumeric), string(:ascii)])
33+
34+
defp val, do: one_of([atom(:alphanumeric), map_of(key(), key())])
35+
end

0 commit comments

Comments
 (0)