diff --git a/README.md b/README.md index a9493cf9..2cae5987 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This is the official Sentry SDK for [Sentry]. To use Sentry in your project, add it as a dependency in your `mix.exs` file. -Sentry does not install a JSON library nor an HTTP client by itself. Sentry will default to the [built-in `JSON`](https://hexdocs.pm/elixir/JSON.html) for JSON and [Hackney] for HTTP requests, but can be configured to use other ones. To use the default ones, do: +Sentry does not install a JSON library nor an HTTP client by itself. Sentry will default to the [built-in `JSON`](https://hexdocs.pm/elixir/JSON.html) for JSON and [Finch] for HTTP requests, but can be configured to use other ones. To use the default ones, do: ```elixir defp deps do @@ -28,7 +28,8 @@ defp deps do # ... {:sentry, "~> 10.8"}, - {:hackney, "~> 1.20"} + {:jason, "~> 1.4"}, + {:finch, "~> 0.17.0"} ] end ``` @@ -203,7 +204,7 @@ Licensed under the MIT license, see [`LICENSE`](./LICENSE). [Sentry]: http://sentry.io/ [Jason]: https://github.com/michalmuskala/jason -[Hackney]: https://github.com/benoitc/hackney +[Finch]: https://github.com/sneako/finch [Bypass]: https://github.com/PSPDFKit-labs/bypass [docs]: https://hexdocs.pm/sentry/readme.html [logger-handlers]: https://www.erlang.org/doc/apps/kernel/logger_chapter#handlers diff --git a/config/config.exs b/config/config.exs index 324ae9f7..a7e0a5b6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -6,7 +6,7 @@ if config_env() == :test do tags: %{}, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], - hackney_opts: [recv_timeout: 50, pool: :sentry_pool], + finch_request_opts: [receive_timeout: 50], send_result: :sync, send_max_attempts: 1, dedup_events: false, @@ -22,3 +22,6 @@ if config_env() == :test do end config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) + +config :sentry, + client: Sentry.FinchClient diff --git a/lib/mix/tasks/sentry.send_test_event.ex b/lib/mix/tasks/sentry.send_test_event.ex index 7a740733..32765fcf 100644 --- a/lib/mix/tasks/sentry.send_test_event.ex +++ b/lib/mix/tasks/sentry.send_test_event.ex @@ -67,7 +67,7 @@ defmodule Mix.Tasks.Sentry.SendTestEvent do end Mix.shell().info("current environment_name: #{inspect(to_string(Config.environment_name()))}") - Mix.shell().info("hackney_opts: #{inspect(Config.hackney_opts())}\n") + Mix.shell().info("Finch pool options: #{inspect(Config.finch_pool_opts(), pretty: true)}\n") end defp send_event(opts) do diff --git a/lib/sentry/application.ex b/lib/sentry/application.ex index 0d8d6058..701352cf 100644 --- a/lib/sentry/application.ex +++ b/lib/sentry/application.ex @@ -18,7 +18,8 @@ defmodule Sentry.Application do http_client = Keyword.fetch!(config, :client) maybe_http_client_spec = - if Code.ensure_loaded?(http_client) and function_exported?(http_client, :child_spec, 0) do + if {:module, http_client} == Code.ensure_loaded(http_client) and + function_exported?(http_client, :child_spec, 0) do [http_client.child_spec()] else [] diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 9a41069e..6358490f 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -352,11 +352,12 @@ defmodule Sentry.Config do client: [ type: :atom, type_doc: "`t:module/0`", - default: Sentry.HackneyClient, + default: Sentry.FinchClient, doc: """ A module that implements the `Sentry.HTTPClient` - behaviour. Defaults to `Sentry.HackneyClient`, which uses - [hackney](https://github.com/benoitc/hackney) as the HTTP client. + behaviour. Defaults to `Sentry.FinchClient`, which uses + [Finch](https://github.com/sneako/finch) as the HTTP client. + *The default changed from Hackney to Finch in v10.11.0*. """ ], send_max_attempts: [ @@ -366,32 +367,67 @@ defmodule Sentry.Config do The maximum number of attempts to send an event to Sentry. """ ], - hackney_opts: [ + finch_pool_opts: [ type: :keyword_list, - default: [pool: :sentry_pool], + default: [size: 50], doc: """ - Options to be passed to `hackney`. Only - applied if `:client` is set to `Sentry.HackneyClient`. + Pool options to be passed to `Finch.start_link/1`. These options control + the connection pool behavior. Only applied if `:client` is set to + `Sentry.FinchClient`. See [Finch documentation](https://hexdocs.pm/finch/0.17.0/Finch.html#start_link/1) + for available options. """ ], - hackney_pool_timeout: [ - type: :timeout, - default: 5000, + finch_request_opts: [ + type: :keyword_list, + default: [receive_timeout: 5000], doc: """ - The maximum time to wait for a - connection to become available. Only applied if `:client` is set to - `Sentry.HackneyClient`. + Request options to be passed to `Finch.request/4`. These options control + individual request behavior. Only applied if `:client` is set to + `Sentry.FinchClient`. See [Finch documentation](https://hexdocs.pm/finch/0.17.0/Finch.html#request/4) + for available options. """ ], - hackney_pool_max_connections: [ - type: :pos_integer, - default: 50, - doc: """ - The maximum number of - connections to keep in the pool. Only applied if `:client` is set to - `Sentry.HackneyClient`. - """ - ] + hackney_opts: + [ + type: :keyword_list, + default: [pool: :sentry_pool], + doc: """ + Options to be passed to `hackney`. Only + applied if `:client` is set to `Sentry.HackneyClient`. + """ + ] ++ + if(Mix.env() == :test, + do: [], + else: [deprecated: "Use Finch as the default HTTP client instead."] + ), + hackney_pool_timeout: + [ + type: :timeout, + default: 5000, + doc: """ + The maximum time to wait for a + connection to become available. Only applied if `:client` is set to + `Sentry.HackneyClient`. + """ + ] ++ + if(Mix.env() == :test, + do: [], + else: [deprecated: "Use Finch as the default HTTP client instead."] + ), + hackney_pool_max_connections: + [ + type: :pos_integer, + default: 50, + doc: """ + The maximum number of + connections to keep in the pool. Only applied if `:client` is set to + `Sentry.HackneyClient`. + """ + ] ++ + if(Mix.env() == :test, + do: [], + else: [deprecated: "Use Finch as the default HTTP client instead."] + ) ] source_code_context_opts_schema = [ @@ -664,6 +700,12 @@ defmodule Sentry.Config do @spec traces_sampler() :: traces_sampler_function() | nil def traces_sampler, do: get(:traces_sampler) + @spec finch_pool_opts() :: keyword() + def finch_pool_opts, do: fetch!(:finch_pool_opts) + + @spec finch_request_opts() :: keyword() + def finch_request_opts, do: fetch!(:finch_request_opts) + @spec hackney_opts() :: keyword() def hackney_opts, do: fetch!(:hackney_opts) diff --git a/lib/sentry/finch_client.ex b/lib/sentry/finch_client.ex new file mode 100644 index 00000000..c177373f --- /dev/null +++ b/lib/sentry/finch_client.ex @@ -0,0 +1,54 @@ +defmodule Sentry.FinchClient do + @behaviour Sentry.HTTPClient + + @moduledoc """ + The built-in HTTP client. + + This client implements the `Sentry.HTTPClient` behaviour. + It's based on the [Finch](https://github.com/sneako/finch) Elixir HTTP client, + which is an *optional dependency* of this library. If you wish to use another + HTTP client, you'll have to implement your own `Sentry.HTTPClient`. See the + documentation for `Sentry.HTTPClient` for more information. + Finch is built on top of [NimblePool](https://github.com/dashbitco/nimble_pool). If you need to set other pool configuration options, + see ["Pool Configuration Options"](https://hexdocs.pm/finch/Finch.html#start_link/1-pool-configuration-options) + in the Finch documentation for details on the possible map values. + [finch configuration options](https://hexdocs.pm/finch/Finch.html#start_link/1-pool-configuration-options) + """ + @moduledoc since: "10.11.0" + + @impl true + def child_spec do + if Code.ensure_loaded?(Finch) do + case Application.ensure_all_started(:finch) do + {:ok, _apps} -> :ok + {:error, reason} -> raise "failed to start the :finch application: #{inspect(reason)}" + end + + Finch.child_spec( + name: __MODULE__, + pools: %{ + :default => Sentry.Config.finch_pool_opts() + } + ) + else + raise """ + cannot start the :sentry application because the HTTP client is set to \ + Sentry.FinchClient (which is the default), but the :finch library is not loaded. \ + Add :finch to your dependencies to fix this. + """ + end + end + + @impl true + def post(url, headers, body) do + request = Finch.build(:post, url, headers, body) + + case Finch.request(request, __MODULE__, Sentry.Config.finch_request_opts()) do + {:ok, %{status: status, headers: headers, body: body}} -> + {:ok, status, headers, body} + + {:error, error} -> + {:error, error} + end + end +end diff --git a/lib/sentry/http_client.ex b/lib/sentry/http_client.ex index 69561de2..e238170f 100644 --- a/lib/sentry/http_client.ex +++ b/lib/sentry/http_client.ex @@ -2,7 +2,7 @@ defmodule Sentry.HTTPClient do @moduledoc """ A behaviour for HTTP clients that Sentry can use. - The default HTTP client is `Sentry.HackneyClient`. + The default HTTP client is `Sentry.FinchClient` since v10.11.0 (it was based on Hackney before that). To configure a different HTTP client, implement the `Sentry.HTTPClient` behaviour and change the `:client` configuration: diff --git a/mix.exs b/mix.exs index 85671501..2ec80286 100644 --- a/mix.exs +++ b/mix.exs @@ -53,7 +53,7 @@ defmodule Sentry.Mixfile do "Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook], Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler], "Data Structures": [Sentry.Attachment, Sentry.CheckIn, Sentry.ClientReport], - HTTP: [Sentry.HTTPClient, Sentry.HackneyClient], + HTTP: [Sentry.HTTPClient, Sentry.FinchClient, Sentry.HackneyClient], Interfaces: [~r/^Sentry\.Interfaces/], Testing: [Sentry.Test] ], @@ -67,7 +67,7 @@ defmodule Sentry.Mixfile do ], authors: ["Mitchell Henke", "Jason Stiebs", "Andrea Leopardi"] ], - xref: [exclude: [:hackney, :hackney_pool, Plug.Conn, :telemetry]], + xref: [exclude: [Finch, :hackney, :hackney_pool, Plug.Conn, :telemetry]], aliases: aliases() ] end @@ -100,6 +100,7 @@ defmodule Sentry.Mixfile do # Optional dependencies {:hackney, "~> 1.8", optional: true}, + {:finch, "~> 0.19.0"}, {:jason, "~> 1.1", optional: true}, {:phoenix, "~> 1.6", optional: true}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", optional: true}, diff --git a/test/logger_backend_test.exs b/test/logger_backend_test.exs index ab219bb7..42e8be23 100644 --- a/test/logger_backend_test.exs +++ b/test/logger_backend_test.exs @@ -136,7 +136,9 @@ defmodule Sentry.LoggerBackendTest do start_supervised!(Sentry.ExamplePlugApplication, restart: :temporary) - :hackney.get("http://127.0.0.1:8003/error_route", [], "", []) + Finch.build(:get, "http://127.0.0.1:8003/error_route", [], "", []) + |> Finch.request(Sentry.FinchClient) + assert_receive {^ref, _event}, 1000 assert_receive {^ref, _event}, 1000 after @@ -150,7 +152,9 @@ defmodule Sentry.LoggerBackendTest do start_supervised!({Sentry.ExamplePlugApplication, server: :bandit}, restart: :temporary) - :hackney.get("http://127.0.0.1:8003/error_route", [], "", []) + Finch.build(:get, "http://127.0.0.1:8003/error_route", [], "", []) + |> Finch.request(Sentry.FinchClient) + assert_receive {^ref, _event}, 1000 assert_receive {^ref, _event}, 1000 after diff --git a/test/mix/sentry.send_test_event_test.exs b/test/mix/sentry.send_test_event_test.exs index 5215bcc2..4c97159c 100644 --- a/test/mix/sentry.send_test_event_test.exs +++ b/test/mix/sentry.send_test_event_test.exs @@ -5,7 +5,7 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do import Sentry.TestHelpers test "prints if :dsn is not set" do - put_test_config(dsn: nil, hackney_opts: [], environment_name: "some_env") + put_test_config(dsn: nil, finch_pool_opts: [], environment_name: "some_env") output = capture_io(fn -> @@ -15,7 +15,7 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do assert output =~ """ Client configuration: current environment_name: "some_env" - hackney_opts: [] + Finch pool options: [] """ assert output =~ ~s(Event not sent because the :dsn option is not set) @@ -35,7 +35,7 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do put_test_config( dsn: "http://public:secret@localhost:#{bypass.port}/1", environment_name: "test", - hackney_opts: [] + finch_pool_opts: [] ) output = @@ -49,7 +49,7 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do public_key: public secret_key: secret current environment_name: "test" - hackney_opts: [] + Finch pool options: [] """ assert output =~ "Sending test event..." diff --git a/test/sentry/client_test.exs b/test/sentry/client_test.exs index 70e7a9bb..0730faf2 100644 --- a/test/sentry/client_test.exs +++ b/test/sentry/client_test.exs @@ -354,7 +354,7 @@ defmodule Sentry.ClientTest do {:error, %Sentry.ClientError{} = error} = Client.send_event(event, result: :sync, request_retries: []) - assert error.reason == {:request_failure, :econnrefused} + assert error.reason == {:request_failure, %Mint.TransportError{reason: :econnrefused}} end test "logs an error when unable to encode JSON" do diff --git a/test/sentry/logger_handler_test.exs b/test/sentry/logger_handler_test.exs index fa10058d..c107780f 100644 --- a/test/sentry/logger_handler_test.exs +++ b/test/sentry/logger_handler_test.exs @@ -90,7 +90,10 @@ defmodule Sentry.LoggerHandlerTest do test "TODO", %{sender_ref: ref} do start_supervised!(Sentry.ExamplePlugApplication, restart: :temporary) - :hackney.get("http://127.0.0.1:8003/error_route", [], "", []) + + Finch.build(:get, "http://127.0.0.1:8003/error_route", [], "", []) + |> Finch.request(Sentry.FinchClient) + assert_receive {^ref, event}, 1000 assert event.original_exception == %RuntimeError{message: "Error"} end @@ -100,7 +103,8 @@ defmodule Sentry.LoggerHandlerTest do %{sender_ref: ref} do start_supervised!(Sentry.ExamplePlugApplication, restart: :temporary) - :hackney.get("http://127.0.0.1:8003/error_route", [], "", []) + Finch.build(:get, "http://127.0.0.1:8003/error_route", [], "", []) + |> Finch.request(Sentry.FinchClient) assert_receive {^ref, event}, 1000 assert event.original_exception == %RuntimeError{message: "Error"} @@ -114,7 +118,8 @@ defmodule Sentry.LoggerHandlerTest do %{sender_ref: ref} do start_supervised!({Sentry.ExamplePlugApplication, server: :bandit}, restart: :temporary) - :hackney.get("http://127.0.0.1:8003/error_route", [], "", []) + Finch.build(:get, "http://127.0.0.1:8003/error_route", [], "", []) + |> Finch.request(Sentry.FinchClient) assert_receive {^ref, _event}, 1000 assert_receive {^ref, _event}, 1000 @@ -686,7 +691,7 @@ defmodule Sentry.LoggerHandlerTest do put_test_config( dsn: "http://public:secret@localhost:#{bypass.port}/1", dedup_events: false, - hackney_opts: [recv_timeout: 500, pool: :sentry_pool] + finch_request_opts: [receive_timeout: 500] ) Bypass.expect(bypass, fn conn -> diff --git a/test/sentry/transport_test.exs b/test/sentry/transport_test.exs index 3e9f12cc..bb4ce4bc 100644 --- a/test/sentry/transport_test.exs +++ b/test/sentry/transport_test.exs @@ -4,7 +4,7 @@ defmodule Sentry.TransportTest do import Sentry.TestHelpers import ExUnit.CaptureLog - alias Sentry.{ClientError, Envelope, Event, HackneyClient, Transport} + alias Sentry.{ClientError, Envelope, Event, FinchClient, Transport} describe "encode_and_post_envelope/2" do setup do @@ -16,14 +16,13 @@ defmodule Sentry.TransportTest do test "sends a POST request with the right headers and payload", %{bypass: bypass} do envelope = Envelope.from_event(Event.create_event(message: "Hello 1")) - Bypass.expect_once(bypass, fn conn -> + Bypass.expect(bypass, fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) assert conn.method == "POST" assert conn.request_path == "/api/1/envelope/" assert ["sentry-elixir/" <> _] = Plug.Conn.get_req_header(conn, "user-agent") - assert ["application/octet-stream"] = Plug.Conn.get_req_header(conn, "content-type") assert [sentry_auth_header] = Plug.Conn.get_req_header(conn, "x-sentry-auth") assert sentry_auth_header =~ @@ -34,7 +33,7 @@ defmodule Sentry.TransportTest do Plug.Conn.resp(conn, 200, ~s<{"id":"123"}>) end) - assert {:ok, "123"} = Transport.encode_and_post_envelope(envelope, HackneyClient) + assert {:ok, "123"} = Transport.encode_and_post_envelope(envelope, FinchClient) end test "returns an error if the HTTP client returns a badly-typed response" do @@ -70,10 +69,12 @@ defmodule Sentry.TransportTest do Bypass.down(bypass) - assert {:request_failure, :econnrefused} = - error(fn -> - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = []) - end) + assert {:error, + %Sentry.ClientError{ + reason: {:request_failure, %Mint.TransportError{reason: :econnrefused}}, + http_response: nil + }} = + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) end test "returns an error if the response from Sentry is not 200", %{bypass: bypass} do @@ -86,7 +87,7 @@ defmodule Sentry.TransportTest do end) {:error, %ClientError{} = error} = - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = []) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) assert error.reason == :server_error assert {400, headers, "{}"} = error.http_response @@ -172,7 +173,7 @@ defmodule Sentry.TransportTest do assert {:error, %RuntimeError{message: "I'm a really bad JSON library"}, _stacktrace} = error(fn -> - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = []) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) end) after :code.delete(CrashingJSONLibrary) @@ -192,7 +193,7 @@ defmodule Sentry.TransportTest do assert {:request_failure, error} = error(fn -> - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = [0]) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = [0]) end) if Version.match?(System.version(), "~> 1.18") do @@ -225,7 +226,7 @@ defmodule Sentry.TransportTest do end) assert {:ok, "123"} = - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = [10, 25]) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = [10, 25]) assert System.system_time(:millisecond) - start_time >= 35 @@ -249,12 +250,12 @@ defmodule Sentry.TransportTest do assert :too_many_retries = error(fn -> - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = []) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) end) log = capture_log(fn -> - Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = []) + Transport.encode_and_post_envelope(envelope, FinchClient, _retries = []) end) assert log =~ "[warning]" diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 1829f0fe..fa9e2038 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -50,9 +50,12 @@ defmodule SentryTest do test "errors when taking too long to receive response", %{bypass: bypass} do Bypass.expect(bypass, fn _conn -> Process.sleep(:infinity) end) - put_test_config(hackney_opts: [recv_timeout: 50]) + put_test_config(finch_request_opts: [receive_timeout: 50]) - assert {:error, %Sentry.ClientError{reason: {:request_failure, :timeout}}} = + assert {:error, + %Sentry.ClientError{ + reason: {:request_failure, %Mint.TransportError{reason: :timeout}} + }} = Sentry.capture_message("error", request_retries: [], result: :sync) Bypass.pass(bypass) diff --git a/test_integrations/phoenix_app/mix.exs b/test_integrations/phoenix_app/mix.exs index d30a5de8..a8c3b021 100644 --- a/test_integrations/phoenix_app/mix.exs +++ b/test_integrations/phoenix_app/mix.exs @@ -56,7 +56,7 @@ defmodule PhoenixApp.MixProject do {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, {:swoosh, "~> 1.5"}, - {:finch, "~> 0.13"}, + {:finch, "~> 0.13", override: true}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.20"},