diff --git a/.credo.exs b/.credo.exs index 5f361cb..cc40c6f 100644 --- a/.credo.exs +++ b/.credo.exs @@ -105,7 +105,7 @@ ## Refactoring Opportunities # {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 12]}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 16]}, {Credo.Check.Refactor.FunctionArity, []}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MapInto, false}, diff --git a/config/config.exs b/config/config.exs index 7c0be37..659f465 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,6 +8,7 @@ config :telemetria, [:test, :telemetria, :example, :sum_with_doubled], [:test, :telemetria, :example, :half], [:test, :telemetria, :example, :half_named, :foo], + [:test, :telemetria, :example, :third], [:test, :telemetria, :example, :tmed], [:test, :telemetria, :example, :tmed_do], [:test, :telemetria, :example, :guarded], @@ -21,3 +22,7 @@ config :telemetria, # config :logger, :default_formatter, # format: {Telemetria.Formatter, :format}, # metadata: :all + +if Mix.env() == :test do + config :telemetria, :messenger_channels, %{mox: {:mox, []}} +end diff --git a/examples/otel/config/runtime.exs.stdout b/examples/otel/config/runtime.exs.stdout new file mode 100644 index 0000000..0df168b --- /dev/null +++ b/examples/otel/config/runtime.exs.stdout @@ -0,0 +1,5 @@ +import Config + +config :opentelemetry, + span_processor: :batch, + traces_exporter: {:otel_exporter_stdout, []} diff --git a/examples/tm/config/config.exs b/examples/tm/config/config.exs index 4e0d2f2..4fb5165 100644 --- a/examples/tm/config/config.exs +++ b/examples/tm/config/config.exs @@ -6,4 +6,6 @@ config :telemetria, events: [ [:tm, :f_to_c] ], - throttle: %{some_group: {1_000, :last}} + throttle: %{some_group: {1_000, :last}}, + # create a slack app and put URL here + messenger_channels: %{slack: {:slack, url: ""}} diff --git a/examples/tm/lib/tm.ex b/examples/tm/lib/tm.ex index 7b6b83e..77c488f 100644 --- a/examples/tm/lib/tm.ex +++ b/examples/tm/lib/tm.ex @@ -3,9 +3,11 @@ defmodule Tm do use Telemetria - @telemetria level: :info, group: :weather_reports, locals: [:celsius] + @telemetria level: :info, group: :weather_reports, locals: [:celsius], messenger: :slack def f_to_c(fahrenheit) do - celsius = (fahrenheit - 32) * 5 / 9 + celsius = do_f_to_c(fahrenheit) round(celsius) end + + defp do_f_to_c(fahrenheit), do: (fahrenheit - 32) * 5 / 9 end diff --git a/examples/tm/mix.lock b/examples/tm/mix.lock index f3afd8e..aafdf84 100644 --- a/examples/tm/mix.lock +++ b/examples/tm/mix.lock @@ -1,6 +1,6 @@ %{ "doctest_formatter": {:hex, :doctest_formatter, "0.3.1", "a3fd87c1f75e8a78e7737ec4a4494800ddda705998a59320b87fe4c59c030794", [:mix], [], "hexpm", "3c092540d8b73ffc526a92daa2dc2ecd50714f14325eeacbc7b4e790f890443a"}, - "estructura": {:hex, :estructura, "1.6.0", "951be10eb4ed1a7e8acc6c965835c3803f39fcb66669d19347d4ff3f212e9dbb", [:mix], [{:doctest_formatter, "~> 0.2", [hex: :doctest_formatter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}], "hexpm", "1d5d180b0174a8100c175f42d78aa0f6d3afe79dd16ae0d28591bb03d406369b"}, + "estructura": {:hex, :estructura, "1.6.0", "c55ded89911301f9f965f9e7e319d8d2edebb001aa82619866b01adf474744c0", [:mix], [{:doctest_formatter, "~> 0.2", [hex: :doctest_formatter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}], "hexpm", "f374cf08158782a04819cd4f97ad1dfcf52512aaf664e467a524bf23ceb0da37"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, diff --git a/examples/tm/test/tm_test.exs b/examples/tm/test/tm_test.exs index 1c11c25..655b9aa 100644 --- a/examples/tm/test/tm_test.exs +++ b/examples/tm/test/tm_test.exs @@ -12,7 +12,7 @@ defmodule TmTest do log = capture_log(fn -> assert Tm.f_to_c(451) == 233 - Process.sleep(100) + Process.sleep(500) end) assert log =~ "[warning] Unexpected throttle setting for group `:weather_reports` → nil" @@ -25,7 +25,7 @@ defmodule TmTest do {:ok, log} = with_log(fn -> Tm.f_to_c(451) - Process.sleep(100) + Process.sleep(500) end) assert log =~ diff --git a/lib/options.ex b/lib/options.ex index 935d9d4..4fbd992 100644 --- a/lib/options.ex +++ b/lib/options.ex @@ -52,6 +52,11 @@ defmodule Telemetria.Options do doc: "The backend to be used as an actual implementation", default: Telemetria.Backend.Telemetry ], + messenger_channels: [ + type: :map, + doc: "The messenger channels as a map `%{name => {impl, opts}}`", + default: %{} + ], level: [ type: {:custom, Telemetria.Options, :log_level, []}, doc: diff --git a/lib/telemetria.ex b/lib/telemetria.ex index 74def97..f7d89e3 100644 --- a/lib/telemetria.ex +++ b/lib/telemetria.ex @@ -38,6 +38,9 @@ defmodule Telemetria do - **`reshape: (map() -> map())`** — the function to be called on the resulting attributes to reshape them before sending to the actual telemetry handler; the default application-wide reshaper might be set in `:telemetria, :reshaper` config + - **`messenger_channels: %{optional(atom()) => {module, keyword()}`** — more handy messenger + management, several channels config with channels names associated with their + implementations and properties ### Example @@ -135,7 +138,9 @@ defmodule Telemetria do @type event_prefix :: [atom()] @type handler_config :: term() + @default_level Application.compile_env(:telemetria, :level, :info) @default_reshaper Application.compile_env(:telemetria, :reshaper) + @messenger_channels Application.compile_env(:telemetria, :messenger_channels, %{}) @doc false defmacro __using__(opts) do @@ -327,6 +332,8 @@ defmodule Telemetria do "transform must be a tuple `{mod, fun}` or a function capture, #{inspect(weird)} given" end + level = get_in(context, [:options, :level]) || @default_level + group = get_in(context, [:options, :group]) args_transform = @@ -341,6 +348,16 @@ defmodule Telemetria do reshape = context |> get_in([:options, :reshape]) |> Kernel.||(@default_reshaper) + messenger = + context + |> get_in([:options, :messenger]) + |> case do + nil -> nil + false -> false + {mod, opts} -> {mod, Keyword.put_new(opts, :level, level)} + channel -> get_channel_info(channel, level) + end + {clause_args, context} = Keyword.pop(context, :arguments, []) args = Keyword.merge(args, clause_args) @@ -373,7 +390,8 @@ defmodule Telemetria do Telemetria.Throttler.execute( unquote(group), - {block_ctx, %{system_time: now, consumed: benchmark}, attributes, unquote(reshape)} + {block_ctx, %{system_time: now, consumed: benchmark}, attributes, unquote(reshape), + unquote(messenger)} ) Backend.exit(block_ctx) @@ -409,6 +427,13 @@ defmodule Telemetria do defp variablize({:%{}, _, elems}), do: {:map, Enum.map(elems, &variablize/1)} defp variablize({var, _, _} = val), do: {var, val} + defp get_channel_info(channel, level) do + case Map.get(@messenger_channels, channel, {channel, []}) do + {mod, opts} -> {mod, Keyword.put(opts, :level, level)} + mod when is_atom(mod) -> {mod, level: level} + end + end + defp extract_guards([]), do: [] defp extract_guards([_ | _] = list) do diff --git a/lib/telemetria/messenger.ex b/lib/telemetria/messenger.ex new file mode 100644 index 0000000..708f9a7 --- /dev/null +++ b/lib/telemetria/messenger.ex @@ -0,0 +1,64 @@ +defmodule Telemetria.Messenger do + @moduledoc """ + The helper allowing quick sending of the telemetry events to the messenger. + """ + + @typedoc "The type of the message the messenger is to process and send" + @type message :: %{ + required(atom()) => term() + } + + @doc "The formatter of the incoming message, producing the binary to be sent over the wire" + @callback format(message(), keyword()) :: message() | String.t() + + @doc "The actual implementation of the message sending (`debug` level)" + @callback debug(message(), keyword()) :: {:ok, term()} | {:error, term()} + + @doc "The actual implementation of the message sending (`info` level)" + @callback info(message(), keyword()) :: {:ok, term()} | {:error, term()} + + @doc "The actual implementation of the message sending (`warning` level)" + @callback warning(message(), keyword()) :: {:ok, term()} | {:error, term()} + + @doc "The actual implementation of the message sending (`error` level)" + @callback error(message(), keyword()) :: {:ok, term()} | {:error, term()} + + @optional_callbacks format: 2 + + @implementation Telemetria.Messenger.Logger + + @doc "Routes the message to the configured messenger(s)" + @spec post(message() | String.t(), impl :: atom() | module(), opts :: keyword()) :: + {:ok, term()} | {:error, term()} + def post(message, impl \\ @implementation, opts \\ []) + + def post(%{} = message, impl, opts) do + impl = fix_impl_name(impl) + + message = + if function_exported?(impl, :format, 2), + do: impl.format(message, opts), + else: inspect(message, opts) + + do_post(message, impl, opts) + end + + def post(message, impl, opts) when is_binary(message), + do: do_post(message, impl, opts) + + defp do_post(message, impl, opts) do + impl = fix_impl_name(impl) + {level, opts} = Keyword.pop(opts, :level, :info) + apply(impl, level, [message, opts]) + end + + @spec fix_impl_name(atom()) :: module() + defp fix_impl_name(true), do: fix_impl_name(@implementation) + + defp fix_impl_name(impl) do + case to_string(impl) do + "Elixir." <> _ -> impl + _ -> Module.concat([Telemetria, Messenger, impl |> to_string() |> Macro.camelize()]) + end + end +end diff --git a/lib/telemetria/messenger/logger.ex b/lib/telemetria/messenger/logger.ex new file mode 100644 index 0000000..ed4e3f9 --- /dev/null +++ b/lib/telemetria/messenger/logger.ex @@ -0,0 +1,18 @@ +defmodule Telemetria.Messenger.Logger do + @moduledoc false + + require Logger + + @behaviour Telemetria.Messenger + + @impl true + def format(message, opts), do: inspect(message, opts) + + Enum.each(~w|debug info warning error|a, fn level -> + @impl true + def unquote(level)(message, opts), + do: post(unquote(level), message, opts) + end) + + defp post(level, message, opts), do: {Logger.log(level, message), opts} +end diff --git a/lib/telemetria/messenger/slack.ex b/lib/telemetria/messenger/slack.ex new file mode 100644 index 0000000..ba20aad --- /dev/null +++ b/lib/telemetria/messenger/slack.ex @@ -0,0 +1,111 @@ +defmodule Telemetria.Messenger.Slack do + @moduledoc false + + @behaviour Telemetria.Messenger + + @impl true + # %{ + # args: [a: 42], + # env: %{ + # function: {:half, 1}, + # line: 23, + # module: Test.Telemetria.Example, + # file: "/home/am/Proyectos/Elixir/telemetria/test/support/telemetria_tester.ex" + # }, + # context: [], + # result: 21.0, + # locals: [], + # measurements: %{ + # system_time: [ + # system: 1732885662078938716, + # monotonic: -576460750195561, + # utc: ~U[2024-11-29 13:07:42.078940Z] + # ], + # consumed: 3336 + # }, + # telemetria_group: :default + # } + def format(message, opts) do + with {event, message} <- Map.pop(message, :event), + {%{function: {f, a}} = env, message} <- Map.pop(message, :env), + {level, message} <- + Map.pop_lazy(message, :level, fn -> Keyword.get(opts, :level, :info) end), + {icon, message} <- Map.pop(message, :icon, slack_icon(level)) do + title = Enum.join(event, ".") + + pretext = + env.module + |> Function.capture(f, a) + |> inspect() + |> Kernel.<>("\n#{env.file}:#{env.line}") + + fields = + message + |> Estructura.Flattenable.flatten(jsonify: true) + |> Enum.map(fn {k, v} -> + %{ + title: k, + value: v, + short: not is_binary(v) or String.length(v) < 32 + } + end) + + attachments = + %{ + color: slack_color(level), + fields: fields, + mrkdwn_in: ["title", "text", "pretext"] + } + |> Map.merge(%{pretext: "```\n" <> pretext <> "\n```"}) + + fallback = + [title, pretext] + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + %{ + description: title, + emoji_icon: icon, + fallback: fallback, + mrkdwn: true, + attachments: [attachments] + } + end + end + + Enum.each(~w|debug info warning error|a, fn level -> + @impl true + def unquote(level)(message, opts), + do: post(unquote(level), message, opts) + end) + + defp post(level, message, opts) do + json = + message + |> put_in([:emoji_icon], slack_icon(level)) + |> put_in([:attachments, Access.all(), :color], slack_color(level)) + |> Jason.encode!() + |> :erlang.binary_to_list() + + url = Keyword.fetch!(opts, :url) + + :httpc.request(:post, {to_charlist(url), [], ~c"application/json", json}, [], []) + end + + defp slack_icon(:debug), do: ":speaker:" + defp slack_icon(:info), do: ":information_source:" + defp slack_icon(:warn), do: ":warning:" + defp slack_icon(:warning), do: slack_icon(:warn) + defp slack_icon(:error), do: ":exclamation:" + + defp slack_icon(level) when is_binary(level), + do: level |> String.to_existing_atom() |> slack_icon() + + defp slack_icon(_), do: slack_icon(:info) + + defp slack_color(:debug), do: "#AAAAAA" + defp slack_color(:info), do: "good" + defp slack_color(:warn), do: "#FF9900" + defp slack_color(:warning), do: slack_color(:warn) + defp slack_color(:error), do: "danger" +end diff --git a/lib/telemetria/throttler.ex b/lib/telemetria/throttler.ex index ce8616c..1130766 100644 --- a/lib/telemetria/throttler.ex +++ b/lib/telemetria/throttler.ex @@ -9,7 +9,7 @@ defmodule Telemetria.Throttler do def execute(group \\ nil, event), do: GenServer.cast(name(), {:event, group || :default, event}) - defp name do + def name do [Telemetria.otp_app(), :telemetria, :throttler] |> Enum.map(&Atom.to_string/1) |> Enum.map(&Macro.camelize/1) @@ -67,13 +67,19 @@ defmodule Telemetria.Throttler do Logger.warning("Wrong config for group: #{group}, skipping") end - defp do_execute(group, {event, measurements, metadata, reshaper}) do + defp do_execute(group, {event, measurements, metadata, reshaper, messenger}) do {context, updates} = metadata |> Map.put(:telemetria_group, group) |> Map.put(:measurements, measurements) |> Map.pop(:context, %{}) + with {impl, opts} when is_atom(impl) and is_list(opts) <- messenger do + updates + |> Map.put(:event, event) + |> Telemetria.Messenger.post(impl, opts) + end + updates = if is_function(reshaper, 1), do: reshaper.(updates), else: updates updates = diff --git a/mix.exs b/mix.exs index 6e66b1e..6e4a367 100644 --- a/mix.exs +++ b/mix.exs @@ -31,7 +31,7 @@ defmodule Telemetria.MixProject do # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger], + extra_applications: [:logger, :inets, :ssl], mod: {Telemetria.Application, []}, start_phases: [{:telemetry_setup, []}], registered: [Telemetria, Telemetria.Application] @@ -48,6 +48,7 @@ defmodule Telemetria.MixProject do {:jason, "~> 1.0"}, {:nimble_options, "~> 1.0"}, # dev / test + {:mox, "~> 1.0", only: [:dev, :test, :ci]}, {:dialyxir, "~> 1.0", only: [:dev, :test, :ci], runtime: false}, {:credo, "~> 1.0", only: [:dev, :ci], runtime: false}, {:ex_doc, "~> 0.11", only: :dev, runtime: false} diff --git a/mix.lock b/mix.lock index 6662d0c..08a99b9 100644 --- a/mix.lock +++ b/mix.lock @@ -12,7 +12,9 @@ "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, diff --git a/test/messenger_test.exs b/test/messenger_test.exs new file mode 100644 index 0000000..833eb23 --- /dev/null +++ b/test/messenger_test.exs @@ -0,0 +1,31 @@ +defmodule Telemetria.Messenger.Test do + use ExUnit.Case + import Mox + + alias Telemetria.Messenger.Slack + alias Test.Telemetria.Example + + setup_all do + Application.put_env(:logger, :console, [], persistent: true) + Application.put_env(:telemetria, :smart_log, false) + end + + setup :verify_on_exit! + + @tag capture_log: true + test "when specified, the messenger gets called" do + Telemetria.Messenger.Mox + |> allow(self(), Telemetria.Throttler.name()) + |> expect(:format, 1, fn map, opts -> Slack.format(map, opts) end) + |> expect(:warning, 1, fn map, _opts -> + assert %{ + description: "test.telemetria.example.third", + fallback: + "test.telemetria.example.third\n&Test.Telemetria.Example.third/1\n/home/am/Proyectos/Elixir/telemetria/test/support/telemetria_tester.ex:37" + } = map + end) + + Example.third(42) + Process.sleep(100) + end +end diff --git a/test/support/telemetria_tester.ex b/test/support/telemetria_tester.ex index 93f1890..215be0b 100644 --- a/test/support/telemetria_tester.ex +++ b/test/support/telemetria_tester.ex @@ -33,6 +33,12 @@ defmodule Test.Telemetria.Example do t(&(&1 / 2), suffix: :foo).(a) end + @telemetria level: :warning, messenger: :mox, locals: [:result] + def third(a) do + result = a / 3 + result + end + @telemetria true def annotated_1(foo), do: annotated_2(foo) @telemetria if: Application.compile_env(:telemetria, :annotated_1?, true) diff --git a/test/test_helper.exs b/test/test_helper.exs index f246f81..714f133 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,4 @@ ExUnit.start() +Mox.defmock(Telemetria.Messenger.Mox, for: Telemetria.Messenger) Telemetria.Instrumenter.setup()