diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 0000000..30a9e59 --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,102 @@ +name: Elixir CI + +on: [push, pull_request] + +jobs: + build: + + name: Build and test + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - elixir: '1.8' + otp: '21' + - elixir: '1.8' + otp: '22' + - elixir: '1.9' + otp: '21' + - elixir: '1.9' + otp: '22' + - elixir: '1.10' + otp: '21' + - elixir: '1.10' + otp: '22' + - elixir: '1.10' + otp: '23' + - elixir: '1.11' + otp: '21' + - elixir: '1.11' + otp: '22' + - elixir: '1.11' + otp: '23' + - elixir: '1.11' + otp: '24' + - elixir: '1.12' + otp: '22' + - elixir: '1.12' + otp: '23' + - elixir: '1.12' + otp: '24' + - elixir: '1.12' + otp: '24' + - elixir: '1.13' + otp: '22' + - elixir: '1.13' + otp: '23' + - elixir: '1.13' + otp: '24' + lint: lint + - elixir: '1.13' + otp: '24' + deps: latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Retrieve Mix Dependencies Cache + if: matrix.deps != 'latest' + uses: actions/cache@v1 + id: mix-cache + with: + path: deps + key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('mix.lock') }} + + - name: Remove mix.lock + if: matrix.deps == 'latest' + run: rm mix.lock + + - name: Install dependencies + if: steps.mix-cache.outputs.cache-hit != 'true' + run: mix deps.get + + - name: Retrieve PLT Cache + uses: actions/cache@v1 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plts-${{ hashFiles('mix.lock') }} + if: ${{ matrix.lint }} + + - name: Create PLTs + run: | + mkdir -p priv/plts + mix dialyzer --plt + if: ${{ matrix.lint }} + + - name: Check quality + run: | + mix format --check-formatted + mix dialyzer --no-check + if: ${{ matrix.lint }} + + - name: Run tests + run: mix test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0889c02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +boom_slack_notifier-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +priv/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..507c3f0 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# BoomSlackNotifier + +Provides a Slack notifier for the [BoomNotifier](https://github.com/wyeworks/boom) exception notification package. + +You can read the full documentation at [https://hexdocs.pm/boom_slack_notifier](https://hexdocs.pm/boom_slack_notifier). + +## Installation + +The package can be installed by adding `boom_slack_notifier` to your list of dependencies in +`mix.exs`: + +```elixir +def deps do + [ + {:boom_notifier, "~> 0.8.0"}, + {:boom_slack_notifier, "~> 0.1.0"} + ] +end +``` + +## How to use it + +```elixir +defmodule YourApp.Router do + use Phoenix.Router + + use BoomNotifier, + notifier: BoomSlackNotifier.SlackNotifier, + options: [ + webhook_url: "", + ] + + # ... +``` + +To configure it, you need to set the `webhook_url` in the `options` keyword list. A `POST` request with a `json` will be made to that webhook when an error ocurrs with the relevant information. + +### Setting up a Slack webhook + +If you don't already have a webhook setup for Slack, you can follow the steps below: + +1. Go to [Slack API](https://api.slack.com/) > My Apps +2. Create a new application +3. Inside your new application go to > Add features and functionality > Incoming Webhooks +4. Activate incoming webhooks for your application +5. Scroll down to 'Webhook URLs for Your Workspace' and create a new Webhook URL for a given channel. + +## Http client + +By default BoomSlackNotifier uses [HTTPoison](https://github.com/edgurgel/httpoison) as the http client. + +You can setup your favorite client by warpping it with the `SlackAdapter` behaviour, for example: + +``` +#mojito_http_adapter.ex + + @impl BoomSlackNotifier.SlackAdapter + @spec post(any, binary, any) :: {:ok, any} | {:error, any} + def post(body, url, headers) do + {:ok, response} = Mojito.request(body: body, method: :post, url: url, headers: headers) + # ... + end +``` + +And then specifying it in your application configuration: + +``` +#config.exs + +config :boom_slack_notifier, :slack_adapter, MyApp.MojitoHttpAdapter + +``` + +Default configuration (not required): +``` +config :boom_slack_notifier, :slack_adapter, BoomSlackNotifier.SlackClient.HTTPoisonAdapter +``` +## License + +BoomSlackNotifier is released under the terms of the [MIT License](https://github.com/wyeworks/boom/blob/master/LICENSE). diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..8af9a58 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,5 @@ +use Mix.Config + +config :phoenix, :json_library, Jason + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..9f8cc4b --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +use Mix.Config + +config :boom_slack_notifier, :slack_adapter, BoomSlackNotifier.HTTPoisonAdapter diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..c5c4435 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,3 @@ +use Mix.Config + +config :boom_slack_notifier, :slack_adapter, Support.HttpAdapterMock diff --git a/lib/boom_slack_notifier/httpoison_adapter.ex b/lib/boom_slack_notifier/httpoison_adapter.ex new file mode 100644 index 0000000..dc18df4 --- /dev/null +++ b/lib/boom_slack_notifier/httpoison_adapter.ex @@ -0,0 +1,16 @@ +defmodule BoomSlackNotifier.HTTPoisonAdapter do + @moduledoc false + + @behaviour BoomSlackNotifier.SlackAdapter + + HTTPoison.start() + + @impl BoomSlackNotifier.SlackAdapter + @spec post(any, binary, HTTPoison.Base.headers()) :: + {:ok, + HTTPoison.Response.t() | HTTPoison.AsyncResponse.t() | HTTPoison.MaybeRedirect.t()} + | {:error, HTTPoison.Error.t()} + def post(body, url, headers) do + HTTPoison.post(url, body, headers, []) + end +end diff --git a/lib/boom_slack_notifier/slack_adapter.ex b/lib/boom_slack_notifier/slack_adapter.ex new file mode 100644 index 0000000..2bcdb08 --- /dev/null +++ b/lib/boom_slack_notifier/slack_adapter.ex @@ -0,0 +1,38 @@ +defmodule BoomSlackNotifier.SlackAdapter do + @moduledoc """ + + By default BoomSlackNotifier uses [HTTPoison](https://github.com/edgurgel/httpoison) as the http client. + + You can setup your favorite client by warpping it with the `SlackAdapter` behaviour, for example: + + ``` + #mojito_http_adapter.ex + + @impl BoomSlackNotifier.SlackAdapter + @spec post(any, binary, any) :: {:ok, any} | {:error, any} + def post(body, url, headers) do + {:ok, response} = Mojito.request(body: body, method: :post, url: url, headers: headers) + # ... + end + ``` + + And then specifying it in your application configuration: + + ``` + #config.exs + + config :boom_slack_notifier, :slack_adapter, MyApp.MojitoHttpAdapter + + ``` + + Default configuration (not required): + ``` + config :boom_slack_notifier, :slack_adapter, BoomSlackNotifier.SlackClient.HTTPoisonAdapter + ``` + """ + + @doc """ + Defines a callback to be used by the http adapters + """ + @callback post(any, binary, any) :: {:ok, any} | {:error, any} +end diff --git a/lib/boom_slack_notifier/slack_message.ex b/lib/boom_slack_notifier/slack_message.ex new file mode 100644 index 0000000..865466f --- /dev/null +++ b/lib/boom_slack_notifier/slack_message.ex @@ -0,0 +1,219 @@ +defmodule BoomSlackNotifier.SlackMessage do + import BoomNotifier.Helpers + + @moduledoc false + + @spec create_message(BoomNotifier.ErrorInfo.t()) :: no_return() + def create_message(error) do + error + |> Map.from_struct() + |> format_summary() + |> format_stacktrace() + |> create_section_blocks() + |> create_message_body() + end + + defp format_summary( + %{ + controller: controller, + action: action + } = error + ) + when is_nil(controller) or is_nil(action) do + error + end + + defp format_summary( + %{ + name: name, + controller: controller, + action: action + } = error + ) do + Map.put( + error, + :exception_summary, + exception_basic_text(name, controller, action) + ) + end + + defp format_stacktrace(error) do + %{error | stack: Enum.map(error.stack, &Exception.format_stacktrace_entry/1)} + end + + defp create_section_blocks(error) do + # Sections are prepended to the list, the slack message will display them in reverse order + [%{type: "divider"}] + |> add_occurrences_section(error[:occurrences]) + |> add_reason_section(error[:reason]) + |> add_stacktrace_section(error[:stack]) + |> add_metadata_section(error[:metadata]) + |> add_request_section(error[:request]) + |> add_summary_section(error[:exception_summary]) + end + + defp add_occurrences_section(sections_list, nil) do + sections_list + end + + defp add_occurrences_section(sections_list, occurrences) do + occurrences_section = %{ + type: "section", + text: %{ + type: "mrkdwn", + text: "*Occurrences*" + }, + fields: [ + request_field("Errors", occurrences.accumulated_occurrences), + request_field("First occurrence", occurrences.first_occurrence), + request_field("Last occurrence", occurrences.last_occurrence) + ] + } + + [occurrences_section | sections_list] + end + + defp add_reason_section(sections_list, nil) do + # ErrorInfo didn't include reason + sections_list + end + + defp add_reason_section(sections_list, reason) do + [plain_text_section("Reason", reason) | sections_list] + end + + defp add_stacktrace_section(sections_list, nil) do + # ErrorInfo didn't include stacktrace + sections_list + end + + defp add_stacktrace_section(sections_list, stack_entries) do + stacktrace = Enum.reduce(stack_entries, "", fn entry, acc -> "#{acc}\n#{entry}" end) + + stacktrace_section = %{ + type: "section", + text: %{ + type: "mrkdwn", + text: "*Stacktrace*\n```#{stacktrace}```" + } + } + + [stacktrace_section | sections_list] + end + + defp add_metadata_section(sections_list, nil) do + # ErrorInfo didn't include metadata + sections_list + end + + defp add_metadata_section(sections_list, metadata) when metadata == %{} do + sections_list + end + + defp add_metadata_section(sections_list, metadata) do + metadata_section = %{ + type: "section", + text: %{ + type: "mrkdwn", + text: "*Metadata*" + }, + fields: Enum.map(metadata, fn {key, value} -> metadata_field(key, value) end) + } + + [metadata_section | sections_list] + end + + defp metadata_field(label, content) when content == %{} do + %{ + type: "mrkdwn", + text: "#{label}:\n N/A" + } + end + + defp metadata_field(label, content) do + # Each field of the metadata section contains a codeblock with the key-value pairs + content = + Enum.reduce(content, "", fn {key, value}, acc -> + acc <> "#{key}=#{value || "N/A"}\n" + end) + + label = String.capitalize("#{label}") + + %{ + type: "mrkdwn", + text: "#{label}\n```#{content}```" + } + end + + defp add_request_section(sections_list, nil) do + # ErrorInfo didn't include request + sections_list + end + + defp add_request_section(sections_list, request) do + request_section = %{ + type: "section", + text: %{ + type: "mrkdwn", + text: "*Request Information*" + }, + fields: [ + request_field("URL", request[:url]), + request_field("Path", request[:path]), + request_field("Port", request[:port]), + request_field("Scheme", request[:scheme]), + request_field("Query String", request[:query_string]), + request_field("Client IP", request[:client_ip]) + ] + } + + [request_section | sections_list] + end + + defp request_field(label, content) do + %{ + type: "mrkdwn", + text: field_text(label, content) + } + end + + defp add_summary_section(sections_list, nil) do + # ErrorInfo didn't include summary + sections_list + end + + defp add_summary_section(sections_list, summary) do + [plain_text_section("Summary", summary) | sections_list] + end + + defp field_text(label, content) do + content = if content === "", do: "\nN/A", else: "```#{content}```" + "#{label}: #{content}" + end + + defp plain_text_section(header, text) do + %{ + type: "section", + text: %{ + type: "mrkdwn", + text: "*#{header}*\n```#{text}```" + } + } + end + + defp create_message_body(error_blocks) do + message_header = %{ + type: "header", + text: %{ + type: "plain_text", + text: "Boom! :boom:", + emoji: true + } + } + + %{ + text: "An exception was raised", + blocks: [message_header | error_blocks] + } + end +end diff --git a/lib/boom_slack_notifier/slack_notifier.ex b/lib/boom_slack_notifier/slack_notifier.ex new file mode 100644 index 0000000..c150c0c --- /dev/null +++ b/lib/boom_slack_notifier/slack_notifier.ex @@ -0,0 +1,63 @@ +defmodule BoomSlackNotifier.SlackNotifier do + @moduledoc """ + Send exception notifications as slack messages through http requests + + ## Usage + ```elixir + defmodule YourApp.Router do + use Phoenix.Router + + use BoomNotifier, + notifier: BoomSlackNotifier.SlackNotifier, + options: [ + webhook_url: "" + ] + # ... + ``` + """ + + @behaviour BoomNotifier.Notifier + + alias BoomSlackNotifier.{SlackMessage, SlackAdapter, HTTPoisonAdapter} + require Logger + + @type options :: [{:webhook_url, String.t()}] + + @impl BoomNotifier.Notifier + def validate_config(options) do + if Keyword.has_key?(options, :webhook_url) do + :ok + else + {:error, ":webhook_url parameter is missing"} + end + end + + @impl BoomNotifier.Notifier + @spec notify(BoomNotifier.ErrorInfo.t(), options) :: no_return() + def notify(error_info, options) do + headers = [{"Content-type", "application/json"}] + + response = + error_info + |> SlackMessage.create_message() + |> Jason.encode!() + |> slack_adapter().post(options[:webhook_url], headers) + + case response do + {:error, info} -> + Logger.error("An error occurred when sending a notification: #{inspect(info)}") + + _ -> + nil + end + end + + @spec slack_adapter() :: SlackAdapter + defp slack_adapter(), + do: + Application.get_env( + :boom_slack_notifier, + :slack_adapter, + HTTPoisonAdapter + ) +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..9ed295d --- /dev/null +++ b/mix.exs @@ -0,0 +1,80 @@ +defmodule BoomSlackNotifier.MixProject do + use Mix.Project + + @source_url "https://github.com/wyeworks/boom_slack_notifier" + + def project do + [ + app: :boom_slack_notifier, + version: "0.1.0", + elixir: "~> 1.8", + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [ + plt_add_apps: [:mix], + plt_core_path: "priv/plts", + plt_file: {:no_warn, "priv/plts/dialyzer.plt"} + ], + elixirc_paths: elixirc_paths(Mix.env()), + description: description(), + package: package(), + docs: docs() + ] + end + + defp description do + """ + Provides a custom notifier for the Boom Notifier package. + It allows sending slack messages whenever an exception is raised in your phoenix app. + """ + end + + defp package do + [ + files: ["lib", "mix.exs", "README.md"], + maintainers: ["Wyeworks"], + licenses: ["MIT"], + links: %{ + "GitHub" => @source_url, + "Docs" => "https://hexdocs.pm/boom_slack_notifier" + } + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:boom_notifier, "~> 0.8.0"}, + {:httpoison, "~> 1.5"}, + {:jason, "~> 1.2"}, + + # Test dependencies + {:phoenix, "~> 1.4", only: :test}, + + # Dev dependencies + {:dialyxir, "~> 1.1", only: :dev, runtime: false}, + {:ex_doc, "~> 0.23", only: :dev} + ] + end + + defp docs do + [ + main: "readme", + source_url: @source_url, + extras: [ + "README.md" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..27fc9b5 --- /dev/null +++ b/mix.lock @@ -0,0 +1,33 @@ +%{ + "boom_notifier": {:hex, :boom_notifier, "0.8.0", "83e3f140d9c5a07c10334173eb855b14b3e20470bfddce4318248dcad4e393a9", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: true]}], "hexpm", "6fe5660e21ce796dfc26c9940fee2a4dcfbfb9ad342976afb8cb96e7166215f4"}, + "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.28.3", "6eea2f69995f5fba94cd6dd398df369fe4e777a47cd887714a0976930615c9e6", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "05387a6a2655b5f9820f3f627450ed20b4325c25977b2ee69bed90af6688e718"}, + "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "httpoison": {:hex, :httpoison, "1.8.1", "df030d96de89dad2e9983f92b0c506a642d4b1f4a819c96ff77d12796189c63e", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "35156a6d678d6d516b9229e208942c405cf21232edd632327ecfaf4fd03e79e0"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "phoenix": {:hex, :phoenix, "1.6.7", "f1de32418bbbcd471f4fe74d3860ee9c8e8c6c36a0ec173be8ff468a5d72ac90", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b354a4f11d9a2f3a380fb731042dae064f22d7aed8c7e7c024a2459f12994aad"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, +} diff --git a/test/boom_slack_notifier_test.exs b/test/boom_slack_notifier_test.exs new file mode 100644 index 0000000..f0f0c9b --- /dev/null +++ b/test/boom_slack_notifier_test.exs @@ -0,0 +1,149 @@ +defmodule BoomSlackNotifierTest do + use ExUnit.Case, async: false + use Plug.Test + alias Support.{TestRouter, Helpers} + import ExUnit.CaptureLog + + @expected_message %{ + blocks: [ + # Header section + %{ + text: %{ + emoji: true, + text: "Boom! :boom:", + type: "plain_text" + }, + type: "header" + }, + # Summary section + %{ + text: %{ + text: + "*Summary*\n```TestException occurred while the request was processed by TestController#index```", + type: "mrkdwn" + }, + type: "section" + }, + # Request section + %{ + fields: [ + %{ + text: "URL: ```http://www.example.com/```", + type: "mrkdwn" + }, + %{text: "Path: ```/```", type: "mrkdwn"}, + %{text: "Port: ```80```", type: "mrkdwn"}, + %{text: "Scheme: ```http```", type: "mrkdwn"}, + %{text: "Query String: \nN/A", type: "mrkdwn"}, + %{text: "Client IP: ```127.0.0.1```", type: "mrkdwn"} + ], + text: %{text: "*Request Information*", type: "mrkdwn"}, + type: "section" + }, + # Metadata section + %{ + fields: [ + %{ + text: "Assigns\n```age=32\nname=Davis\n```", + type: "mrkdwn" + }, + %{ + text: "Logger\n```age=17\nname=Dennis\n```", + type: "mrkdwn" + } + ], + text: %{text: "*Metadata*", type: "mrkdwn"}, + type: "section" + }, + # Reason section + %{ + text: %{ + text: "*Reason*\n```booom!```", + type: "mrkdwn" + }, + type: "section" + }, + %{type: "divider"} + ], + text: "An exception was raised" + } + + # The complete text and format of the stacktrace will vary depending on the elixir version, we therefore only check for the first entry + @expected_stacktrace_entry "test/support/phoenix_app_mock.ex:10: Support.TestController.index/2" + @success_webhook_url "http://www.someurl.com/123" + + setup do + Helpers.register_test_process() + Logger.metadata(name: "Dennis", age: 17) + + Application.put_env(:boom_slack_notifier, :test_webhook_url, value: @success_webhook_url) + end + + test "validates return {:error, message} when url is not present" do + assert {:error, ":webhook_url parameter is missing"} == + BoomSlackNotifier.SlackNotifier.validate_config(random_param: nil) + end + + test "logs an error when the request fails" do + Application.put_env(:boom_slack_notifier, :test_webhook_url, value: "failing_url") + + assert capture_log(fn -> + conn = conn(:get, "/") + + catch_error(TestRouter.call(conn, TestRouter.init([]))) + + Process.sleep(100) + end) =~ + "An error occurred when sending a notification: %{reason: \"Could not resolve URL\"}" + end + + test "request is sent to webhook" do + conn = conn(:get, "/") + + catch_error(TestRouter.call(conn, TestRouter.init([]))) + + assert_receive {:ok, request, url, headers} + assert url == @success_webhook_url + + message = Jason.decode!(request, keys: :atoms) + assert message.text == @expected_message.text + assert headers == [{"Content-type", "application/json"}] + + [ + message_header, + message_summary, + message_request, + message_metadata, + message_stacktrace, + message_reason, + message_occurrences, + _divider + ] = message.blocks + + [accumulated_errors, first_occurrence, last_occurrence] = message_occurrences.fields + + [ + expected_header, + expected_summary, + expected_request, + expected_metadata, + expected_reason, + _divider + ] = @expected_message.blocks + + assert message_summary == expected_summary + assert message_request == expected_request + assert message_metadata == expected_metadata + assert message_header == expected_header + assert message_reason == expected_reason + + assert message_stacktrace.text.text =~ @expected_stacktrace_entry + + assert accumulated_errors.text =~ "Errors:" + assert first_occurrence.text =~ "First occurrence:" + assert last_occurrence.text =~ "Last occurrence:" + + last_occurrence_datetime = Helpers.datetime_from_text_field(last_occurrence.text) + assert DateTime.diff(last_occurrence_datetime, DateTime.utc_now()) < 2 + end +end diff --git a/test/support/helpers.ex b/test/support/helpers.ex new file mode 100644 index 0000000..69f1fb6 --- /dev/null +++ b/test/support/helpers.ex @@ -0,0 +1,18 @@ +defmodule Support.Helpers do + @test_process_name :test_process + def datetime_from_text_field(datetime_text) do + # Get the datetime from the field text e.g. "Last occurrence: ```2022-04-27 00:27:34.040105Z```" + Regex.run(~r/(?<=\```)(.*?)(?=\```)/, datetime_text, capture: :first) + |> Enum.at(0) + |> DateTime.from_iso8601() + |> elem(1) + end + + def register_test_process() do + Process.register(self(), @test_process_name) + end + + def get_test_process_name() do + @test_process_name + end +end diff --git a/test/support/http_adapter_mock.ex b/test/support/http_adapter_mock.ex new file mode 100644 index 0000000..5151af1 --- /dev/null +++ b/test/support/http_adapter_mock.ex @@ -0,0 +1,15 @@ +defmodule Support.HttpAdapterMock do + @behaviour BoomSlackNotifier.SlackAdapter + alias Support.Helpers + + @impl BoomSlackNotifier.SlackAdapter + + @spec post(any, binary, any) :: {:ok, any} | {:error, any} + def post(_body, "failing_url", _headers) do + {:error, %{reason: "Could not resolve URL"}} + end + + def post(body, url, headers) do + send(Helpers.get_test_process_name(), {:ok, body, url, headers}) + end +end diff --git a/test/support/phoenix_app_mock.ex b/test/support/phoenix_app_mock.ex new file mode 100644 index 0000000..ae3be71 --- /dev/null +++ b/test/support/phoenix_app_mock.ex @@ -0,0 +1,40 @@ +defmodule Support.TestController do + use Phoenix.Controller + import Plug.Conn + + defmodule TestException do + defexception plug_status: 403, message: "booom!" + end + + def index(_conn, _params) do + raise TestException.exception([]) + end +end + +defmodule Support.TestRouter do + use Phoenix.Router + import Phoenix.Controller + + use BoomNotifier, + notifier: BoomSlackNotifier.SlackNotifier, + options: [ + webhook_url: Application.get_env(:boom_slack_notifier, :test_webhook_url)[:value] + ], + custom_data: [:assigns, :logger] + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:save_custom_data) + end + + scope "/" do + pipe_through(:browser) + get("/", Support.TestController, :index, log: false) + end + + def save_custom_data(conn, _) do + conn + |> assign(:name, "Davis") + |> assign(:age, 32) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()