lib/mix/tasks/sentry.package_source_code.ex

defmodule Mix.Tasks.Sentry.PackageSourceCode do
  @shortdoc "Packages source code for Sentry to use when reporting errors"

  @moduledoc """
  Packages source code for Sentry to use when reporting errors.

  This task should be used in production settings, before building a release of your
  application. It packages all the source code of your application in a single file
  (called `sentry.map`), which is optimized for fast retrieval of source code lines.
  Sentry then uses this to report source code context. See the documentation for the
  `Sentry` module for configuration options related to the source code context.

  *This task is available since v10.0.0 of this library*.

  ## Usage

  ```shell
  mix sentry.package_source_code
  ```

  ### Using in Production

  In production settings, call this task before building a release. This way, the source
  code packaged by this task will be included in the release.

  For example, in a release script (this could also be in a `Dockerfile`, if you're using
  Docker):

  ```shell
  # ...

  mix sentry.package_source_code
  mix release
  ```

  > #### Runtime Configuration {: .tip}
  >
  > If you use `config/runtime.exs` for runtime configuration of your application and
  > release, *and* you also configure the source-context-related options for the `:sentry`
  > application in that file (such as `:root_source_code_paths`), you'll need to call the
  > `mix app.config` task before calling this Mix task. `mix app.config` loads the
  > `config/runtime.exs` configuration. Generally, we recommend configuring all the
  > source-context-related options in compile-time config files (like `config/config.exs`
  > or `config/prod.exs`).

  ## Options

    * `--debug` - print more information about collecting and encoding source code
    * `--output PATH` - write source map file to the given `PATH`. If you don't specify
      this option, the file will be written to the default path, which is inside the `priv`
      directory of the `:sentry` application. If you use this option, remember to also
      **configure the `:source_code_map_path` option**, otherwise Sentry will try to
      *read* the file from the default location. See `Sentry` for more information.
      This option can be useful when the source
      `priv` directory is read-only, such as with [NixOS](https://github.com/NixOS/nixpkgs)
      and similar tools.
      *Available since v10.2.0*.
    * `--no-compile` - does not compile applications before running
    * `--no-deps-check` - does not check dependencies before running

  """

  @moduledoc since: "10.0.0"

  use Mix.Task

  alias Sentry.Sources

  @bytes_in_kb 1024
  @bytes_in_mb 1024 * 1024
  @bytes_in_gb 1024 * 1024 * 1024

  @switches [
    debug: :boolean,
    output: :string,
    no_compile: :boolean,
    no_deps_check: :boolean
  ]

  @impl true
  def run(args) do
    {opts, _args} = OptionParser.parse!(args, strict: @switches)

    # Don't call the app.config task here, see
    # https://github.com/getsentry/sentry-elixir/commit/b2a04ddcf5d5c5f861da9888b3dec2f4f12cee01

    if not Keyword.get(opts, :no_deps_check, false) do
      Mix.Task.run("loadpaths")
    end

    if not Keyword.get(opts, :no_compile, false) do
      Mix.Task.run("compile")
    end

    config = Application.get_all_env(:sentry)

    {elapsed, source_map} =
      :timer.tc(fn ->
        case Sources.load_files(config) do
          {:ok, source_map} -> source_map
          {:error, message} -> Mix.raise(message)
        end
      end)

    log_debug(
      opts,
      "Loaded source code map with #{map_size(source_map)} files in #{format_time(elapsed)}"
    )

    {elapsed, contents} = :timer.tc(fn -> Sources.encode_source_code_map(source_map) end)
    log_debug(opts, "Encoded source code map in #{format_time(elapsed)}")

    output_path = Keyword.get_lazy(opts, :output, &Sources.path_of_packaged_source_code/0)
    File.mkdir_p!(Path.dirname(output_path))
    File.write!(output_path, contents)

    Mix.shell().info([
      "Wrote #{map_size(source_map)} files in ",
      [:cyan, format_bytes(byte_size(contents)), :reset],
      " to: #{Path.relative_to_cwd(output_path)}"
    ])
  end

  ## Helpers

  defp log_debug(opts, str) do
    if opts[:debug] do
      Mix.shell().info([:magenta, str, :reset])
    end
  end

  defp format_bytes(n) when n < @bytes_in_kb, do: "#{n} bytes"
  defp format_bytes(n) when n < @bytes_in_mb, do: "#{Float.round(n / @bytes_in_kb, 2)} kb"
  defp format_bytes(n) when n < @bytes_in_gb, do: "#{Float.round(n / @bytes_in_mb, 2)} Mb"

  defp format_time(n) when n < 1000, do: "#{n} µs"
  defp format_time(n) when n < 1_000_000, do: "#{div(n, 1000)} ms"
  defp format_time(n), do: "#{Float.round(n / 1_000_000, 2)} s"
end