Packages

Behaviour and use macro for Raven monitor integrations

Current section

Files

Jump to
raven_observer_sdk lib code_name_raven monitor.ex
Raw

lib/code_name_raven/monitor.ex

defmodule CodeNameRaven.Monitor do
@moduledoc """
The behaviour contract for a Raven monitor module.
A monitor module is a self-contained plugin. It decides how to collect data,
what protocols to use, and how to present results. Raven handles supervision,
scheduling, the health signal, and the databus. The monitor handles everything
inside those boundaries.
This behaviour is collection-only — no display, no Phoenix, no rendering.
`CodeNameRaven.Monitor` never references Phoenix, even optionally, and never
will: a module that `use`s only this has zero Phoenix dependency and can run
on a headless collector node. For the display half, `use CodeNameRaven.Display`
(published separately, in `raven_sdk_display`) alongside this behaviour.
## Implementing a monitor
defmodule MyMonitors.HttpCheck do
use CodeNameRaven.Monitor
use CodeNameRaven.Display, category: :web, size_hint: :small
@impl CodeNameRaven.Monitor
def collect(%{url: url}, state) do
case Req.get(url) do
{:ok, %{status: status}} -> {:ok, %{status: status}, state}
{:error, reason} -> {:error, inspect(reason), state}
end
end
@impl CodeNameRaven.Monitor
def healthy?(%{status: status}) when status in 200..299, do: :up
def healthy?(_), do: :down
end
`collect/2` and `healthy?/1` are the only required callbacks. Everything else
has a working default. Omit `use CodeNameRaven.Display` entirely for a
collection-only integration — see `CodeNameRaven.Display`'s own moduledoc for
the display-only and two-module patterns.
## Internal state
The second argument to `collect/2` is the monitor's own internal state —
opaque to Raven. Use it to carry information between collection cycles:
previous readings for delta computation, cached credentials, connection
handles. This state is ephemeral: if the process crashes and is restarted,
`init/1` is called again and state starts fresh.
## The health signal
After every collection, Raven calls `healthy?/1` with the result and
publishes the signal to the databus. This is the only output Raven requires.
Everything else — publishing metrics, pushing display data — is the monitor's
choice.
"""
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
@type collect_result :: term()
@type monitor_state :: term()
@type status :: :up | :degraded | :down | :unknown
# ---------------------------------------------------------------------------
# Contract version
#
# The version of the CodeNameRaven.Monitor behaviour itself — bumped only
# when a callback's signature or semantics change, never for an ordinary
# Raven release. See monitor_api_version/0 below.
# ---------------------------------------------------------------------------
@current_api_version 1
@min_supported_api_version 1
# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------
@doc """
Initialises the monitor's internal state.
Called once when the monitor process starts, before the first collection
tick. Use this to establish connections, seed baseline values, or build any
structure the monitor needs to carry between collections.
Returns `{:ok, initial_state}` on success or `{:error, reason}` if the
monitor cannot start (the supervisor will attempt a restart).
The default implementation returns `{:ok, %{}}`.
"""
@callback init(params :: map()) ::
{:ok, monitor_state()} | {:error, String.t()}
@doc """
Collects data from the target.
Called on every scheduled check. Receives the monitor's params (from its
`Config`) and its current internal state. Returns the collection result and
the (possibly updated) state. The result is passed to `healthy?/1` and, for
a paired `CodeNameRaven.Display`, to `render/1` as `:result` in assigns.
Return `{:ok, result, new_state}` on success or `{:error, reason,
new_state}` on failure. On error, Raven records the failure and increments
the failure count; `healthy?/1` is not called.
This callback is **required** — there is no default.
"""
@callback collect(params :: map(), state :: monitor_state()) ::
{:ok, collect_result(), monitor_state()}
| {:error, reason :: String.t(), monitor_state()}
@doc """
Determines the health signal from a successful collection result.
Called after every successful `collect/2`. The return value is published to
the databus as the monitor's health signal.
Return `:up` if the target is healthy, `:down` if it is not, or `:unknown`
if the result is ambiguous and a definitive determination cannot be made.
This callback is **required** — there is no default.
"""
@callback healthy?(result :: collect_result()) :: status()
@doc """
Called by Raven after every completed check cycle — success or failure.
Receives the monitor's config map, the collect result (`nil` on error), and
the resolved health status. Use this hook to persist samples, emit metrics,
or trigger any side effect that should happen on every check.
The return value is ignored. Exceptions are caught by the server and logged
as warnings — a failing `after_check/3` does not affect the monitor's health
signal or state.
The default is a no-op. Override in monitors that need persistence:
@impl true
def after_check(%{id: monitor_id}, result, status, metrics) do
SampleStore.insert(%{monitor_id: monitor_id, status: to_string(status), metrics: metrics, ...})
:ok
end
"""
@callback after_check(
config :: map(),
result :: collect_result() | nil,
status :: status(),
metrics :: map()
) :: :ok
@doc """
Returns a template map of params this monitor expects.
Used by the admin UI to decide whether to show a configuration form. If the
map is empty the monitor starts immediately with no params. If it is
non-empty the UI pre-fills the params form with this template so the operator
only needs to fill in the real values.
The default is `%{}` (no params required). Override this in any monitor that
needs configuration — for example:
def params_template, do: %{url: "https://"}
"""
@callback params_template() :: map()
@doc """
Returns the named numeric metrics produced by a successful collection.
Called by the platform after every successful `collect/2`. The returned map
is published to the DataBus `monitor:metrics` topic — one `MonitorMetric`
message per entry. Subscribers (BoundaryProbe, storage writer, alert engine)
receive each metric independently.
Keys may be atoms or strings; the platform normalises them to strings before
publishing. Values must be numeric (`integer` or `float`). Return `%{}` (the
default) if this monitor produces no standalone metrics.
Example:
@impl true
def metrics(%{latency_ms: latency, status_code: code}) do
%{latency_ms: latency, status_code: code}
end
"""
@callback metrics(result :: collect_result()) :: %{(atom() | String.t()) => number()}
@doc """
Handles an arbitrary message delivered to the monitor's process.
Called by Monitor.Server for any message that is not part of the standard
check cycle (i.e. not the internal `:check` timer). Use this to react to
external events — for example, subscribing to a DataBus topic in `init/1`
and processing the arriving messages here.
Return `{:ok, new_state}` to update the monitor state and wait for the next
scheduled check. Return `{:ok, new_state, :check_now}` to additionally
trigger an immediate check cycle — useful when the incoming message contains
fresh data that should be evaluated right away rather than waiting up to
`interval_ms` for the next tick.
The default ignores all messages and returns the state unchanged.
"""
@callback handle_info(msg :: term(), params :: map(), state :: monitor_state()) ::
{:ok, monitor_state()} | {:ok, monitor_state(), :check_now}
@doc """
Called when the monitor process is shutting down.
Use this to release resources opened in `init/1` — close connections, flush
buffers, etc. The default is a no-op.
"""
@callback terminate(reason :: term(), state :: monitor_state()) :: :ok
@doc """
Returns the canonical URI that identifies this monitor's target.
The platform hashes this string to produce the target id, which is used to
correlate monitors across instances and integrations watching the same asset.
Two monitors that return identical strings will be linked to the same target.
Return `{:ok, uri}` with the canonical URI for the target — preferably the
identifier the technology itself defines (a URL, a connection URI, a cloud
resource path). Return `:none` if this monitor has no meaningful external
target or if a stable URI cannot be determined from the given params.
The platform does not validate or parse the URI. Consistency is the
integration author's responsibility.
The default returns `:none`. Override in integrations that watch a
network-addressable or otherwise canonically identified asset:
@impl true
def target_uri(%{url: url}), do: {:ok, url}
"""
@callback target_uri(params :: map()) :: {:ok, String.t()} | :none
@doc """
Returns the subset of `params` that defines this monitor's identity.
The platform hashes `{module, identity_params(params)}` to produce the
monitor id — the key used to register the running process and to store
its samples and config. Two starts with the same module and the same
`identity_params/1` result reconnect to the same history; anything you
leave out of the returned map can change freely without forking it.
Use this to separate what makes this *a different probe* (a different
host, a different thing being watched) from incidental configuration
(credentials, timeouts, thresholds) that should be tunable without
losing history. `target_uri/1` already makes this same call for the
network-addressable case — if you've implemented it, you likely want
`identity_params/1` to agree with it:
@impl true
def target_uri(%{url: url}), do: {:ok, url}
@impl true
def identity_params(%{url: url}), do: %{url: url}
The default returns `params` unchanged — every field is treated as
identity-relevant, which is safe but means *any* change, including
incidental ones, forks identity. Override when your params mix
identity-essential fields with incidental ones.
"""
@callback identity_params(params :: map()) :: map()
@doc """
Returns the version of the Raven↔integration contract this module was
written against.
This is **not** the integration's own version — it does not change when
you fix a bug or add a param. It changes only when Raven itself changes
something about the `CodeNameRaven.Monitor` behaviour: a callback's
signature, a calling convention, what shape Raven passes you. Those
changes are rare and deliberate; most Raven releases never touch this.
Raven checks this at load time (boot, or an explicit re-upload) against
the range of contract versions the running Raven build supports
(`CodeNameRaven.Monitor.current_api_version/0`). An integration outside
that range is rejected and logged rather than loaded — see
`CodeNameRaven.IntegrationLoader`. Already-running monitor processes are
never affected by this check; it only applies the next time something is
loaded.
The default returns `1`, the contract version every integration was
implicitly written against before this concept existed. Override only if
you are deliberately targeting a specific contract version.
"""
@callback monitor_api_version() :: pos_integer()
@doc """
Returns the NimbleOptions schema for validating monitor params.
Defaults to `[]` (no pre-flight validation, fully backward compatible).
"""
@callback params_schema() :: keyword()
# ---------------------------------------------------------------------------
# __using__ — sets up the behaviour and provides default implementations
# ---------------------------------------------------------------------------
@doc false
defmacro __using__(_opts \\ []) do
quote do
@behaviour CodeNameRaven.Monitor
# --- Defaults for optional callbacks -----------------------------------
@impl CodeNameRaven.Monitor
def init(_params), do: {:ok, %{}}
@impl CodeNameRaven.Monitor
def after_check(_config, _result, _status, _metrics), do: :ok
@impl CodeNameRaven.Monitor
def metrics(_result), do: %{}
@impl CodeNameRaven.Monitor
def params_template, do: %{}
@impl CodeNameRaven.Monitor
def handle_info(_msg, _params, state), do: {:ok, state}
@impl CodeNameRaven.Monitor
def terminate(_reason, _state), do: :ok
@impl CodeNameRaven.Monitor
def target_uri(_params), do: :none
@impl CodeNameRaven.Monitor
def identity_params(params), do: params
@impl CodeNameRaven.Monitor
def monitor_api_version, do: 1
@impl CodeNameRaven.Monitor
def params_schema, do: []
defoverridable init: 1,
after_check: 4,
metrics: 1,
params_template: 0,
handle_info: 3,
terminate: 2,
target_uri: 1,
identity_params: 1,
monitor_api_version: 0,
params_schema: 0
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@doc false
@spec derive_display_name(module()) :: String.t()
def derive_display_name(module) do
module
|> Module.split()
|> List.last()
|> Macro.underscore()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
@doc """
Returns true if the given module implements the Monitor behaviour.
"""
@spec monitor_module?(module()) :: boolean()
def monitor_module?(module) do
Code.ensure_loaded?(module) and
function_exported?(module, :collect, 2) and
function_exported?(module, :healthy?, 1)
end
@doc """
The contract version this running Raven build implements.
"""
@spec current_api_version() :: pos_integer()
def current_api_version, do: @current_api_version
@doc """
The oldest contract version this running Raven build still accepts.
"""
@spec min_supported_api_version() :: pos_integer()
def min_supported_api_version, do: @min_supported_api_version
@doc """
Returns true if `module`'s declared `monitor_api_version/0` falls within
the range this Raven build supports.
Modules that don't export `monitor_api_version/0` (predating this check)
are treated as version 1, matching the default every integration gets via
`use CodeNameRaven.Monitor`.
"""
@spec api_version_supported?(module()) :: boolean()
def api_version_supported?(module) do
version =
if function_exported?(module, :monitor_api_version, 0) do
module.monitor_api_version()
else
1
end
version in @min_supported_api_version..@current_api_version
end
end