lib/sentry/context.ex

defmodule Sentry.Context do
  @moduledoc """
    Provides functionality to store user, tags, extra, and breadcrumbs context when an
    event is reported. The contexts will be fetched and merged into the event when it is sent.

    When calling Sentry.Context, Logger metadata is used to store this state.
    This imposes some limitations. The metadata will only exist within
    the current process, and the context will die with the process.

    For example, if you add context inside your controller and an
    error happens in a Task, that context will not be included.

    A common use-case is to set context within Plug or Phoenix applications, as each
    request is its own process, and so any stored context will be included should an
    error be reported within that request process. Example:

      # post_controller.ex
      def index(conn, _params) do
        Sentry.Context.set_user_context(%{id: conn.assigns.user_id})
        posts = Blog.list_posts()
        render(conn, "index.html", posts: posts)
      end

    It should be noted that the `set_*_context/1` functions merge with the
    existing context rather than entirely overwriting it.
  """
  @logger_metadata_key :sentry
  @user_key :user
  @tags_key :tags
  @extra_key :extra
  @request_key :request
  @breadcrumbs_key :breadcrumbs

  @doc """
  Retrieves all currently set context on the current process.

  ## Example

      iex> Sentry.Context.set_user_context(%{id: 123})
      iex> Sentry.Context.set_tags_context(%{message_id: 456})
      iex> Sentry.Context.get_all()
      %{
        user: %{id: 123},
        tags: %{message_id: 456},
        extra: %{},
        request: %{},
        breadcrumbs: []
      }
  """
  @spec get_all() :: %{
          user: map(),
          tags: map(),
          extra: map(),
          request: map(),
          breadcrumbs: list()
        }
  def get_all do
    context = get_sentry_context()

    %{
      user: Map.get(context, @user_key, %{}),
      tags: Map.get(context, @tags_key, %{}),
      extra: Map.get(context, @extra_key, %{}),
      request: Map.get(context, @request_key, %{}),
      breadcrumbs: Map.get(context, @breadcrumbs_key, []) |> Enum.reverse() |> Enum.to_list()
    }
  end

  @doc """
  Merges new fields into the `:extra` context, specific to the current process.

  This is used to set fields which should display when looking at a specific
  instance of an error.

  ## Example

      iex> Sentry.Context.set_extra_context(%{id: 123})
      :ok
      iex> Sentry.Context.set_extra_context(%{detail: "bad_error"})
      :ok
      iex> Sentry.Context.set_extra_context(%{message: "Oh no"})
      :ok
      iex> Sentry.Context.get_all()
      %{
        user: %{},
        tags: %{},
        extra: %{detail: "bad_error", id: 123, message: "Oh no"},
        request: %{},
        breadcrumbs: []
      }
  """
  @spec set_extra_context(map()) :: :ok
  def set_extra_context(map) when is_map(map) do
    set_context(@extra_key, map)
  end

  @doc """
  Merges new fields into the `:user` context, specific to the current process.

  This is used to set certain fields which identify the actor who experienced a
  specific instance of an error.

  ## Example

      iex> Sentry.Context.set_user_context(%{id: 123})
      :ok
      iex> Sentry.Context.set_user_context(%{username: "george"})
      :ok
      iex> Sentry.Context.get_all()
      %{
        user: %{id: 123, username: "george"},
        tags: %{},
        extra: %{},
        request: %{},
        breadcrumbs: []
      }
  """
  @spec set_user_context(map()) :: :ok
  def set_user_context(map) when is_map(map) do
    set_context(@user_key, map)
  end

  @doc """
  Merges new fields into the `:tags` context, specific to the current process.

  This is used to set fields which should display when looking at a specific
  instance of an error. These fields can also be used to search and filter on.

  ## Example

      iex> Sentry.Context.set_tags_context(%{id: 123})
      :ok
      iex> Sentry.Context.set_tags_context(%{other_id: 456})
      :ok
      iex> Sentry.Context.get_all()
      %{
          breadcrumbs: [],
          extra: %{},
          request: %{},
          tags: %{id: 123, other_id: 456},
          user: %{}
      }
  """
  @spec set_tags_context(map()) :: :ok
  def set_tags_context(map) when is_map(map) do
    set_context(@tags_key, map)
  end

  @doc """
  Merges new fields into the `:request` context, specific to the current
  process.

  This is used to set metadata that identifies the request associated with a
  specific instance of an error.

  ## Example

      iex(1)> Sentry.Context.set_request_context(%{id: 123})
      :ok
      iex(2)> Sentry.Context.set_request_context(%{url: "www.example.com"})
      :ok
      iex(3)> Sentry.Context.get_all()
      %{
          breadcrumbs: [],
          extra: %{},
          request: %{id: 123, url: "www.example.com"},
          tags: %{},
          user: %{}
      }
  """
  @spec set_request_context(map()) :: :ok
  def set_request_context(map) when is_map(map) do
    set_context(@request_key, map)
  end

  @doc """
  Clears all existing context for the current process.

  ## Example

      iex> Sentry.Context.set_tags_context(%{id: 123})
      :ok
      iex> Sentry.Context.clear_all()
      :ok
      iex> Sentry.Context.get_all()
      %{breadcrumbs: [], extra: %{}, request: %{}, tags: %{}, user: %{}}
  """
  def clear_all do
    :logger.update_process_metadata(%{@logger_metadata_key => %{}})
  end

  defp get_sentry_context do
    case :logger.get_process_metadata() do
      %{@logger_metadata_key => sentry} -> sentry
      %{} -> %{}
      :undefined -> %{}
    end
  end

  @doc """
  Adds a new breadcrumb to the `:breadcrumb` context, specific to the current
  process.

  Breadcrumbs are used to record a series of events that led to a specific
  instance of an error. Breadcrumbs can contain arbitrary key data to assist in
  understanding what happened before an error occurred.

  ## Example

      iex> Sentry.Context.add_breadcrumb(message: "first_event")
      :ok
      iex> Sentry.Context.add_breadcrumb(%{message: "second_event", type: "auth"})
      %{breadcrumbs: [%{:message => "first_event", "timestamp" => 1562007480}]}
      iex> Sentry.Context.add_breadcrumb(%{message: "response"})
      %{
          breadcrumbs: [
                %{:message => "second_event", :type => "auth", "timestamp" => 1562007505},
                %{:message => "first_event", "timestamp" => 1562007480}
              ]
      }
      iex> Sentry.Context.get_all()
      %{
          breadcrumbs: [
                %{:message => "first_event", "timestamp" => 1562007480},
                %{:message => "second_event", :type => "auth", "timestamp" => 1562007505},
                %{:message => "response", "timestamp" => 1562007517}
              ],
          extra: %{},
          request: %{},
          tags: %{},
          user: %{}
      }
  """
  @spec add_breadcrumb(keyword() | map()) :: :ok
  def add_breadcrumb(list) when is_list(list) do
    if Keyword.keyword?(list) do
      list
      |> Enum.into(%{})
      |> add_breadcrumb
    else
      raise ArgumentError, """
      Sentry.Context.add_breadcrumb/1 only accepts keyword lists or maps.
      Received a non-keyword list.
      """
    end
  end

  def add_breadcrumb(map) when is_map(map) do
    map = Map.put_new(map, "timestamp", Sentry.Util.unix_timestamp())

    sentry_metadata =
      get_sentry_context()
      |> Map.update(@breadcrumbs_key, [map], fn breadcrumbs ->
        breadcrumbs = [map | breadcrumbs]
        Enum.take(breadcrumbs, -1 * Sentry.Config.max_breadcrumbs())
      end)

    :logger.update_process_metadata(%{@logger_metadata_key => sentry_metadata})
  end

  defp set_context(key, new) when is_map(new) do
    sentry_metadata =
      case :logger.get_process_metadata() do
        %{@logger_metadata_key => sentry} -> Map.update(sentry, key, new, &Map.merge(&1, new))
        _ -> %{key => new}
      end

    :logger.update_process_metadata(%{@logger_metadata_key => sentry_metadata})
  end

  @doc """
  Returns the keys used to store context in the current Process's process
  dictionary.

  ## Example

      iex> Sentry.Context.context_keys()
      [:breadcrumbs, :tags, :user, :extra]
  """
  @spec context_keys() :: list(atom())
  def context_keys do
    [@breadcrumbs_key, @tags_key, @user_key, @extra_key]
  end
end