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"}
]
end
Docs 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)) # => true
Sonda.recorded_once?(pid, &match?({:a_signal, _, _}, &1)) # => true
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}
# ]
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) # => true
See 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) # => false
When 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
end
When 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:
sink
the data structure implementingSonda.Sink
signal
an atomtimestamp
aNaiveDateTime
data
any 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 DateTime
Notice 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:
server
is the identifier for theAgent
, usually aPID
signal
is an atom for the messagedata
is any type of data that is attached to the message
This function is used to call record
on the sink stored inside this agent.
server
is the identifier for theAgent
, usually aPID
sink_block
is 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.