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