defmodule Telemetria do
@moduledoc """
`Telemetría` is the opinionated wrapper for [`:telemetry`](https://hexdocs.pm/telemetry)
(started with `v0.19.0` it became agnostic to the actual telemetry backend and supports
`OpenTelemetry` out of the box, allowing for more custom implementations of the said backend.)
It provides handy macros to attach telemetry events to any function, private function,
anonymous functions (on per-clause basis) and just random set of expressions.
`Telemetría` exports three macros:
- `deft/2` which is wrapping `Kernel.def/2`
- `defpt/2` which is wrapping `Kernel.defp/2`
- `t/2` which is wrapping the expression passed as the first parameter
and adds the options passed as a keyword to the second parameter to the
context of the respective telemetry event
> ### Module attribute over macros {: .tip}
>
> Unless inevitably needed, one should prefer module attributes over explicit macros
> (see the section “Using Module Attribute” below.)
>
> Module attributes have a way richer customization abilities, including but
> not limited to conditional wrapping, slack messaging etc. See options
> which are accepted by the module attribute below.
`Telemetría` allows compile-time telemetry events definition and provides
a compiler that is responsible for incremental builds and updates of the list of
events telemetry is aware about.
> ### Compile-time config {: .warning}
>
> `Telemetría` uses a compiler to wrap annotated functions with a telemetry calls.
> That means, that all the configuration must be placed into compile-time config files.
## Using Module Attribute
Besides the functions listed above, one might attach `Telemetría` to the function
by annotating it with `@telemetria` module attribute.
There are several options to pass to this attribute:
- **`true`** — attach the `telemetry` to the function
- **`if: boolean()`** — compile-time condition
- **`if: (result -> boolean())`** — runtime condition
- **`level: Logger,level()`** — specify a min logger level to attach telemetry
- **`group: atom()`** — the configured group to manage event throttling,
see `:throttle` setting in `Telemetria.Options`
- **`locals: [atom()]`** — the list of names of local variables to be exported
to the telemetry call
- **`transform: [{:args, (list() -> list())}, {:result, (any() -> any())}]`** —
the functions to be called on the incoming attributes and/or result to reshape them
- **`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
The following code would emit the telemetry event for the function `weather`,
returning `result` in Celcius _and_ injecting `farenheit` value under `locals`
```elixir
defmodule Forecast do
use Telemetria
@telemetria level: :info, group: :weather_reports, locals: [:farenheit]
def weather(city) do
fahrenheit = ExternalService.retrieve(city)
Converter.fahrenheit_to_celcius(fahrenheit)
end
end
```
## Advantages
`Telemetría` takes care about managing events in the target application,
makes it a single-letter change to turn a function into a function wrapped
with telemetry call, measuring the execution time out of the box.
It also allows to easily convert expressions to be be telemetry-aware.
Besides that, `telemetry: false` flag allows to purge the calls in compile-time
resulting in zero overhead (useful for benchmark, or like.)
### Example
You need to include the compiler in `mix.exs`:
```elixir
defmodule MyApp.MixProject do
def project do
[
# ...
compilers: [:telemetria | Mix.compilers()],
# ...
]
end
# ...
end
```
## Enabling `Telemetría`
To enable `telemetría` for the project, you should add `:telemetria` compiler to the list
of `Mix.compilers/0` as shown below (`mix.exs`).
```elixir
def project do
[
...
compilers: [:telemetria | Mix.compilers()],
...
]
end
```
Additional steps are described below for the different use-cases.
### Plain Macros
In the modules you want to add telemetry macros to, you should `require Telemetria` (or,
preferably, `import Telemetria` to make it available without FQN.) Once imported,
the macros are available and tracked by the compiler.
```elixir
defmodule MyMod do
import Telemetria
defpt pi, do: 3.14
deft answer, do: 42 - pi()
def inner do
short_result = t(42 * 42)
result =
t do
# long calculations
:ok
end
end
end
```
### Module Attribute
Module attributes are processed by the compilation hooks. To enable `@telemetria`
module attributes, one should `use Telemetria`. Below is the example that would send
two telemetry events to the configured `Telemetria.Backend`.
```elixir
defmodule Otel do
@moduledoc "`Telemetria` with :opentelemetry` example"
use Telemetria
@telemetria level: :info, group: :weather_reports, locals: [:celsius], messenger: :slack
def f_to_c(fahrenheit) do
celsius = do_f_to_c(fahrenheit)
round(celsius)
end
@telemetria level: :info, group: :weather_reports
defp do_f_to_c(fahrenheit), do: (fahrenheit - 32) * 5 / 9
end
```
### Typical Config
`Telemetría` requires an application-wide config to operate properly. Yes, I know
having a config in a library is discouraged by the core team. Unfortunately, for the
compiler to work properly, the static compile-time config is still required.
After all, even if running many OTP applications on the same node, one would barely
want to have a different telemetry config for them.
```elixir
import Config
config :telemetria,
purge_level: :debug,
level: :info,
events: [
[:tm, :f_to_c]
],
throttle: %{some_group: {1_000, :last}}
# create a slack app and put URL here
# messenger_channels: %{slack: {:slack, url: ""}}
```
## Use in releases
`:telemetria` compiler keeps track of the events in the compiler manifest file
to support incremental builds. Also it spits out `config/.telemetria.config.json`
config for convenience. It might be used in in the release configuration as shown below.
```elixir
releases: [
configured: [
# ...,
config_providers: [{Telemetria.ConfigProvider, "/etc/telemetria.json"}]
]
]
```
## Options
#{NimbleOptions.docs(Telemetria.Options.schema())}
"""
alias Telemetria.{Backend, Mix.Events}
@type event_name :: [atom(), ...] | String.t()
@type event_measurements :: map()
@type event_metadata :: map()
@type event_value :: number()
@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
initial_ast =
case Keyword.get(opts, :action, :none) do
:require -> quote(location: :keep, generated: true, do: require(Telemetria))
:import -> quote(location: :keep, generated: true, do: import(Telemetria))
:none -> :ok
unknown -> IO.puts("Ignored unknown value for :action option: " <> inspect(unknown))
end
quote location: :keep, generated: true do
unquote(initial_ast)
Module.register_attribute(__MODULE__, :telemetria, accumulate: false)
Module.register_attribute(__MODULE__, :telemetria_hooks, accumulate: true)
@on_definition Telemetria.Hooks
@before_compile Telemetria.Hooks
end
end
@doc "Declares a function with a telemetry attached, measuring execution time"
defmacro deft(call, expr) do
expr = telemetry_wrap(expr, call, __CALLER__)
quote location: :keep, generated: true do
Kernel.def(unquote(call), unquote(expr))
end
end
@doc "Declares a private function with a telemetry attached, measuring execution time"
defmacro defpt(call, expr) do
expr = telemetry_wrap(expr, call, __CALLER__)
quote location: :keep, generated: true do
Kernel.defp(unquote(call), unquote(expr))
end
end
@doc "Attaches telemetry to anonymous function (per clause,) or to expression(s)"
defmacro t(ast, opts \\ [])
defmacro t({:fn, meta, clauses}, opts) do
clauses =
for {:->, meta, [args, clause]} <- clauses do
{:->, meta,
[
args,
do_t(clause, Keyword.merge([arguments: extract_guards(args)], opts), __CALLER__)
]}
end
{:fn, meta, clauses}
end
defmacro t(ast, opts), do: do_t(ast, opts, __CALLER__)
@compile {:inline, enabled?: 0, enabled?: 1}
@spec enabled?(opts :: keyword()) :: boolean()
defp enabled?(opts \\ []),
do: Keyword.get(opts, :enabled, Application.get_env(:telemetria, :enabled, true))
@compile {:inline, do_t: 3}
@spec do_t(ast, keyword(), Macro.Env.t()) :: ast
when ast: {atom(), keyword(), tuple() | list()}
defp do_t(ast, opts, caller) do
if enabled?(opts) do
{suffix, opts} = Keyword.pop(opts, :suffix)
ast
|> telemetry_wrap(List.wrap(suffix), caller, opts)
|> Keyword.get(:do, [])
else
ast
end
end
@doc false
@spec otp_app :: atom()
def otp_app do
Application.get_env(
:telemetria,
:otp_app,
case :application.get_application(self()) do
{:ok, otp_app} -> otp_app
_ -> :unknown
end
)
end
@doc false
@spec noop(any()) :: any()
def noop(arg), do: arg
@doc false
@spec yes(any()) :: true
def yes(_arg), do: true
@spec telemetry_prefix(
Macro.Env.t(),
{atom(), keyword(), tuple()} | nil | maybe_improper_list()
) :: [atom()]
def telemetry_prefix(%Macro.Env{module: mod, function: fun}, call) do
suffix =
case fun do
{f, _arity} -> [f]
_ -> []
end ++
case call do
[_ | _] = suffices -> suffices
{f, _, _} when is_atom(f) -> [f]
_ -> []
end
prefix =
case mod do
nil ->
[:module_scope]
mod when is_atom(mod) ->
mod |> Module.split() |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom()))
end
Enum.dedup(prefix ++ suffix)
end
@spec telemetry_wrap(ast, nil | ast | maybe_improper_list(), Macro.Env.t(), [
Telemetria.Hooks.option()
]) :: ast
when ast: keyword() | {atom(), keyword(), any()}
@doc false
def telemetry_wrap(expr, call, caller, context \\ [])
def telemetry_wrap(expr, {:when, _meta, [call, _guards]}, %Macro.Env{} = caller, context) do
telemetry_wrap(expr, call, caller, context)
end
def telemetry_wrap(expr, call, %Macro.Env{} = caller, context) do
find_name = fn
{{:_, _, _}, _} -> nil
{{_, _, na} = n, _} when na in [nil, []] -> n
{{:=, _, [{_, _, na} = n, _]}, _} when na in [nil, []] -> n
{{:=, _, [_, {_, _, na} = n]}, _} when na in [nil, []] -> n
{any, idx} -> {:=, [], [{:"arg_#{idx}", [], Elixir}, any]}
end
args =
case call do
{fun, meta, args} when is_atom(fun) and is_list(meta) and is_list(args) -> args
_ -> []
end
|> Enum.with_index()
|> Enum.map(find_name)
|> Enum.reject(&is_nil/1)
|> Enum.map(fn
{:=, _, [{name, _, _}, var]} -> {name, var}
{name, _, _} = var -> {name, var}
end)
if enabled?() do
{block, expr} =
if Keyword.keyword?(expr) do
Keyword.pop(expr, :do, [])
else
{expr, []}
end
event = telemetry_prefix(caller, call)
report(event, caller)
unless is_nil(caller.module),
do: Module.put_attribute(caller.module, :doc, {caller.line, telemetry: true})
caller = caller |> Map.take(~w|module function file line|a) |> Macro.escape()
fix_fun = fn
nil ->
&Telemetria.noop/1
{mod, fun} ->
Function.capture(mod, fun, 1)
f when is_function(f, 1) ->
f
weird ->
raise Telemetria.Error,
"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 =
context |> get_in([:options, :transform, :args]) |> fix_fun.() |> Macro.escape()
result_transform =
context |> get_in([:options, :transform, :result]) |> fix_fun.() |> Macro.escape()
locals =
context |> get_in([:options, :locals]) |> Kernel.||([])
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)
conditional = Macro.escape(context[:conditional] || (&Telemetria.yes/1))
block =
quote location: :keep, generated: true do
now = [
system: System.system_time(),
monotonic: System.monotonic_time(:nanosecond),
unique_integer: :erlang.unique_integer([:monotonic]),
utc: DateTime.utc_now()
]
block_ctx = Backend.entry(unquote(event))
result = unquote(block)
if unquote(conditional).(result) do
benchmark_ns = System.monotonic_time(:nanosecond) - now[:monotonic]
benchmark = div(benchmark_ns, 1_000)
attributes = %{
env: unquote(caller),
locals: Keyword.take(binding(), unquote(locals)),
result: unquote(result_transform).(result),
args: unquote(args_transform).(unquote(args)),
context: unquote(context)
}
Backend.update(unquote(event), %{timestamp: now[:utc]})
Telemetria.Throttler.execute(
unquote(group),
{unquote(event),
%{system_time: now, consumed: benchmark, consumed_ns: benchmark_ns}, attributes,
unquote(reshape), unquote(messenger), block_ctx}
)
Backend.exit(block_ctx)
end
result
end
Keyword.put(expr, :do, block)
else
expr
end
end
@warn_missing_compiler Application.compile_env(:telemetria, :warn_missing_compiler, false)
defp report(event, caller) do
if is_nil(GenServer.whereis(Events)) do
if @warn_missing_compiler do
Mix.shell().warning([
"Added event: #{inspect(event)} at #{caller.file}:#{caller.line}, ",
"but `Telemetria` config won’t be updated. `",
"Add `:telemetria` compiler to `compilers:` in your `mix.exs`!"
])
end
else
Events.put(:event, {caller.module, event})
end
end
defp variablize({:_, _, _}), do: {:_, :skipped}
defp variablize({:{}, _, elems}), do: {:tuple, Enum.map(elems, &variablize/1)}
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
list
|> Enum.map(&extract_guards/1)
|> Enum.map(fn
{:_, _, _} = underscore -> variablize(underscore)
{{op, _, _} = term, _guards} when op in [:{}, :%{}] -> variablize(term)
{{_, _, _} = val, _guards} -> variablize(val)
{_, _, _} = val -> variablize(val)
other -> {:unknown, inspect(other)}
end)
end
defp extract_guards({:when, _, [l, r]}), do: {l, extract_or_guards(r)}
defp extract_guards(other), do: {other, []}
defp extract_or_guards({:when, _, [l, r]}), do: [l | extract_or_guards(r)]
defp extract_or_guards(other), do: [other]
end