Sonda is a telemetry library for Elixir, providing configurable sinks for recording signals.
By default, Sonda is configured to record signals in memory and can be inspected through the provided utility functions.
{:ok, pid} = Sonda.start_link()
Sonda.record(pid, :a_signal, %{"some" -> "data"})
Sonda.recorded_once?(pid, &match?({:a_signal, _, _}, &1)) # => true
Sonda.record(pid, :another_signal, 123)
Sonda.record(pid, :signal_only)
Sonda.records(pid)
# [
# {:a_signal, ~N[2020-04-22 23:07:05.905776], %{"some" -> "data"}},
# {:another_signal, ~N[2020-04-22 23:07:06.905776], 123}},
# {:signal_only, ~N[2020-04-22 23:07:07.905776], nil}
# ]The package can be installed by adding sonda to your list of dependencies in
mix.exs:
def deps do
[
{:sonda, "~> 0.1.0"}
]
endDocs can be found at https://hexdocs.pm/sonda.
The default configuration of Sonda provides all the utility functions for recording signals in memory and then inspecting the output:
{:ok, pid} = Sonda.start_link()Sonda.record(pid, :a_signal, %{"some" -> "data"})
Sonda.record(pid, :another_signal, 123)
Sonda.record(pid, :signal_only)Sonda.recorded?(pid, &match?({:a_signal, _, _}, &1)) # => trueSonda.recorded_once?(pid, &match?({:a_signal, _, _}, &1)) # => trueSonda.records(pid)
# [
# {:a_signal, ~N[2020-04-22 23:07:05.905776], %{"some" -> "data"}},
# {:another_signal, ~N[2020-04-22 23:07:06.905776], 123}},
# {:signal_only, ~N[2020-04-22 23:07:07.905776], nil}
# ]Sonda.records(pid, fn {_, _, data} -> data != 123 end)
# [
# {:a_signal, ~N[2020-04-22 23:07:05.905776], %{"some" -> "data"}},
# {:signal_only, ~N[2020-04-22 23:07:07.905776], nil}
# ]Gets the first recorded message matching the provided function, only if there is only one. An error is returned in all the other cases
Sonda.one_record(pid, fn {_, _, data} -> data == 123 end)
# {:ok, {:another_signal, ~N[2020-04-22 23:07:06.905776], 123}}}Gets the first recorded message matching the provided function, only if there is only one. An error is returned in all the other cases
Sonda.one_record(pid, fn {_, _, data} -> data == 123 end)
# {:ok, {:another_signal, ~N[2020-04-22 23:07:06.905776], 123}}}Sonda.record_signal?(pid, :a_signal) # => trueSee the Filtering Signals section for more information
about why a signal might be recorded or not. This output of this function
is false for any signal that's not accepted.
It's possible to ignore some signals from being recorded. It's sufficient to start Sonda with the following configuration:
{:ok, pid} = Sonda.start_link(signals: [:a_signal, :another_signal])The output of the function Sonda.record_signal?/2 is true only for the
signals :a_signal and :another_signal, like in the following example:
{:ok, pid} = Sonda.start_link(signals: [:a_signal, :another_signal])
Sonda.record_signal?(pid, :a_signal) # => true
Sonda.record_signal?(pid, :another_signal) # => true
Sonda.record_signal?(pid, :not_recorded) # => falseWhen the input of the functions record/2 and record/3 is a signal for
which the output of Sonda.record_signal?/2 is false,
no operation is performed. The following example shows that :not_recorded
signal is ignored:
{:ok, pid} = Sonda.start_link(signals: [:a_signal, :another_signal])
Sonda.record(pid, :a_signal, %{"some" -> "data"})
Sonda.record(pid, :not_recorded)
Sonda.record(pid, :another_signal, 123)
Sonda.records(pid)
# [
# {:a_signal, ~N[2020-04-22 23:07:05.905776], %{"some" -> "data"}},
# {:another_signal, ~N[2020-04-22 23:07:06.905776], 123}}
# ]Notice that the default Sonda configuration when no parameters are passed to
start_link is equivalent to passing the :any option to signals, like
in the following example:
{:ok, pid} = Sonda.start_link(signals: :any)
# Equivalent to
{:ok, pid} = Sonda.start_link()One of the most common purpose of Sonda is to inspect the usage of closures or the behavior of GenServers. The following example shows the closure being invoked twice by inspecting the signals recorded through Sonda:
defmodule SomeTest do
use ExUnit.Case
test "Enum.map/2 is invoked once per list element" do
sonda = start_supervised!(Sonda)
elements = [1, 2]
elements = Enum.map(elements, fn element ->
Sonda.record(sonda, :map, element)
element * 2
end)
records = Sonda.records(sonda)
assert elements == [2, 4]
assert length(records) == 2
end
endWhen Sonda is started using Sonda.start_link/*, a
Sonda.Agent is started behind
the scenes, holding the state of a sink.
By default, a Sonda.Sink.Proxy is used, which is a proxy
to multiple sinks.
The default configuration provided to Sonda.Sink.Proxy holds only one
sink: Sonda.Sink.Memory,
which provides the functionality of appending and inspecting messages.
The Sonda module delegates all the work to a special Sonda.Agent called
Sonda.Agent.Default.
Sonda utility functions can be used as long as
the configuration of the Sonda.Agent uses a Sonda.Sink.Proxy with the
first sink being a Sonda.Sink.Memory.
This is not a limitation, feel free to create your own utility module and use
that rather than the Sonda module.
Sonda doesn't make assumptions on what you want to do with the signals
recorded. It's sufficient to implement the
Sonda.Sink protocol to record
signals in the data structure of your choice.
The sink data structure is stored in Sonda.Agent, so the data is kept as
long as the process is running, without the need of having to implement a
GenServer.
The sink protocol requires the following function to be implemented:
sinkthe data structure implementingSonda.Sinksignalan atomtimestampaNaiveDateTimedataany type of data
This function should handle the message in any way you see fit. Sonda uses Sonda.Sink.Memory behind the scenes to provide the functionality of, appending messages and inspecting them.
Sonda provides out of the box support for using multiple sinks. When starting Sonda, the option to provide an alternative list of sinks can be provided like in the following example:
{:ok, pid} = Sonda.start_link(sinks: [a_sink, another_sink])Notice that if the first sink is not a Sonda.Sink.Memory, all inspection
functions on the Sonda module will not operate correctly.
The following configuration preserves the default functionality of Sonda as well as expanding it with other sinks you see fit (for example, recording messages to a database):
memory_sink = Sonda.Sink.Memory.configure(signals: :any)
other_sink = %OtherSink{} # This is the sink implemented by you
{:ok, pid} = Sonda.start_link(sinks: [memory_sink, other_sink])By default, Sonda uses NaiveDateTime.utc_now/0 to determine the timestamp
of a recorded message. This behavior can be altered by providing the option
:clock_now when starting Sonda like in the following example:
{:ok, pid} = Sonda.start_link(clock_now: fn -> DateTime.utc_now() end)
# Timestamps will now be of type DateTimeNotice that Sonda is just passing down this option to Sonda.Agent, which
is the module actually supporting the :clock_now option.
By using a custom Sonda.Agent and a custom sink implementing Sonda.Sink
protocol, it's possible to greatly alter the behavior of Sonda, giving
access to the lowest levels of machinery available in the library.
Sonda.Agent is a GenServer that holds a Sonda.Agent.clock function to
provide timestamps and a Sonda.Sink data structure.
Only two functions are provided:
serveris the identifier for theAgent, usually aPIDsignalis an atom for the messagedatais any type of data that is attached to the message
This function is used to call record on the sink stored inside this agent.
serveris the identifier for theAgent, usually aPIDsink_blockis a function which accepts one argument: the sink stored in the agent. It can return any type of data, which in turn will be returned byget_sink/2
This function is used to access the sink, to perform any reading operation.
This library is heavily influenced by telemetry, the Eventide library for ruby instrumentation.