lib/sentry/check_in.ex

defmodule Sentry.CheckIn do
  @moduledoc """
  This module represents the struct for a "check-in".

  Check-ins are used to report the status of a monitor to Sentry. This is used
  to track the health and progress of **cron jobs**. This module is somewhat
  low level, and mostly useful when you want to report the status of a cron
  but you are not using any common library to manage your cron jobs.

  > #### Using `capture_check_in/1` {: .tip}
  >
  > Instead of using this module directly, you'll probably want to use
  > `Sentry.capture_check_in/1` to manually report the status of your cron jobs.

  See <https://develop.sentry.dev/sdk/check-ins/>. This struct is available
  since v10.2.0.
  """
  @moduledoc since: "10.2.0"

  alias Sentry.{Config, Interfaces, UUID}

  @typedoc """
  The possible status of the check-in.
  """
  @type status() :: :in_progress | :ok | :error

  @typedoc """
  The possible values for the `:schedule` option under `:monitor_config`.

  If the `:type` is `:crontab`, then the `:value` must be a string representing
  a crontab expression. If the `:type` is `:interval`, then the `:value` must be
  a number representing the interval and the `:unit` must be present and be one of `:year`,
  `:month`, `:week`, `:day`, `:hour`, or `:minute`.
  """
  @type monitor_config_schedule() ::
          %{type: :crontab, value: String.t()}
          | %{
              type: :interval,
              value: number(),
              unit: :year | :month | :week | :day | :hour | :minute
            }

  @typedoc """
  The type for the check-in struct.
  """
  @type t() :: %__MODULE__{
          check_in_id: String.t(),
          monitor_slug: String.t(),
          status: status(),
          duration: float() | nil,
          release: String.t() | nil,
          environment: String.t() | nil,
          monitor_config:
            nil
            | %{
                required(:schedule) => monitor_config_schedule(),
                optional(:checkin_margin) => number(),
                optional(:max_runtime) => number(),
                optional(:failure_issue_threshold) => number(),
                optional(:recovery_threshold) => number(),
                optional(:timezone) => String.t()
              },
          contexts: Interfaces.context()
        }

  @enforce_keys [
    :check_in_id,
    :monitor_slug,
    :status
  ]
  defstruct @enforce_keys ++
              [
                :duration,
                :release,
                :environment,
                :monitor_config,
                :contexts
              ]

  number_schema_opts = [type: {:or, [:integer, :float]}, type_doc: "`t:number/0`"]

  crontab_schedule_opts_schema = [
    type: [type: {:in, [:crontab]}, required: true],
    value: [type: :string, required: true]
  ]

  interval_schedule_opts_schema = [
    type: [type: {:in, [:interval]}, required: true],
    value: number_schema_opts,
    unit: [type: {:in, [:year, :month, :week, :day, :hour, :minute]}, required: true]
  ]

  create_check_in_opts_schema = [
    check_in_id: [
      type: :string
    ],
    status: [
      type: {:in, [:in_progress, :ok, :error]},
      required: true,
      type_doc: "`t:status/0`"
    ],
    monitor_slug: [
      type: :string,
      required: true
    ],
    duration: number_schema_opts,
    contexts: [
      type: :map,
      default: %{},
      doc: """
      The contexts to attach to the check-in. This is a map of arbitrary data,
      but right now Sentry supports the `trace_id` key under the
      [trace context](https://develop.sentry.dev/sdk/event-payloads/contexts/#trace-context)
      to connect the check-in with related errors.
      """
    ],
    monitor_config: [
      doc: "If you pass this optional option, you **must** pass the nested `:schedule` option.",
      type: :keyword_list,
      keys: [
        checkin_margin: number_schema_opts,
        max_runtime: number_schema_opts,
        failure_issue_threshold: number_schema_opts,
        recovery_threshold: number_schema_opts,
        timezone: [type: :string],
        schedule: [
          type:
            {:or,
             [
               {:keyword_list, crontab_schedule_opts_schema},
               {:keyword_list, interval_schedule_opts_schema}
             ]},
          type_doc: "`t:monitor_config_schedule/0`"
        ]
      ]
    ]
  ]

  @create_check_in_opts_schema NimbleOptions.new!(create_check_in_opts_schema)

  @doc """
  Creates a new check-in struct with the given options.

  ## Options

  The options you can pass match a subset of the fields of the `t:t/0` struct.
  You can pass:

  #{NimbleOptions.docs(@create_check_in_opts_schema)}

  ## Examples

      iex> check_in = CheckIn.new(status: :ok, monitor_slug: "my-slug")
      iex> check_in.status
      :ok
      iex> check_in.monitor_slug
      "my-slug"

  """
  @spec new(keyword()) :: t()
  def new(opts) when is_list(opts) do
    opts = NimbleOptions.validate!(opts, @create_check_in_opts_schema)

    monitor_config =
      case Keyword.fetch(opts, :monitor_config) do
        {:ok, monitor_config} ->
          monitor_config
          |> Map.new()
          |> Map.update!(:schedule, &Map.new/1)

        :error ->
          nil
      end

    %__MODULE__{
      check_in_id: Keyword.get_lazy(opts, :check_in_id, &UUID.uuid4_hex/0),
      status: Keyword.fetch!(opts, :status),
      monitor_slug: Keyword.fetch!(opts, :monitor_slug),
      duration: Keyword.get(opts, :duration),
      release: Config.release(),
      environment: Config.environment_name(),
      monitor_config: monitor_config,
      contexts: Keyword.fetch!(opts, :contexts)
    }
  end

  # Used to then encode the returned map to JSON.
  @doc false
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = check_in) do
    Map.from_struct(check_in)
  end
end