diff --git a/.formatter.exs b/.formatter.exs index 66646b82..a640d13b 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:plug], + import_deps: [:plug, :phoenix, :phoenix_live_view], inputs: [ "lib/**/*.ex", "config/*.exs", diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c884b974..443a4bfe 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,8 +31,8 @@ jobs: otp: '25.3' # Oldest supported Elixir/Erlang pair. - - elixir: '1.11.4' - otp: '21.3' + - elixir: '1.13.4-otp-22' + otp: '22.3.4' steps: - name: Check out this repository diff --git a/lib/sentry/live_view_hook.ex b/lib/sentry/live_view_hook.ex new file mode 100644 index 00000000..9b6418a9 --- /dev/null +++ b/lib/sentry/live_view_hook.ex @@ -0,0 +1,155 @@ +if Code.ensure_loaded?(Phoenix.LiveView) do + defmodule Sentry.LiveViewHook do + @moduledoc """ + A module that provides a `Phoenix.LiveView` hook to add Sentry context and breadcrumbs. + + *Available since v10.5.0.* + + This module sets context and breadcrumbs for the live view process through + `Sentry.Context`. It sets things like: + + * The request URL + * The user agent and user's IP address + * Breadcrumbs for events that happen within LiveView + + To make this module work best, you'll need to fetch information from the LiveView's + WebSocket. You can do that when calling the `socket/3` macro in your Phoenix endpoint. + For example: + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [:peer_data, :uri, :user_agent]] + + ## Examples + + defmodule MyApp.UserLive do + use Phoenix.LiveView + + on_mount Sentry.LiveViewHook + + # ... + end + + You can do the same at the router level: + + live_session :default, on_mounbt: Sentry.LiveViewHook do + scope "..." do + # ... + end + end + + You can also set this in your `MyAppWeb` module, so that all LiveViews that + `use MyAppWeb, :live_view` will have this hook. + """ + + @moduledoc since: "10.5.0" + + import Phoenix.LiveView, only: [attach_hook: 4, get_connect_info: 2] + + alias Sentry.Context + + require Logger + + # See also: + # https://develop.sentry.dev/sdk/event-payloads/request/ + + @doc false + @spec on_mount(:default, map(), map(), struct()) :: {:cont, struct()} + def on_mount(:default, params, _session, socket), do: on_mount(params, socket) + + ## Helpers + + defp on_mount(params, %Phoenix.LiveView.Socket{} = socket) do + Context.set_extra_context(%{socket_id: socket.id}) + Context.set_request_context(%{url: socket.host_uri}) + + Context.add_breadcrumb(%{ + category: "web.live_view.mount", + message: "Mounted live view", + data: params + }) + + if uri = get_connect_info(socket, :uri) do + Context.set_request_context(%{url: URI.to_string(uri)}) + end + + if user_agent = get_connect_info(socket, :user_agent) do + Context.set_extra_context(%{user_agent: user_agent}) + end + + # :peer_data returns t:Plug.Conn.Adapter.peer_data/0. + # https://hexdocs.pm/plug/Plug.Conn.Adapter.html#t:peer_data/0 + if ip_address = socket |> get_connect_info(:peer_data) |> get_safe_ip_address() do + Context.set_user_context(%{ip_address: ip_address}) + end + + socket + |> maybe_attach_hook_handle_params() + |> attach_hook(__MODULE__, :handle_event, &handle_event_hook/3) + |> attach_hook(__MODULE__, :handle_info, &handle_info_hook/2) + catch + # We must NEVER raise an error in a hook, as it will crash the LiveView process + # and we don't want Sentry to be responsible for that. + kind, reason -> + Logger.error( + "Sentry.LiveView.on_mount hook errored out: #{Exception.format(kind, reason)}", + event_source: :logger + ) + + {:cont, socket} + else + socket -> {:cont, socket} + end + + defp handle_event_hook(event, params, socket) do + Context.add_breadcrumb(%{ + category: "web.live_view.event", + message: inspect(event), + data: %{event: event, params: params} + }) + + {:cont, socket} + end + + defp handle_info_hook(message, socket) do + Context.add_breadcrumb(%{ + category: "web.live_view.info", + message: inspect(message, pretty: true) + }) + + {:cont, socket} + end + + defp handle_params_hook(params, uri, socket) do + Context.set_extra_context(%{socket_id: socket.id}) + Context.set_request_context(%{url: uri}) + + Context.add_breadcrumb(%{ + category: "web.live_view.params", + message: "#{uri}", + data: %{params: params, uri: uri} + }) + + {:cont, socket} + end + + defp maybe_attach_hook_handle_params(socket) do + case socket.parent_pid do + nil -> attach_hook(socket, __MODULE__, :handle_params, &handle_params_hook/3) + pid when is_pid(pid) -> socket + end + end + + defp get_safe_ip_address(%{ip_address: ip} = _peer_data) do + case :inet.ntoa(ip) do + ip_address when is_list(ip_address) -> List.to_string(ip_address) + {:error, _reason} -> nil + end + catch + _kind, _reason -> nil + end + + defp get_safe_ip_address(_peer_data) do + nil + end + end +end diff --git a/mix.exs b/mix.exs index d86a8a9b..8c616294 100644 --- a/mix.exs +++ b/mix.exs @@ -46,7 +46,7 @@ defmodule Sentry.Mixfile do "Upgrade Guides": [~r{^pages/upgrade}] ], groups_for_modules: [ - "Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext], + "Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook], Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler], "Data Structures": [Sentry.Attachment, Sentry.CheckIn], HTTP: [Sentry.HTTPClient, Sentry.HackneyClient], @@ -91,6 +91,8 @@ defmodule Sentry.Mixfile do # Optional dependencies {:hackney, "~> 1.8", optional: true}, {:jason, "~> 1.1", optional: true}, + {:phoenix, "~> 1.6", optional: true}, + {:phoenix_live_view, "~> 0.20", optional: true}, {:plug, "~> 1.6", optional: true}, {:telemetry, "~> 0.4 or ~> 1.0", optional: true}, @@ -99,27 +101,11 @@ defmodule Sentry.Mixfile do {:dialyxir, "~> 1.0", only: [:test, :dev], runtime: false}, {:ex_doc, "~> 0.29.0", only: :dev}, {:excoveralls, "~> 0.17.1", only: [:test]}, - {:phoenix, "~> 1.5", only: [:test]}, - {:phoenix_html, "~> 2.0", only: [:test]} - ] ++ maybe_oban_optional_dependency() ++ maybe_quantum_optional_dependency() - end - - # TODO: Remove this once we drop support for Elixir < 1.13. - defp maybe_oban_optional_dependency do - if Version.match?(System.version(), "~> 1.13") do - [{:oban, "~> 2.17 and >= 2.17.6", only: [:test]}] - else - [] - end - end - - # TODO: Remove this once we drop support for Elixir < 1.12. - defp maybe_quantum_optional_dependency do - if Version.match?(System.version(), "~> 1.12") do - [{:quantum, "~> 3.0", only: [:test]}] - else - [] - end + # Required by Phoenix.LiveView's testing + {:floki, ">= 0.30.0", only: :test}, + {:oban, "~> 2.17 and >= 2.17.6", only: [:test]}, + {:quantum, "~> 3.0", only: [:test]} + ] end defp package do diff --git a/mix.lock b/mix.lock index c8f84db7..51bf0ba5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,10 @@ %{ "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, - "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, + "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, - "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, + "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, @@ -14,6 +15,7 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [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", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, "excoveralls": {:hex, :excoveralls, "0.17.1", "83fa7906ef23aa7fc8ad7ee469c357a63b1b3d55dd701ff5b9ce1f72442b2874", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "95bc6fda953e84c60f14da4a198880336205464e75383ec0f570180567985ae0"}, + "floki": {:hex, :floki, "0.36.1", "712b7f2ba19a4d5a47dfe3e74d81876c95bbcbee44fe551f0af3d2a388abb3da", [:mix], [], "hexpm", "21ba57abb8204bcc70c439b423fc0dd9f0286de67dc82773a14b0200ada0995f"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [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", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, @@ -22,23 +24,27 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "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.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_ownership": {:hex, :nimble_ownership, "0.3.0", "29514f8779b26f50f9c07109677c98c0cc0b8025e89f82964dafa9cf7d657ec0", [:mix], [], "hexpm", "76c605106bc1e60f5b028b20203a1e0c90b4350b08e4b8a33f68bb50dcb6e837"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "oban": {:hex, :oban, "2.17.6", "bac1dacd836edbf6a200ddd880db10faa2d39bb2e550ec6d19b3eb9c43852c2a", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "623f3554212e9a776e015156c47f076d66c7b74115ac47a7d3acba0294e65acb"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"}, - "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, - "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [: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]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, "quantum": {:hex, :quantum, "3.5.3", "ee38838a07761663468145f489ad93e16a79440bebd7c0f90dc1ec9850776d99", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "500fd3fa77dcd723ed9f766d4a175b684919ff7b6b8cfd9d7d0564d58eba8734"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, "telemetry_registry": {:hex, :telemetry_registry, "0.2.1", "fe648a691f2128e4279d993cd010994c67f282354dc061e697bf070d4b87b480", [:mix, :rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4221cefbcadd0b3e7076960339223742d973f1371bc20f3826af640257bc3690"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, } diff --git a/pages/setup-with-plug-and-phoenix.md b/pages/setup-with-plug-and-phoenix.md index a485e526..00271923 100644 --- a/pages/setup-with-plug-and-phoenix.md +++ b/pages/setup-with-plug-and-phoenix.md @@ -24,6 +24,20 @@ If you are using Phoenix: + plug Sentry.PlugContext ``` +If you're also using [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view), consider also setting up your LiveViews to use the `Sentry.LiveViewHook` hook: + +```elixir +defmodule MyAppWeb do + def live_view do + quote do + use Phoenix.LiveView + + on_mount Sentry.LiveViewHook + end + end +end +``` + ### Capturing User Feedback If you would like to capture user feedback as described [here](https://docs.sentry.io/platforms/elixir/enriching-events/user-feedback/), the `Sentry.get_last_event_id_and_source/0` function can be used to see if Sentry has sent an event within the current Plug process (and get the source of that event). `:plug` will be the source for events coming from `Sentry.PlugCapture`. The options described in the Sentry documentation linked above can be encoded into the response as well. diff --git a/test/event_test.exs b/test/event_test.exs index 7cd5a41e..7aa75c88 100644 --- a/test/event_test.exs +++ b/test/event_test.exs @@ -414,7 +414,7 @@ defmodule Sentry.EventTest do exception = RuntimeError.exception("error") event = Sentry.Event.transform_exception(exception, []) - assert ["asn1", "bypass", "certifi", "compiler" | _rest] = + assert ["asn1", "bypass" | _rest] = event.modules |> Map.keys() |> Enum.sort() diff --git a/test/plug_capture_test.exs b/test/plug_capture_test.exs index 2b3dcb69..226fc2cf 100644 --- a/test/plug_capture_test.exs +++ b/test/plug_capture_test.exs @@ -57,7 +57,9 @@ defmodule Sentry.PlugCaptureTest do end describe "with a Plug application" do - test "sends error to Sentry and uses Sentry.PlugContext to fill in context", %{bypass: bypass} do + test "sends error to Sentry and uses Sentry.PlugContext to fill in context", %{ + bypass: bypass + } do Bypass.expect(bypass, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -188,10 +190,8 @@ defmodule Sentry.PlugCaptureTest do end test "does not send Phoenix.Router.NoRouteError" do - assert_raise Phoenix.Router.NoRouteError, ~r"no route found for GET /not_found", fn -> - conn(:get, "/not_found") - |> call_phoenix_endpoint() - end + conn(:get, "/not_found") + |> call_phoenix_endpoint() end test "scrubs Phoenix.ActionClauseError", %{bypass: bypass} do @@ -213,7 +213,9 @@ defmodule Sentry.PlugCaptureTest do assert_receive {^ref, sentry_body} event = decode_event_from_envelope!(sentry_body) - assert event["culprit"] == "Sentry.PlugCaptureTest.PhoenixController.action_clause_error/2" + assert event["culprit"] == + "Sentry.PlugCaptureTest.PhoenixController.action_clause_error/2" + assert [exception] = event["exception"] assert exception["type"] == "Phoenix.ActionClauseError" assert exception["value"] =~ ~s(params: %{"password" => "*********"}) diff --git a/test/sentry/integrations/oban/cron_test.exs b/test/sentry/integrations/oban/cron_test.exs index 64a0c636..67f91345 100644 --- a/test/sentry/integrations/oban/cron_test.exs +++ b/test/sentry/integrations/oban/cron_test.exs @@ -1,158 +1,112 @@ -# TODO: Oban requires Elixir 1.13+, remove this once we depend on that too. -if Version.match?(System.version(), "~> 1.13") do - defmodule Sentry.Integrations.Oban.CronTest do - use Sentry.Case, async: false +defmodule Sentry.Integrations.Oban.CronTest do + use Sentry.Case, async: false - import Sentry.TestHelpers + import Sentry.TestHelpers - setup_all do - Sentry.Integrations.Oban.Cron.attach_telemetry_handler() - end + setup_all do + Sentry.Integrations.Oban.Cron.attach_telemetry_handler() + end - setup do - bypass = Bypass.open() + setup do + bypass = Bypass.open() - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - dedup_events: false, - environment_name: "test" - ) + put_test_config( + dsn: "http://public:secret@localhost:#{bypass.port}/1", + dedup_events: false, + environment_name: "test" + ) - %{bypass: bypass} - end + %{bypass: bypass} + end - for event_type <- [:start, :stop, :exception] do - test "ignores #{event_type} events without a cron meta", %{bypass: bypass} do - Bypass.down(bypass) - :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{job: %Oban.Job{}}) - end + for event_type <- [:start, :stop, :exception] do + test "ignores #{event_type} events without a cron meta", %{bypass: bypass} do + Bypass.down(bypass) + :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{job: %Oban.Job{}}) + end - test "ignores #{event_type} events without a cron_expr meta", %{bypass: bypass} do - Bypass.down(bypass) + test "ignores #{event_type} events without a cron_expr meta", %{bypass: bypass} do + Bypass.down(bypass) - :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ - job: %Oban.Job{meta: %{"cron" => true}} - }) - end + :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ + job: %Oban.Job{meta: %{"cron" => true}} + }) + end - test "ignores #{event_type} events with a cron expr of @reboot", %{bypass: bypass} do - Bypass.down(bypass) + test "ignores #{event_type} events with a cron expr of @reboot", %{bypass: bypass} do + Bypass.down(bypass) - :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ - job: %Oban.Job{meta: %{"cron" => true, "cron_expr" => "@reboot"}} - }) - end + :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ + job: %Oban.Job{meta: %{"cron" => true, "cron_expr" => "@reboot"}} + }) + end - test "ignores #{event_type} events with a cron expr that is not a string", %{bypass: bypass} do - Bypass.down(bypass) + test "ignores #{event_type} events with a cron expr that is not a string", %{bypass: bypass} do + Bypass.down(bypass) - :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ - job: %Oban.Job{meta: %{"cron" => true, "cron_expr" => 123}} - }) - end + :telemetry.execute([:oban, :job, unquote(event_type)], %{}, %{ + job: %Oban.Job{meta: %{"cron" => true, "cron_expr" => 123}} + }) end + end - test "captures start events with monitor config", %{bypass: bypass} do - test_pid = self() - ref = make_ref() + test "captures start events with monitor config", %{bypass: bypass} do + test_pid = self() + ref = make_ref() - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{headers, check_in_body}] = decode_envelope!(body) + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, check_in_body}] = decode_envelope!(body) - assert headers["type"] == "check_in" + assert headers["type"] == "check_in" - assert check_in_body["check_in_id"] == "oban-123" - assert check_in_body["status"] == "in_progress" - assert check_in_body["monitor_slug"] == "sentry-my-worker" - assert check_in_body["duration"] == nil - assert check_in_body["environment"] == "test" + assert check_in_body["check_in_id"] == "oban-123" + assert check_in_body["status"] == "in_progress" + assert check_in_body["monitor_slug"] == "sentry-my-worker" + assert check_in_body["duration"] == nil + assert check_in_body["environment"] == "test" - assert check_in_body["monitor_config"] == %{ - "schedule" => %{ - "type" => "interval", - "value" => 1, - "unit" => "day" - } + assert check_in_body["monitor_config"] == %{ + "schedule" => %{ + "type" => "interval", + "value" => 1, + "unit" => "day" } + } - send(test_pid, {ref, :done}) - - Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) - end) - - :telemetry.execute([:oban, :job, :start], %{}, %{ - job: %Oban.Job{ - worker: "Sentry.MyWorker", - id: 123, - meta: %{"cron" => true, "cron_expr" => "@daily"} - } - }) - - assert_receive {^ref, :done}, 1000 - end - - for {oban_state, expected_status} <- [ - success: "ok", - failure: "error", - cancelled: "ok", - discard: "ok", - snoozed: "ok" - ], - {frequency, expected_unit} <- [ - {"@hourly", "hour"}, - {"@daily", "day"}, - {"@weekly", "week"}, - {"@monthly", "month"}, - {"@yearly", "year"}, - {"@annually", "year"} - ] do - test "captures stop events with monitor config and state of #{inspect(oban_state)} and frequency of #{frequency}", - %{bypass: bypass} do - test_pid = self() - ref = make_ref() - - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{headers, check_in_body}] = decode_envelope!(body) - - assert headers["type"] == "check_in" - - assert check_in_body["check_in_id"] == "oban-942" - assert check_in_body["status"] == unquote(expected_status) - assert check_in_body["monitor_slug"] == "sentry-my-worker" - assert check_in_body["duration"] == 12.099 - assert check_in_body["environment"] == "test" - - assert check_in_body["monitor_config"] == %{ - "schedule" => %{ - "type" => "interval", - "value" => 1, - "unit" => unquote(expected_unit) - } - } - - send(test_pid, {ref, :done}) + send(test_pid, {ref, :done}) - Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) - end) + Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) + end) - duration = System.convert_time_unit(12_099, :millisecond, :native) + :telemetry.execute([:oban, :job, :start], %{}, %{ + job: %Oban.Job{ + worker: "Sentry.MyWorker", + id: 123, + meta: %{"cron" => true, "cron_expr" => "@daily"} + } + }) - :telemetry.execute([:oban, :job, :stop], %{duration: duration}, %{ - state: unquote(oban_state), - job: %Oban.Job{ - worker: "Sentry.MyWorker", - id: 942, - meta: %{"cron" => true, "cron_expr" => unquote(frequency)} - } - }) - - assert_receive {^ref, :done}, 1000 - end - end + assert_receive {^ref, :done}, 1000 + end - test "captures exception events with monitor config", %{bypass: bypass} do + for {oban_state, expected_status} <- [ + success: "ok", + failure: "error", + cancelled: "ok", + discard: "ok", + snoozed: "ok" + ], + {frequency, expected_unit} <- [ + {"@hourly", "hour"}, + {"@daily", "day"}, + {"@weekly", "week"}, + {"@monthly", "month"}, + {"@yearly", "year"}, + {"@annually", "year"} + ] do + test "captures stop events with monitor config and state of #{inspect(oban_state)} and frequency of #{frequency}", + %{bypass: bypass} do test_pid = self() ref = make_ref() @@ -163,15 +117,16 @@ if Version.match?(System.version(), "~> 1.13") do assert headers["type"] == "check_in" assert check_in_body["check_in_id"] == "oban-942" - assert check_in_body["status"] == "error" + assert check_in_body["status"] == unquote(expected_status) assert check_in_body["monitor_slug"] == "sentry-my-worker" assert check_in_body["duration"] == 12.099 assert check_in_body["environment"] == "test" assert check_in_body["monitor_config"] == %{ "schedule" => %{ - "type" => "crontab", - "value" => "* 1 1 1 1" + "type" => "interval", + "value" => 1, + "unit" => unquote(expected_unit) } } @@ -182,16 +137,58 @@ if Version.match?(System.version(), "~> 1.13") do duration = System.convert_time_unit(12_099, :millisecond, :native) - :telemetry.execute([:oban, :job, :exception], %{duration: duration}, %{ - state: :success, + :telemetry.execute([:oban, :job, :stop], %{duration: duration}, %{ + state: unquote(oban_state), job: %Oban.Job{ worker: "Sentry.MyWorker", id: 942, - meta: %{"cron" => true, "cron_expr" => "* 1 1 1 1"} + meta: %{"cron" => true, "cron_expr" => unquote(frequency)} } }) assert_receive {^ref, :done}, 1000 end end + + test "captures exception events with monitor config", %{bypass: bypass} do + test_pid = self() + ref = make_ref() + + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, check_in_body}] = decode_envelope!(body) + + assert headers["type"] == "check_in" + + assert check_in_body["check_in_id"] == "oban-942" + assert check_in_body["status"] == "error" + assert check_in_body["monitor_slug"] == "sentry-my-worker" + assert check_in_body["duration"] == 12.099 + assert check_in_body["environment"] == "test" + + assert check_in_body["monitor_config"] == %{ + "schedule" => %{ + "type" => "crontab", + "value" => "* 1 1 1 1" + } + } + + send(test_pid, {ref, :done}) + + Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) + end) + + duration = System.convert_time_unit(12_099, :millisecond, :native) + + :telemetry.execute([:oban, :job, :exception], %{duration: duration}, %{ + state: :success, + job: %Oban.Job{ + worker: "Sentry.MyWorker", + id: 942, + meta: %{"cron" => true, "cron_expr" => "* 1 1 1 1"} + } + }) + + assert_receive {^ref, :done}, 1000 + end end diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs index 50013670..a4f770a6 100644 --- a/test/sentry/integrations/oban/error_reporter_test.exs +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -1,56 +1,53 @@ -# TODO: Oban requires Elixir 1.13+, remove this once we depend on that too. -if Version.match?(System.version(), "~> 1.13") do - defmodule Sentry.Integrations.Oban.ErrorReporterTest do - use ExUnit.Case, async: true +defmodule Sentry.Integrations.Oban.ErrorReporterTest do + use ExUnit.Case, async: true - alias Sentry.Integrations.Oban.ErrorReporter + alias Sentry.Integrations.Oban.ErrorReporter - defmodule MyWorker do - use Oban.Worker + defmodule MyWorker do + use Oban.Worker - @impl Oban.Worker - def perform(%Oban.Job{}), do: :ok - end + @impl Oban.Worker + def perform(%Oban.Job{}), do: :ok + end - describe "handle_event/4" do - test "reports the correct error to Sentry" do - # Any worker is okay here, this is just an easier way to get a job struct. - job = - %{"id" => "123", "entity" => "user", "type" => "delete"} - |> MyWorker.new() - |> Ecto.Changeset.apply_action!(:validate) - |> Map.replace!(:unsaved_error, %{ - reason: %RuntimeError{message: "oops"}, - kind: :error, - stacktrace: [] - }) - - Sentry.Test.start_collecting() - - assert :ok = - ErrorReporter.handle_event( - [:oban, :job, :exception], - %{}, - %{job: job}, - :no_config - ) - - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} - assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception - - assert exception.type == "RuntimeError" - assert exception.value == "oops" - assert exception.mechanism.handled == true - assert stacktrace.module == MyWorker - - assert stacktrace.function == - "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1" - - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - end + describe "handle_event/4" do + test "reports the correct error to Sentry" do + # Any worker is okay here, this is just an easier way to get a job struct. + job = + %{"id" => "123", "entity" => "user", "type" => "delete"} + |> MyWorker.new() + |> Ecto.Changeset.apply_action!(:validate) + |> Map.replace!(:unsaved_error, %{ + reason: %RuntimeError{message: "oops"}, + kind: :error, + stacktrace: [] + }) + + Sentry.Test.start_collecting() + + assert :ok = + ErrorReporter.handle_event( + [:oban, :job, :exception], + %{}, + %{job: job}, + :no_config + ) + + assert [event] = Sentry.Test.pop_sentry_reports() + assert event.original_exception == %RuntimeError{message: "oops"} + assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception + + assert exception.type == "RuntimeError" + assert exception.value == "oops" + assert exception.mechanism.handled == true + assert stacktrace.module == MyWorker + + assert stacktrace.function == + "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1" + + assert event.tags.oban_queue == "default" + assert event.tags.oban_state == "available" + assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" end end end diff --git a/test/sentry/integrations/quantum/cron_test.exs b/test/sentry/integrations/quantum/cron_test.exs index faee1148..31c5ead2 100644 --- a/test/sentry/integrations/quantum/cron_test.exs +++ b/test/sentry/integrations/quantum/cron_test.exs @@ -1,157 +1,187 @@ -# TODO: Quantum requires Elixir 1.12+, remove this once we depend on that too. -if Version.match?(System.version(), "~> 1.12") do - defmodule Sentry.Integrations.Quantum.CronTest do - use Sentry.Case, async: false +defmodule Sentry.Integrations.Quantum.CronTest do + use Sentry.Case, async: false - import Sentry.TestHelpers + import Sentry.TestHelpers - defmodule Scheduler do - use Quantum, otp_app: :sentry - end + defmodule Scheduler do + use Quantum, otp_app: :sentry + end - setup_all do - Sentry.Integrations.Quantum.Cron.attach_telemetry_handler() - end + setup_all do + Sentry.Integrations.Quantum.Cron.attach_telemetry_handler() + end - setup do - bypass = Bypass.open() + setup do + bypass = Bypass.open() - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - dedup_events: false, - environment_name: "test" - ) + put_test_config( + dsn: "http://public:secret@localhost:#{bypass.port}/1", + dedup_events: false, + environment_name: "test" + ) - %{bypass: bypass} + %{bypass: bypass} + end + + for event_type <- [:start, :stop, :exception] do + test "ignores #{event_type} events without a cron meta", %{bypass: bypass} do + Bypass.down(bypass) + + :telemetry.execute([:quantum, :job, unquote(event_type)], %{}, %{ + job: Scheduler.new_job(name: :test_job) + }) end - for event_type <- [:start, :stop, :exception] do - test "ignores #{event_type} events without a cron meta", %{bypass: bypass} do - Bypass.down(bypass) - - :telemetry.execute([:quantum, :job, unquote(event_type)], %{}, %{ - job: Scheduler.new_job(name: :test_job) - }) - end - - test "ignores #{event_type} events with a cron expr of @reboot", %{bypass: bypass} do - Bypass.down(bypass) - - :telemetry.execute([:quantum, :job, unquote(event_type)], %{}, %{ - job: - Scheduler.new_job( - name: :reboot_job, - schedule: Crontab.CronExpression.Parser.parse!("@reboot") - ) - }) - end + test "ignores #{event_type} events with a cron expr of @reboot", %{bypass: bypass} do + Bypass.down(bypass) + + :telemetry.execute([:quantum, :job, unquote(event_type)], %{}, %{ + job: + Scheduler.new_job( + name: :reboot_job, + schedule: Crontab.CronExpression.Parser.parse!("@reboot") + ) + }) end + end - test "captures start events with monitor config", %{bypass: bypass} do - test_pid = self() - ref = make_ref() + test "captures start events with monitor config", %{bypass: bypass} do + test_pid = self() + ref = make_ref() - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{headers, check_in_body}] = decode_envelope!(body) + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, check_in_body}] = decode_envelope!(body) - assert headers["type"] == "check_in" + assert headers["type"] == "check_in" - assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" - assert check_in_body["status"] == "in_progress" - assert check_in_body["monitor_slug"] == "quantum-test-job" - assert check_in_body["duration"] == nil - assert check_in_body["environment"] == "test" + assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" + assert check_in_body["status"] == "in_progress" + assert check_in_body["monitor_slug"] == "quantum-test-job" + assert check_in_body["duration"] == nil + assert check_in_body["environment"] == "test" - assert check_in_body["monitor_config"] == %{ - "schedule" => %{ - "type" => "crontab", - "value" => "0 0 * * * *" - } + assert check_in_body["monitor_config"] == %{ + "schedule" => %{ + "type" => "crontab", + "value" => "0 0 * * * *" } + } - send(test_pid, {ref, :done}) + send(test_pid, {ref, :done}) - Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) - end) + Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) + end) - :telemetry.execute([:quantum, :job, :start], %{}, %{ - job: - Scheduler.new_job( - name: :test_job, - schedule: Crontab.CronExpression.Parser.parse!("@daily") - ), - telemetry_span_context: ref - }) + :telemetry.execute([:quantum, :job, :start], %{}, %{ + job: + Scheduler.new_job( + name: :test_job, + schedule: Crontab.CronExpression.Parser.parse!("@daily") + ), + telemetry_span_context: ref + }) - assert_receive {^ref, :done}, 1000 - end + assert_receive {^ref, :done}, 1000 + end - test "captures exception events with monitor config", %{bypass: bypass} do - test_pid = self() - ref = make_ref() + test "captures exception events with monitor config", %{bypass: bypass} do + test_pid = self() + ref = make_ref() - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{headers, check_in_body}] = decode_envelope!(body) + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, check_in_body}] = decode_envelope!(body) - assert headers["type"] == "check_in" + assert headers["type"] == "check_in" - assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" - assert check_in_body["status"] == "error" - assert check_in_body["monitor_slug"] == "quantum-test-job" - assert check_in_body["duration"] == 12.099 - assert check_in_body["environment"] == "test" + assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" + assert check_in_body["status"] == "error" + assert check_in_body["monitor_slug"] == "quantum-test-job" + assert check_in_body["duration"] == 12.099 + assert check_in_body["environment"] == "test" - assert check_in_body["monitor_config"] == %{ - "schedule" => %{ - "type" => "crontab", - "value" => "0 0 * * * *" - } + assert check_in_body["monitor_config"] == %{ + "schedule" => %{ + "type" => "crontab", + "value" => "0 0 * * * *" } + } - send(test_pid, {ref, :done}) + send(test_pid, {ref, :done}) - Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) - end) + Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) + end) - duration = System.convert_time_unit(12_099, :millisecond, :native) + duration = System.convert_time_unit(12_099, :millisecond, :native) - :telemetry.execute([:quantum, :job, :exception], %{duration: duration}, %{ - job: - Scheduler.new_job( - name: :test_job, - schedule: Crontab.CronExpression.Parser.parse!("@daily") - ), - telemetry_span_context: ref - }) + :telemetry.execute([:quantum, :job, :exception], %{duration: duration}, %{ + job: + Scheduler.new_job( + name: :test_job, + schedule: Crontab.CronExpression.Parser.parse!("@daily") + ), + telemetry_span_context: ref + }) - assert_receive {^ref, :done}, 1000 - end + assert_receive {^ref, :done}, 1000 + end - test "captures stop events with monitor config", %{bypass: bypass} do - test_pid = self() - ref = make_ref() + test "captures stop events with monitor config", %{bypass: bypass} do + test_pid = self() + ref = make_ref() - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{headers, check_in_body}] = decode_envelope!(body) + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{headers, check_in_body}] = decode_envelope!(body) - assert headers["type"] == "check_in" + assert headers["type"] == "check_in" - assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" - assert check_in_body["status"] == "ok" - assert check_in_body["monitor_slug"] == "quantum-test-job" - assert check_in_body["duration"] == 12.099 - assert check_in_body["environment"] == "test" + assert check_in_body["check_in_id"] == "quantum-#{:erlang.phash2(ref)}" + assert check_in_body["status"] == "ok" + assert check_in_body["monitor_slug"] == "quantum-test-job" + assert check_in_body["duration"] == 12.099 + assert check_in_body["environment"] == "test" - assert check_in_body["monitor_config"] == %{ - "schedule" => %{ - "type" => "crontab", - "value" => "0 0 * * * *" - } + assert check_in_body["monitor_config"] == %{ + "schedule" => %{ + "type" => "crontab", + "value" => "0 0 * * * *" } + } + + send(test_pid, {ref, :done}) + + Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) + end) + + duration = System.convert_time_unit(12_099, :millisecond, :native) + + :telemetry.execute([:quantum, :job, :stop], %{duration: duration}, %{ + job: + Scheduler.new_job( + name: :test_job, + schedule: Crontab.CronExpression.Parser.parse!("@daily") + ), + telemetry_span_context: ref + }) + + assert_receive {^ref, :done}, 1000 + end + for {job_name, expected_slug} <- [ + {:some_job, "quantum-some-job"}, + {MyApp.MyJob, "quantum-my-app-my-job"} + ] do + test "works for a job named #{inspect(job_name)}", %{bypass: bypass} do + test_pid = self() + ref = make_ref() + + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + assert [{_headers, check_in_body}] = decode_envelope!(body) + + assert check_in_body["monitor_slug"] == unquote(expected_slug) send(test_pid, {ref, :done}) Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) @@ -162,7 +192,7 @@ if Version.match?(System.version(), "~> 1.12") do :telemetry.execute([:quantum, :job, :stop], %{duration: duration}, %{ job: Scheduler.new_job( - name: :test_job, + name: unquote(job_name), schedule: Crontab.CronExpression.Parser.parse!("@daily") ), telemetry_span_context: ref @@ -170,38 +200,5 @@ if Version.match?(System.version(), "~> 1.12") do assert_receive {^ref, :done}, 1000 end - - for {job_name, expected_slug} <- [ - {:some_job, "quantum-some-job"}, - {MyApp.MyJob, "quantum-my-app-my-job"} - ] do - test "works for a job named #{inspect(job_name)}", %{bypass: bypass} do - test_pid = self() - ref = make_ref() - - Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - assert [{_headers, check_in_body}] = decode_envelope!(body) - - assert check_in_body["monitor_slug"] == unquote(expected_slug) - send(test_pid, {ref, :done}) - - Plug.Conn.send_resp(conn, 200, ~s<{"id": "1923"}>) - end) - - duration = System.convert_time_unit(12_099, :millisecond, :native) - - :telemetry.execute([:quantum, :job, :stop], %{duration: duration}, %{ - job: - Scheduler.new_job( - name: unquote(job_name), - schedule: Crontab.CronExpression.Parser.parse!("@daily") - ), - telemetry_span_context: ref - }) - - assert_receive {^ref, :done}, 1000 - end - end end end diff --git a/test/sentry/live_view_hook_test.exs b/test/sentry/live_view_hook_test.exs new file mode 100644 index 00000000..1bf44ee8 --- /dev/null +++ b/test/sentry/live_view_hook_test.exs @@ -0,0 +1,126 @@ +defmodule SentryTest.Live do + use Phoenix.LiveView + + on_mount Sentry.LiveViewHook + + def render(assigns) do + ~H""" +

Testing Sentry hooks

+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_event("refresh", _params, socket) do + {:noreply, socket} + end + + def handle_info(:test_message, socket) do + {:noreply, socket} + end +end + +defmodule SentryTest.Router do + use Phoenix.Router + import Phoenix.LiveView.Router + + scope "/" do + live "/hook_test", SentryTest.Live + end +end + +defmodule SentryTest.Endpoint do + use Phoenix.Endpoint, otp_app: :sentry + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [:peer_data, :uri, :user_agent]] + + plug SentryTest.Router +end + +defmodule Sentry.LiveViewHookTest do + use Sentry.Case, async: true + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + @endpoint SentryTest.Endpoint + + setup_all do + Application.put_env(:sentry, SentryTest.Endpoint, + secret_key_base: "TMnue44VMTf1VmyD6SYKR30cqKpHluHOFZGXcVkC33hKVVKTVZ1HBQLLLLLLLLLL", + live_view: [signing_salt: "F8ftIAbYdeTzhwgl"] + ) + + pid = start_supervised!(SentryTest.Endpoint) + Process.link(pid) + :ok + end + + setup do + %{conn: build_conn()} + end + + test "attaches the right context", %{conn: conn} do + conn = + conn + |> Plug.Conn.put_req_header("user-agent", "sentry-testing 1.0") + + {:ok, view, html} = live(conn, "/hook_test") + assert html =~ "

