defmodule Elixometer do
@moduledoc ~S"""
A light wrapper around [exometer](https://github.com/Feuerlabs/exometer).
Elixometer allows you to define metrics and subscribe them automatically
to the default reporter for your environment.
## Configuration
In one of your config files, set up an exometer reporter, and then register
it to elixometer like this:
config(:exometer_core, report: [reporters: [{:exometer_report_tty, []}]])
config(:elixometer, reporter: :exometer_report_tty)
## Metrics
Defining metrics in elixometer is substantially easier than in exometer.
Instead of defining and then updating a metric, just update it. Also, instead
of providing a list of atoms, a metric is named with a period separated
bitstring. Presently, Elixometer supports timers, histograms, gauges,
and counters.
Timings may also be defined by annotating a function with a `@timed`
annotation. This annotation takes a key argument, which tells elixometer
what key to use. You can specify `:auto` and a key will be generated from
the module name and method name.
Updating a metric is similarly easy:
defmodule ParentModule.MetricsTest do
use Elixometer
def counter_test(thingie) do
update_counter("metrics_test.\#{thingie}.count", 1)
end
def timer_test do
timed("metrics_test.timer_test.timings") do
OtherModule.slow_method
end
end
@timed(key: "timed.function")
def function_that_is_timed do
OtherModule.slow_method
end
@timed(key: :auto) # The key will be "parent_module.metrics_test.another_timed_function"
def another_timed_function do
OtherModule.slow_method
end
end
"""
@type metric_name :: String.t() | String.Chars.t()
defmodule App do
@moduledoc false
use Application
def start(_type, _args_) do
Elixometer.Supervisor.start_link()
end
end
defmodule Config do
@moduledoc false
defstruct table_name: nil, counters: Map.new()
end
defmodule Timer do
@moduledoc false
defstruct method_name: nil, key: nil, units: :microsecond, args: nil, guards: nil, body: nil
end
@elixometer_table :elixometer
alias Elixometer.Updater
import Elixometer.Utils
use GenServer
defmacro __using__(_mod) do
quote do
import Elixometer
Module.register_attribute(__MODULE__, :elixometer_timers, accumulate: true)
@before_compile Elixometer
@on_definition Elixometer
end
end
def __on_definition__(env, _kind, name, args, guards, body) do
mod = env.module
timer_info = Module.get_attribute(mod, :timed)
if timer_info do
key =
case timer_info[:key] do
:auto ->
# Convert a fully qualified module to an underscored representation.
# Module.SubModule.SubSubModule will become
# module.sub_module.sub_sub_module
prefix =
mod
|> inspect
|> String.replace(~r/([a-z])([A-Z])/, ~S"\1_\2")
|> String.downcase()
"#{prefix}.#{name}"
other ->
other
end
units = timer_info[:units] || :microsecond
Module.put_attribute(mod, :elixometer_timers, %Timer{
method_name: name,
args: args,
guards: guards,
body: normalize_body(body),
units: units,
key: key
})
Module.delete_attribute(mod, :timed)
end
end
# Elixir 1.5.0-rc changed on_definition/6 to always wrap body in a keyword
# list (e.g. `[do: body]`). For backwards compatibility, this normalization
# function wraps earlier versions' bodies in a keyword list, too.
defp normalize_body(body) do
case Version.compare(System.version(), "1.5.0-rc") do
:lt -> [do: body]
_ when is_nil(body) -> raise "timed function must have a body"
_ -> body
end
end
defp build_timer_body(%Timer{key: key, units: units, body: [do: body]}) do
build_timer(key, units, body)
end
defmacro __before_compile__(env) do
mod = env.module
timers = Module.get_attribute(mod, :elixometer_timers)
timed_methods =
timers
|> Enum.reverse()
|> Enum.map(fn %Timer{} = timer_data ->
Module.make_overridable(
mod,
[{timer_data.method_name, length(timer_data.args)}]
)
body = build_timer_body(timer_data)
if length(timer_data.guards) > 0 do
quote do
def unquote(timer_data.method_name)(unquote_splicing(timer_data.args))
when unquote_splicing(timer_data.guards) do
unquote(body)
end
end
else
quote do
def unquote(timer_data.method_name)(unquote_splicing(timer_data.args)) do
unquote(body)
end
end
end
end)
quote do
(unquote_splicing(timed_methods))
end
end
def init(_args) do
table_name = :ets.new(@elixometer_table, [:set, :named_table, read_concurrency: true])
:timer.send_interval(250, :tick)
{:ok, %Config{table_name: table_name}}
end
def start_link(args) do
GenServer.start_link(__MODULE__, args, name: __MODULE__)
end
@spec get_metric_value(metric_name) :: {:ok, any} | {:error, :not_found}
def get_metric_value(metric_name) do
metric_name
|> to_exometer_key
|> :exometer.get_value()
end
@spec get_metric_value(metric_name, :exometer.datapoint()) :: {:ok, any} | {:error, :not_found}
def get_metric_value(metric_name, data_point) do
metric_val =
metric_name
|> to_exometer_key
|> :exometer.get_value(data_point)
case metric_val do
{:ok, metric} -> {:ok, metric[data_point]}
r = {:error, _reason} -> r
end
end
@spec get_metric_values(metric_name) :: [
{:exometer.name(), :exometer.type(), :exometer.status()}
]
def get_metric_values(metric_name) do
metric_name
|> to_exometer_key
|> get_values
end
@spec get_metric_values(metric_name, :exometer.datapoint()) ::
{:ok, integer} | {:error, :not_found}
def get_metric_values(metric_name, data_point) do
metric_val =
metric_name
|> get_metric_values
|> get_values_total(data_point)
case metric_val do
:not_found -> {:error, :not_found}
total -> {:ok, total}
end
end
defp to_exometer_key(metric_name) when is_list(metric_name), do: metric_name
defp to_exometer_key(metric_name) when is_binary(metric_name) do
String.split(metric_name, ".")
end
defp get_values(key) when is_list(key) do
:exometer.get_values((key -- ["_"]) ++ [:_])
end
defp get_values_total(values, data_point) do
Enum.reduce_while(values, 0, fn {_, attrs}, total ->
case Keyword.get(attrs, data_point) do
nil -> {:halt, :not_found}
value -> {:cont, total + value}
end
end)
end
@doc """
Updates a histogram with a new value. If the metric doesn't exist, a new metric
is created and subscribed to.
"""
@spec update_histogram(String.t(), number, pos_integer, boolean) :: :ok
def update_histogram(name, delta, aggregate_seconds \\ 60, truncate \\ true)
when is_bitstring(name) do
Updater.histogram(name, delta, aggregate_seconds, truncate)
end
@doc """
Updates and alternately creates spiral metric. A spiral metric is a metric maintains a series
of internal slots that "age out" and are replaced by newer values. This is useful for
maintaining QPS stats.
"""
@spec update_spiral(String.t(), number, time_span: pos_integer, slot_period: pos_integer) :: :ok
def update_spiral(name, delta, opts \\ [time_span: :timer.seconds(60), slot_period: 1000]) do
Updater.spiral(name, delta, opts)
end
@doc """
Updates a counter metric. If the metric doesn't exist, the metric is created
and the metric is subscribed to the default reporter.
If the value of the `:reset_seconds` option is greater than zero, the counter will be reset
automatically at the specified interval.
"""
@spec update_counter(String.t(), integer, reset_seconds: nil | integer) :: :ok
def update_counter(name, delta, [reset_seconds: secs] \\ [reset_seconds: nil])
when is_bitstring(name) and (is_nil(secs) or secs >= 1) do
Updater.counter(name, delta, secs)
end
@doc """
Clears a counter with the given name.
"""
@spec clear_counter(metric_name) :: :ok | {:error, any}
def clear_counter(metric_name) when is_bitstring(metric_name) do
clear_counter(format(:counters, metric_name))
end
def clear_counter(metric_name) when is_list(metric_name) do
:exometer.reset(metric_name)
end
@doc """
Updates a gauge metric. If the metric doesn't exist, the metric is created
and the metric is subscribed to the default reporter.
"""
@spec update_gauge(String.t(), number) :: :ok
def update_gauge(name, value) when is_bitstring(name) do
Updater.gauge(name, value)
end
@doc """
Updates a timer metric. If the metric doesn't exist, it will be created and
subscribed to the default reporter.
The time units default to *microseconds*, but you can also pass in any of
the units in `t:System.time_unit/0`, with the exception of `pos_integer`.
This includes `:second`, `:millisecond`, `:microsecond`, and `:nanosecond`.
Note that nanoseconds are provided for convenience, but Erlang does not
actually provide this much granularity.
"""
defmacro timed(name, units \\ :microsecond, do: block) do
build_timer(name, units, block)
end
defp build_timer(name, units, block) do
quote do
{elapsed_us, rv} = :timer.tc(fn -> unquote(block) end)
Updater.timer(unquote(name), unquote(units), elapsed_us)
rv
end
end
def add_counter(metric_name, ttl_millis) do
GenServer.cast(__MODULE__, {:add_counter, metric_name, ttl_millis})
end
def add_counter(metric_name) do
GenServer.cast(__MODULE__, {:add_counter, metric_name, nil})
end
def metric_defined?(name) when is_bitstring(name) do
name |> String.split(".") |> metric_defined?
end
def metric_defined?(name) do
:ets.member(@elixometer_table, {:definitions, name})
end
def metric_subscribed?(name) do
:ets.member(@elixometer_table, {:subscriptions, name})
end
def ensure_subscribed(name) do
if not metric_subscribed?(name) do
GenServer.call(__MODULE__, {:subscribe, name})
end
end
def ensure_metric_defined(name, defn_fn) do
if not metric_defined?(name) do
GenServer.call(__MODULE__, {:define_metric, name, defn_fn})
end
:ok
end
@doc """
Ensures a metric is correctly registered in Elixometer.
This means that Elixometer knows about it and its metrics are
subscribed to an exometer reporter
"""
def ensure_registered(metric_name, register_fn) do
ensure_metric_defined(metric_name, register_fn)
subscribe(metric_name)
rescue
e in ErlangError -> e
end
@doc """
Ensures that a metric is subscribed to an exometer reporter.
"""
def subscribe(metric_name) do
if not metric_subscribed?(metric_name) do
GenServer.call(__MODULE__, {:subscribe, metric_name})
end
end
def handle_call({:subscribe, metric_name}, _caller, state) do
create_subscription(metric_name)
{:reply, :ok, state}
end
def handle_call({:define_metric, metric_name, defn_fn}, _caller, state) do
# we re-check whether the metric is defined here to prevent
# a race condition in ensure_metric_defined
if not metric_defined?(metric_name) do
defn_fn.()
:ets.insert(@elixometer_table, {{:definitions, metric_name}, true})
end
{:reply, :ok, state}
end
def handle_cast({:add_counter, metric_name, ttl_millis}, config) do
new_counters = Map.put(config.counters, metric_name, ttl_millis)
{:noreply, %Config{config | counters: new_counters}}
end
def handle_info(:tick, config) do
Enum.each(
config.counters,
fn {name, millis} ->
{:ok, [ms_since_reset: since_reset]} = :exometer.get_value(name, :ms_since_reset)
if millis && since_reset >= millis do
:exometer.reset(name)
end
end
)
{:noreply, config}
end
defp create_subscription(metric_name) do
# If a metric isn't subscribed to our reporters, create a subscription in our
# ets table and subscribe our metric to exometer's reporters.
if not metric_subscribed?(metric_name) do
cfg = Application.get_all_env(:elixometer)
reporter = cfg[:reporter]
interval = cfg[:update_frequency]
subscribe_options = cfg[:subscribe_options] || []
excluded_datapoints = cfg[:excluded_datapoints] || []
bulk_subscribe? = cfg[:bulk_subscribe] || false
if reporter do
metric_name
|> :exometer.info()
|> get_datapoints()
|> Enum.reject(&Enum.member?(excluded_datapoints, &1))
|> do_subscribe(reporter, metric_name, interval, subscribe_options, bulk_subscribe?)
end
:ets.insert(@elixometer_table, {{:subscriptions, metric_name}, true})
end
end
defp do_subscribe(
data_points,
reporter,
metric_name,
interval,
subscribe_options,
_bulk_subscribe? = true
) do
:exometer_report.subscribe(reporter, metric_name, data_points, interval, subscribe_options)
end
defp do_subscribe(
data_points,
reporter,
metric_name,
interval,
subscribe_options,
_bulk_subscribe? = false
) do
Enum.map(
data_points,
&:exometer_report.subscribe(reporter, metric_name, &1, interval, subscribe_options)
)
end
defp get_datapoints(info) do
case Keyword.fetch(info, :datapoints) do
{:ok, datapoints} ->
datapoints
:error ->
info
|> Keyword.fetch!(:value)
|> Enum.map(fn {k, _v} -> k end)
end
end
end