core/alert/handler/handler.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule AntikytheraCore.Alert.Message do
  use Croma.SubtypeOfTuple, elem_modules: [Antikythera.Time, Croma.String]
end

defmodule AntikytheraCore.Alert.HandlerState do
  alias AntikytheraCore.Alert.Message

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      handler_module: Croma.Atom,
      otp_app_name: Croma.Atom,
      # Newest first
      message_buffer: Croma.TypeGen.list_of(Message),
      busy?: Croma.Boolean
    ]
end

defmodule AntikytheraCore.Alert.Handler do
  @default_fast_interval 60
  @default_delayed_interval 1_800
  @fast_interval_key "fast_interval"
  @delayed_interval_key "delayed_interval"
  @ignore_patterns_key "ignore_patterns"

  @moduledoc """
  Behaviour module for alert handlers.

  Implementations must be prefixed with `AntikytheraCore.Alert.Handler`, e.g. `AntikytheraCore.Alert.Handler.Email`.
  This module also implements `:gen_event` behaviour and installed as handlers for `AntikytheraCore.Alert.Manager` processes.
  Each installed handlers will call callbacks of their corresponding implementation modules (references to those modules are held in handler states).

  A simple buffering/throttling mechanism is built in.

  - Messages received by handlers will be sent out in "fast-then-delayed" pattern:
      - Occasional messages will be flushed from the buffer and sent out in `fast_interval`.
      - Messages arriving too frequently will be buffered for `delayed_interval` until they are sent out.
  - By default, `fast_interval` is #{@default_fast_interval} seconds
    and `delayed_interval` is #{@default_delayed_interval} seconds.
      - They can be customized via core/gear configs.
        Specify #{@fast_interval_key} or #{@delayed_interval_key} for the handler.

  ## Customization and installation of handlers

  Through `validate_config/1` callback, `handler_config` will be validated
  whether it includes sufficient information required for the implementation.
  If the validation passed, a handler, with a reference to the implementation module, will be installed for that OTP application.
  If the validation failed, the handler will not be installed, and will be uninstalled if it is installed already.

  This means, customization and installation are purely done via core/gear config.
  """

  @behaviour :gen_event
  alias Antikythera.GearName
  alias AntikytheraCore.Alert.{Manager, Message, HandlerState, HandlerConfig}

  @doc """
  Send alert(s) for messages in the buffer, using `handler_config`. Summarize messages if needed.
  Must return messages which could not be sent for whatever reason for retry.
  Oldest message comes first in `messages`, same goes for returned messages.

  Note: If the handler is trying to perform alerts which can take some time to finish (e.g. send email),
  consider dispatching them to a temporary process.
  In that case though, results of alerts cannot be received (`[]` should always be returned).
  """
  @callback send_alerts(
              messages :: [Message.t()],
              handler_config :: HandlerConfig.t(),
              otp_app_name :: :antikythera | GearName.t()
            ) :: [Message.t()]

  @doc """
  Validate `handler_config` whether it includes sufficient configurations for the handler.
  Return `true` when it is valid.
  """
  @callback validate_config(handler_config :: HandlerConfig.t()) :: boolean

  @impl true
  def init({otp_app_name, handler}) do
    {:ok,
     %HandlerState{
       handler_module: handler,
       otp_app_name: otp_app_name,
       message_buffer: [],
       busy?: false
     }}
  end

  @impl true
  def handle_event(
        message,
        %HandlerState{
          busy?: busy?,
          message_buffer: buffer,
          otp_app_name: otp_app_name,
          handler_module: handler
        } = handler_state
      ) do
    handler_config = HandlerConfig.get(handler, otp_app_name)
    ignore_patterns = ignore_patterns(handler_config)
    ignore_message? = ignore_message?(message, ignore_patterns)
    new_buffer = if ignore_message?, do: buffer, else: [message | buffer]

    if busy? or ignore_message? do
      {:ok, %HandlerState{handler_state | message_buffer: new_buffer}}
    else
      schedule_handler_timeout(handler, handler_config, %{
        handler_state
        | message_buffer: new_buffer
      })
    end
  end

  @impl true
  def handle_info(
        {:handler_timeout, handler},
        %HandlerState{
          handler_module: handler,
          otp_app_name: otp_app_name,
          message_buffer: buffer0
        } = handler_state
      ) do
    case buffer0 do
      [] ->
        {:ok, %{handler_state | busy?: false}}

      messages ->
        handler_config = HandlerConfig.get(handler, otp_app_name)

        buffer1 =
          messages
          |> Enum.reverse()
          |> handler.send_alerts(handler_config, otp_app_name)
          |> Enum.reverse()

        schedule_handler_timeout(handler, handler_config, %{
          handler_state
          | message_buffer: buffer1
        })
    end
  end

  def handle_info(_msg, handler_state) do
    {:ok, handler_state}
  end

  defunp schedule_handler_timeout(
           handler :: v[atom],
           handler_config :: v[map],
           %HandlerState{busy?: busy?} = handler_state
         ) :: {:ok, HandlerState.t()} do
    if busy? do
      Manager.schedule_handler_timeout(handler, delayed_interval(handler_config))
      {:ok, handler_state}
    else
      Manager.schedule_handler_timeout(handler, fast_interval(handler_config))
      {:ok, %HandlerState{handler_state | busy?: true}}
    end
  end

  defp ignore_message?({_time, body}, ignore_patterns) do
    Enum.map(ignore_patterns, &Regex.compile!/1)
    |> Enum.any?(&Regex.match?(&1, body))
  end

  defp fast_interval(handler_config) do
    case Map.get(handler_config, @fast_interval_key) do
      num when is_integer(num) and num > 0 -> num
      _ -> @default_fast_interval
    end
  end

  defp delayed_interval(handler_config) do
    case Map.get(handler_config, @delayed_interval_key) do
      num when is_integer(num) and num > 0 -> num
      _ -> @default_delayed_interval
    end
  end

  defp ignore_patterns(handler_config) do
    case Map.get(handler_config, @ignore_patterns_key) do
      patterns when is_list(patterns) -> patterns
      _ -> []
    end
  end

  #
  # irrelevant gen_event callbacks
  #
  @impl true
  def handle_call(_msg, state) do
    {:ok, {:error, :unexpected_call}, state}
  end

  @impl true
  def terminate(_reason, _state) do
    :ok
  end

  @impl true
  def code_change(_old, state, _extra) do
    {:ok, state}
  end
end