Testing Sentry hooks

" + + context1 = get_sentry_context(view) + + assert "phx-" <> _ = context1.extra.socket_id + assert context1.request.url == "http://www.example.com/hook_test" + assert context1.extra.user_agent == "sentry-testing 1.0" + + assert [ + %{category: "web.live_view.params"} = params_breadcrumb, + %{category: "web.live_view.mount"} = mount_breadcrumb + ] = context1.breadcrumbs + + assert mount_breadcrumb.message == "Mounted live view" + assert mount_breadcrumb.data == %{} + + assert params_breadcrumb.message == "http://www.example.com/hook_test" + assert params_breadcrumb.data == %{params: %{}, uri: "http://www.example.com/hook_test"} + + # Send an event and test the new breadcrumb. + + assert render_hook(view, :refresh, %{force: true}) =~ "Testing Sentry hooks" + + context2 = get_sentry_context(view) + assert Map.take(context1, [:extra, :request]) == Map.take(context2, [:extra, :request]) + assert [event_breadcrumb, ^params_breadcrumb, ^mount_breadcrumb] = context2.breadcrumbs + assert event_breadcrumb.category == "web.live_view.event" + assert event_breadcrumb.message == ~s("refresh") + assert event_breadcrumb.data == %{params: %{"force" => true}, event: "refresh"} + + # Send a message and test the new breadcrumb. + send(view.pid, :test_message) + assert render(view) =~ "Testing Sentry hooks" + + context3 = get_sentry_context(view) + assert Map.take(context1, [:extra, :request]) == Map.take(context3, [:extra, :request]) + + assert [info_breadcrumb, ^event_breadcrumb, ^params_breadcrumb, ^mount_breadcrumb] = + context3.breadcrumbs + + assert info_breadcrumb.category == "web.live_view.info" + assert info_breadcrumb.message == ~s(:test_message) + end + + defp get_sentry_context(view) do + {:dictionary, pdict} = Process.info(view.pid, :dictionary) + + assert {:ok, sentry_context} = + pdict + |> Keyword.fetch!(:"$logger_metadata$") + |> Map.fetch(Sentry.Context.__logger_metadata_key__()) + + sentry_context + end +end diff --git a/test/support/test_error_view.ex b/test/support/test_error_view.ex index 7c84867c..29dcd7e4 100644 --- a/test/support/test_error_view.ex +++ b/test/support/test_error_view.ex @@ -1,5 +1,7 @@ defmodule Sentry.ErrorView do - import Phoenix.HTML, only: [sigil_E: 2, raw: 1] + use Phoenix.Component + + import Phoenix.HTML, only: [raw: 1] def render(_, _) do case Sentry.get_last_event_id_and_source() do @@ -8,11 +10,13 @@ defmodule Sentry.ErrorView do %{title: "Testing", eventId: event_id} |> Jason.encode!() - ~E""" + assigns = %{opts: opts} + + ~H""" """