lib/sentry.ex

defmodule Sentry do
  use Application
  alias Sentry.{Config, Event}
  require Logger

  @moduledoc """
  Provides the basic functionality to submit a `Sentry.Event` to the Sentry Service.

  ## Configuration

  Add the following to your production config

      config :sentry, dsn: "https://public:secret@app.getsentry.com/1",
        included_environments: [:prod],
        environment_name: :prod,
        tags: %{
          env: "production"
        }

  The `environment_name` and `included_environments` work together to determine
  if and when Sentry should record exceptions. The `environment_name` is the
  name of the current environment. In the example above, we have explicitly set
  the environment to `:prod` which works well if you are inside an environment
  specific configuration `config/prod.exs`.

  An alternative is to use `Mix.env` in your general configuration file:

      config :sentry, dsn: "https://public:secret@app.getsentry.com/1",
        included_environments: [:prod],
        environment_name: Mix.env

  This will set the environment name to whatever the current Mix environment
  atom is, but it will only send events if the current environment is `:prod`,
  since that is the only entry in the `included_environments` key.

  You can even rely on more custom determinations of the environment name. It's
  not uncommmon for most applications to have a "staging" environment. In order
  to handle this without adding an additional Mix environment, you can set an
  environment variable that determines the release level.

      config :sentry, dsn: "https://public:secret@app.getsentry.com/1",
        included_environments: ~w(production staging),
        environment_name: System.get_env("RELEASE_LEVEL") || "development"

  In this example, we are getting the environment name from the `RELEASE_LEVEL`
  environment variable. If that variable does not exist, we default to `"development"`.
  Now, on our servers, we can set the environment variable appropriately. On
  our local development machines, exceptions will never be sent, because the
  default value is not in the list of `included_environments`.

  ## Filtering Exceptions

  If you would like to prevent certain exceptions, the `:filter` configuration option
  allows you to implement the `Sentry.EventFilter` behaviour.  The first argument is the
  exception to be sent, and the second is the source of the event.  `Sentry.Plug`
  will have a source of `:plug`, `Sentry.LoggerBackend` will have a source of `:logger`, and `Sentry.Phoenix.Endpoint` will have a source of `:endpoint`.
  If an exception does not come from either of those sources, the source will be nil
  unless the `:event_source` option is passed to `Sentry.capture_exception/2`

  A configuration like below will prevent sending `Phoenix.Router.NoRouteError` from `Sentry.Plug`, but
  allows other exceptions to be sent.

      # sentry_event_filter.ex
      defmodule MyApp.SentryEventFilter do
        @behaviour Sentry.EventFilter

        def exclude_exception?(%Elixir.Phoenix.Router.NoRouteError{}, :plug), do: true
        def exclude_exception?(_exception, _source), do: false
      end

      # config.exs
      config :sentry, filter: MyApp.SentryEventFilter,
        included_environments: ~w(production staging),
        environment_name: System.get_env("RELEASE_LEVEL") || "development"

  ## Capturing Exceptions

  Simply calling `capture_exception/2` will send the event. By default, the event
  is sent asynchronously and the result can be awaited upon.  The `:result` option
  can be used to change this behavior.  See `Sentry.Client.send_event/2` for more
  information.

      {:ok, task} = Sentry.capture_exception(my_exception)
      {:ok, event_id} = Task.await(task)
      {:ok, another_event_id} = Sentry.capture_exception(other_exception, [event_source: :my_source, result: :sync])

  ### Options

    * `:event_source` - The source passed as the first argument to `c:Sentry.EventFilter.exclude_exception?/2`

  ## Configuring The `Logger` Backend

  See `Sentry.LoggerBackend`
  """

  @type send_result :: Sentry.Client.send_event_result() | :excluded | :ignored

  def start(_type, _opts) do
    children = [
      {Task.Supervisor, name: Sentry.TaskSupervisor},
      Config.client().child_spec()
    ]

    if Config.client() == Sentry.HackneyClient do
      unless Code.ensure_loaded?(:hackney) do
        raise """
        cannot start the :sentry application because the HTTP client is set to \
        Sentry.HackneyClient (which is the default), but the Hackney library is not loaded. \
        Add :hackney to your dependencies to fix this.
        """
      end

      case Application.ensure_all_started(:hackney) do
        {:ok, _apps} -> :ok
        {:error, reason} -> raise "failed to start the :hackney application: #{inspect(reason)}"
      end
    end

    validate_json_config!()
    validate_log_level_config!()

    opts = [strategy: :one_for_one, name: Sentry.Supervisor]
    Supervisor.start_link(children, opts)
  end

  @doc """
  Parses and submits an exception to Sentry if current environment is in included_environments.
  `opts` argument is passed as the second argument to `Sentry.send_event/2`.
  """
  @spec capture_exception(Exception.t(), Keyword.t()) :: send_result
  def capture_exception(exception, opts \\ []) do
    filter_module = Config.filter()
    event_source = Keyword.get(opts, :event_source)

    if filter_module.exclude_exception?(exception, event_source) do
      :excluded
    else
      exception
      |> Event.transform_exception(opts)
      |> send_event(opts)
    end
  end

  @doc """
  Puts the last event ID sent to the server for the current process in
  the process dictionary.
  """
  @spec put_last_event_id_and_source(String.t()) :: {String.t(), atom() | nil} | nil
  def put_last_event_id_and_source(event_id, source \\ nil) when is_binary(event_id) do
    Process.put(:sentry_last_event_id_and_source, {event_id, source})
  end

  @doc """
  Gets the last event ID sent to the server from the process dictionary.
  Since it uses the process dictionary, it will only return the last event
  ID sent within the current process.
  """
  @spec get_last_event_id_and_source() :: {String.t(), atom() | nil} | nil
  def get_last_event_id_and_source do
    Process.get(:sentry_last_event_id_and_source)
  end

  @doc """
  Reports a message to Sentry.

  `opts` argument is passed as the second argument to `Sentry.send_event/2`.
  """
  @spec capture_message(String.t(), Keyword.t()) :: send_result
  def capture_message(message, opts \\ []) when is_binary(message) do
    opts
    |> Keyword.put(:message, message)
    |> Event.create_event()
    |> send_event(opts)
  end

  @doc """
  Sends a `Sentry.Event`

  `opts` argument is passed as the second argument to `send_event/2` of the configured `Sentry.HTTPClient`.  See `Sentry.Client.send_event/2` for more information.
  """
  @spec send_event(Event.t(), Keyword.t()) :: send_result
  def send_event(event, opts \\ [])

  def send_event(%Event{message: nil, exception: nil}, _opts) do
    Logger.log(Config.log_level(), "Sentry: unable to parse exception")

    :ignored
  end

  def send_event(%Event{} = event, opts) do
    included_environments = Config.included_environments()
    environment_name = Config.environment_name()

    if environment_name in included_environments do
      Sentry.Client.send_event(event, opts)
    else
      :ignored
    end
  end

  defp validate_json_config!() do
    case Config.json_library() do
      nil ->
        raise ArgumentError.exception("nil is not a valid :json_library configuration")

      library ->
        try do
          with {:ok, %{}} <- library.decode("{}"),
               {:ok, "{}"} <- library.encode(%{}) do
            :ok
          else
            _ ->
              raise ArgumentError.exception(
                      "configured :json_library #{inspect(library)} does not implement decode/1 and encode/1"
                    )
          end
        rescue
          UndefinedFunctionError ->
            reraise ArgumentError.exception("""
                    configured :json_library #{inspect(library)} is not available or does not implement decode/1 and encode/1.
                    Do you need to add #{inspect(library)} to your mix.exs?
                    """),
                    __STACKTRACE__
        end
    end
  end

  defp validate_log_level_config!() do
    value = Config.log_level()

    if value in Config.permitted_log_level_values() do
      :ok
    else
      raise ArgumentError.exception("#{inspect(value)} is not a valid :log_level configuration")
    end
  end
end