diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index ac700b22..53745216 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,3 +1,4 @@ [ - {"test/support/example_plug_application.ex"} + {"test/support/example_plug_application.ex"}, + {"test/support/test_helpers.ex"} ] diff --git a/README.md b/README.md index 4dbd358f..765396c3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ This is the official Sentry SDK for [Sentry]. ### Install -To use Sentry in your project, add it as a dependency in your `mix.exs` file. Sentry does not install a JSON library nor HTTP client by itself. Sentry will default to trying to use [Jason] for JSON serialization and [Hackney] for HTTP requests, but can be configured to use other ones. To use the default ones, do: +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: ```elixir defp deps do @@ -26,12 +28,24 @@ defp deps do # ... {:sentry, "~> 10.0"}, - {:jason, "~> 1.4"}, {:hackney, "~> 1.19"} ] end ``` +> [!WARNING] +> If you're using an Elixir version before 1.18, the Sentry SDK will default to [Jason] as the JSON library. However, you **must** add it to your dependencies: +> +> ```elixir +> defp deps do +> [ +> # ... +> {:sentry, "~> 10.0"}, +> {:jason, "~> 1.4"} +> ] +> end +> ``` + ### Configuration Sentry has a range of configuration options, but most applications will have a configuration that looks like the following: @@ -130,7 +144,6 @@ Thanks to everyone who has contributed to this project so far. - ## Getting Help/Support If you need help setting up or configuring the Elixir SDK (or anything else in the Sentry universe) please head over to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people in our Discord community ready to help you! diff --git a/config/config.exs b/config/config.exs index 1184ea49..d63cc9d2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -15,4 +15,4 @@ if config_env() == :test do config :logger, backends: [] end -config :phoenix, :json_library, Jason +config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index 87c124fc..54c7d6b8 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -295,7 +295,7 @@ defmodule Sentry.Client do defp sanitize_non_jsonable_value(value, json_library) do try do - json_library.encode(value) + Sentry.JSON.encode(value, json_library) catch _type, _reason -> {:changed, inspect(value)} else diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index a2407512..d57830c7 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -146,14 +146,20 @@ defmodule Sentry.Config do environment variable is set, it will be used as the default value. """ ], + # TODO: deprecate this once we require Elixir 1.18+, when we can force users to use + # the JSON module. json_library: [ type: {:custom, __MODULE__, :__validate_json_library__, []}, - default: Jason, type_doc: "`t:module/0`", + default: if(Code.ensure_loaded?(JSON), do: JSON, else: Jason), doc: """ A module that implements the "standard" Elixir JSON behaviour, that is, exports the - `encode/1` and `decode/1` functions. If you use the default, make sure to add - [`:jason`](https://hex.pm/packages/jason) as a dependency of your application. + `encode/1` and `decode/1` functions. + + Defaults to `Jason` if the `JSON` kernel module is not available (it was introduced + in Elixir 1.18.0). If you use the default configuration with Elixir version lower than + 1.18, this option will default to `Jason`, but you will have to add + [`:jason`](https://hexa.pm/packages/jason) as a dependency of your application. """ ], send_client_reports: [ @@ -693,6 +699,8 @@ defmodule Sentry.Config do {:error, "nil is not a valid value for the :json_library option"} end + def __validate_json_library__(JSON), do: {:ok, JSON} + def __validate_json_library__(mod) when is_atom(mod) do try do with {:ok, %{}} <- mod.decode("{}"), diff --git a/lib/sentry/envelope.ex b/lib/sentry/envelope.ex index 649f6e85..8f44cb20 100644 --- a/lib/sentry/envelope.ex +++ b/lib/sentry/envelope.ex @@ -81,7 +81,7 @@ defmodule Sentry.Envelope do end defp item_to_binary(json_library, %Event{} = event) do - case event |> Sentry.Client.render_event() |> json_library.encode() do + case event |> Sentry.Client.render_event() |> Sentry.JSON.encode(json_library) do {:ok, encoded_event} -> header = ~s({"type":"event","length":#{byte_size(encoded_event)}}) [header, ?\n, encoded_event, ?\n] @@ -100,13 +100,13 @@ defmodule Sentry.Envelope do into: header, do: {Atom.to_string(key), value} - {:ok, header_iodata} = json_library.encode(header) + {:ok, header_iodata} = Sentry.JSON.encode(header, json_library) [header_iodata, ?\n, attachment.data, ?\n] end defp item_to_binary(json_library, %CheckIn{} = check_in) do - case check_in |> CheckIn.to_map() |> json_library.encode() do + case check_in |> CheckIn.to_map() |> Sentry.JSON.encode(json_library) do {:ok, encoded_check_in} -> header = ~s({"type":"check_in","length":#{byte_size(encoded_check_in)}}) [header, ?\n, encoded_check_in, ?\n] @@ -117,7 +117,7 @@ defmodule Sentry.Envelope do end defp item_to_binary(json_library, %ClientReport{} = client_report) do - case client_report |> Map.from_struct() |> json_library.encode() do + case client_report |> Map.from_struct() |> Sentry.JSON.encode(json_library) do {:ok, encoded_client_report} -> header = ~s({"type":"client_report","length":#{byte_size(encoded_client_report)}}) [header, ?\n, encoded_client_report, ?\n] diff --git a/lib/sentry/json.ex b/lib/sentry/json.ex new file mode 100644 index 00000000..b5378504 --- /dev/null +++ b/lib/sentry/json.ex @@ -0,0 +1,33 @@ +defmodule Sentry.JSON do + @moduledoc false + + @spec encode(term(), module()) :: {:ok, String.t()} | {:error, term()} + def encode(data, json_library) + + if Code.ensure_loaded?(JSON) do + def encode(data, JSON) do + {:ok, JSON.encode!(data)} + rescue + error -> {:error, error} + end + end + + def encode(data, json_library) do + json_library.encode(data) + end + + @spec decode(binary(), module()) :: {:ok, term()} | {:error, term()} + def decode(binary, json_library) + + if Code.ensure_loaded?(JSON) do + def decode(binary, JSON) do + {:ok, JSON.decode!(binary)} + rescue + error -> {:error, error} + end + end + + def decode(binary, json_library) do + json_library.decode(binary) + end +end diff --git a/lib/sentry/transport.ex b/lib/sentry/transport.ex index a353b2b8..e6899ff9 100644 --- a/lib/sentry/transport.ex +++ b/lib/sentry/transport.ex @@ -95,7 +95,7 @@ defmodule Sentry.Transport do defp request(client, endpoint, headers, body) do with {:ok, 200, _headers, body} <- client_post_and_validate_return_value(client, endpoint, headers, body), - {:ok, json} <- Config.json_library().decode(body) do + {:ok, json} <- Sentry.JSON.decode(body, Config.json_library()) do {:ok, Map.get(json, "id")} else {:ok, 429, headers, _body} -> diff --git a/pages/setup-with-plug-and-phoenix.md b/pages/setup-with-plug-and-phoenix.md index 00271923..2221fc88 100644 --- a/pages/setup-with-plug-and-phoenix.md +++ b/pages/setup-with-plug-and-phoenix.md @@ -51,7 +51,7 @@ defmodule MyAppWeb.ErrorView do def render("500.html", _assigns) do case Sentry.get_last_event_id_and_source() do {event_id, :plug} when is_binary(event_id) -> - opts = Jason.encode!(%{eventId: event_id}) + opts = JSON.encode!(%{eventId: event_id}) ~E""" diff --git a/test/envelope_test.exs b/test/envelope_test.exs index dd887d3d..d0707266 100644 --- a/test/envelope_test.exs +++ b/test/envelope_test.exs @@ -15,10 +15,10 @@ defmodule Sentry.EnvelopeTest do assert {:ok, encoded} = Envelope.to_binary(envelope) assert [id_line, header_line, event_line] = String.split(encoded, "\n", trim: true) - assert Jason.decode!(id_line) == %{"event_id" => event.event_id} - assert %{"type" => "event", "length" => _} = Jason.decode!(header_line) + assert decode!(id_line) == %{"event_id" => event.event_id} + assert %{"type" => "event", "length" => _} = decode!(header_line) - assert {:ok, decoded_event} = Jason.decode(event_line) + decoded_event = decode!(event_line) assert decoded_event["event_id"] == event.event_id assert decoded_event["breadcrumbs"] == [] assert decoded_event["environment"] == "test" @@ -65,29 +65,29 @@ defmodule Sentry.EnvelopeTest do "..." ] = String.split(encoded, "\n", trim: true) - assert %{"event_id" => _} = Jason.decode!(id_line) + assert %{"event_id" => _} = decode!(id_line) - assert Jason.decode!(attachment1_header) == %{ + assert decode!(attachment1_header) == %{ "type" => "attachment", "length" => 3, "filename" => "example.dat" } - assert Jason.decode!(attachment2_header) == %{ + assert decode!(attachment2_header) == %{ "type" => "attachment", "length" => 6, "filename" => "example.txt", "content_type" => "text/plain" } - assert Jason.decode!(attachment3_header) == %{ + assert decode!(attachment3_header) == %{ "type" => "attachment", "length" => 2, "filename" => "example.json", "content_type" => "application/json" } - assert Jason.decode!(attachment4_header) == %{ + assert decode!(attachment4_header) == %{ "type" => "attachment", "length" => 3, "filename" => "dump", @@ -105,10 +105,10 @@ defmodule Sentry.EnvelopeTest do assert {:ok, encoded} = Envelope.to_binary(envelope) assert [id_line, header_line, event_line] = String.split(encoded, "\n", trim: true) - assert %{"event_id" => _} = Jason.decode!(id_line) - assert %{"type" => "check_in", "length" => _} = Jason.decode!(header_line) + assert %{"event_id" => _} = decode!(id_line) + assert %{"type" => "check_in", "length" => _} = decode!(header_line) - assert {:ok, decoded_check_in} = Jason.decode(event_line) + decoded_check_in = decode!(event_line) assert decoded_check_in["check_in_id"] == check_in_id assert decoded_check_in["monitor_slug"] == "test" assert decoded_check_in["status"] == "ok" @@ -128,10 +128,10 @@ defmodule Sentry.EnvelopeTest do assert {:ok, encoded} = Envelope.to_binary(envelope) assert [id_line, header_line, event_line] = String.split(encoded, "\n", trim: true) - assert %{"event_id" => _} = Jason.decode!(id_line) - assert %{"type" => "client_report", "length" => _} = Jason.decode!(header_line) + assert %{"event_id" => _} = decode!(id_line) + assert %{"type" => "client_report", "length" => _} = decode!(header_line) - assert {:ok, decoded_client_report} = Jason.decode(event_line) + decoded_client_report = decode!(event_line) assert decoded_client_report["timestamp"] == client_report.timestamp assert decoded_client_report["discarded_events"] == [ diff --git a/test/plug_capture_test.exs b/test/plug_capture_test.exs index 226fc2cf..c9e1e22e 100644 --- a/test/plug_capture_test.exs +++ b/test/plug_capture_test.exs @@ -29,7 +29,9 @@ defmodule Sentry.PlugCaptureTest do use Phoenix.Endpoint, otp_app: :sentry use Plug.Debugger, otp_app: :sentry - plug Plug.Parsers, parsers: [:json], pass: ["*/*"], json_decoder: Jason + json_mod = if Code.ensure_loaded?(JSON), do: JSON, else: Jason + + plug Plug.Parsers, parsers: [:json], pass: ["*/*"], json_decoder: json_mod plug Sentry.PlugContext plug PhoenixRouter end @@ -45,7 +47,9 @@ defmodule Sentry.PlugCaptureTest do use Phoenix.Endpoint, otp_app: :sentry use Plug.Debugger, otp_app: :sentry - plug Plug.Parsers, parsers: [:json], pass: ["*/*"], json_decoder: Jason + json_mod = if Code.ensure_loaded?(JSON), do: JSON, else: Jason + + plug Plug.Parsers, parsers: [:json], pass: ["*/*"], json_decoder: json_mod plug Sentry.PlugContext plug PhoenixRouter end diff --git a/test/sentry/config_test.exs b/test/sentry/config_test.exs index 3637c712..07ad8992 100644 --- a/test/sentry/config_test.exs +++ b/test/sentry/config_test.exs @@ -161,7 +161,11 @@ defmodule Sentry.ConfigTest do assert Config.validate!(json_library: Jason)[:json_library] == Jason # Default - assert Config.validate!([])[:json_library] == Jason + if Version.match?(System.version(), "~> 1.18") do + assert Config.validate!([])[:json_library] == JSON + else + assert Config.validate!([])[:json_library] == Jason + end assert_raise ArgumentError, ~r/invalid value for :json_library option/, fn -> Config.validate!(json_library: Atom) diff --git a/test/sentry/json_test.exs b/test/sentry/json_test.exs new file mode 100644 index 00000000..48461c65 --- /dev/null +++ b/test/sentry/json_test.exs @@ -0,0 +1,32 @@ +defmodule Sentry.JSONTest do + use ExUnit.Case, async: true + + json_modules = + if Code.ensure_loaded?(JSON) do + [JSON, Jason] + else + [Jason] + end + + for json_mod <- json_modules do + describe "decode/2 with #{inspect(json_mod)}" do + test "decodes empty object to empty map" do + assert Sentry.JSON.decode("{}", unquote(json_mod)) == {:ok, %{}} + end + + test "returns {:error, reason} if binary is not a JSON" do + assert {:error, _reason} = Sentry.JSON.decode("not JSON", unquote(json_mod)) + end + end + + describe "encode/2 with #{inspect(json_mod)}" do + test "encodes empty map to empty object" do + assert Sentry.JSON.encode(%{}, unquote(json_mod)) == {:ok, "{}"} + end + + test "returns {:error, reason} if data cannot be parsed to JSON" do + assert {:error, _reason} = Sentry.JSON.encode({:ok, "will fail"}, unquote(json_mod)) + end + end + end +end diff --git a/test/sentry/transport_test.exs b/test/sentry/transport_test.exs index 0b7d7e85..3e9f12cc 100644 --- a/test/sentry/transport_test.exs +++ b/test/sentry/transport_test.exs @@ -190,11 +190,17 @@ defmodule Sentry.TransportTest do Plug.Conn.resp(conn, 200, ~s) end) - assert {:request_failure, %Jason.DecodeError{}} = + assert {:request_failure, error} = error(fn -> Transport.encode_and_post_envelope(envelope, HackneyClient, _retries = [0]) end) + if Version.match?(System.version(), "~> 1.18") do + assert error.__struct__ == JSON.DecodeError + else + assert error.__struct__ == Jason.DecodeError + end + assert_received {:request, ^ref} assert_received {:request, ^ref} end diff --git a/test/support/example_plug_application.ex b/test/support/example_plug_application.ex index ee6e88c5..0fc5d637 100644 --- a/test/support/example_plug_application.ex +++ b/test/support/example_plug_application.ex @@ -5,6 +5,8 @@ defmodule Sentry.ExamplePlugApplication do import ExUnit.Assertions + alias Sentry.TestHelpers + plug Plug.Parsers, parsers: [:multipart, :urlencoded] plug Sentry.PlugContext plug :match @@ -50,7 +52,7 @@ defmodule Sentry.ExamplePlugApplication do {event_id, :plug} -> opts = %{title: "Testing", eventId: event_id} - |> Jason.encode!() + |> TestHelpers.encode!() """ diff --git a/test/support/test_error_view.ex b/test/support/test_error_view.ex index 29dcd7e4..fb8fb6e1 100644 --- a/test/support/test_error_view.ex +++ b/test/support/test_error_view.ex @@ -3,12 +3,14 @@ defmodule Sentry.ErrorView do import Phoenix.HTML, only: [raw: 1] + alias Sentry.TestHelpers + def render(_, _) do case Sentry.get_last_event_id_and_source() do {event_id, :plug} -> opts = %{title: "Testing", eventId: event_id} - |> Jason.encode!() + |> TestHelpers.encode!() assigns = %{opts: opts} diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index eca6a617..f3203593 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -1,7 +1,17 @@ defmodule Sentry.TestHelpers do import ExUnit.Assertions - alias Sentry.Config + @spec decode!(String.t()) :: term() + def decode!(binary) do + assert {:ok, data} = Sentry.JSON.decode(binary, Sentry.Config.json_library()) + data + end + + @spec decode!(term()) :: String.t() + def encode!(data) do + assert {:ok, binary} = Sentry.JSON.encode(data, Sentry.Config.json_library()) + binary + end @spec put_test_config(keyword()) :: :ok def put_test_config(config) when is_list(config) do @@ -46,7 +56,7 @@ defmodule Sentry.TestHelpers do @spec decode_envelope!(binary()) :: [{header :: map(), item :: map()}] def decode_envelope!(binary) do [id_line | rest] = String.split(binary, "\n") - {:ok, %{"event_id" => _}} = Config.json_library().decode(id_line) + %{"event_id" => _} = decode!(id_line) decode_envelope_items(rest) end @@ -54,13 +64,9 @@ defmodule Sentry.TestHelpers do items |> Enum.chunk_every(2) |> Enum.flat_map(fn - [header, item] -> - {:ok, header} = Config.json_library().decode(header) - {:ok, item} = Config.json_library().decode(item) - [{header, item}] + [header, item] ->[{decode!(header), decode!(item)}] - [""] -> - [] + [""] ->[] end) end end diff --git a/test_integrations/phoenix_app/config/config.exs b/test_integrations/phoenix_app/config/config.exs index 1f7d4a0d..a0ce0afe 100644 --- a/test_integrations/phoenix_app/config/config.exs +++ b/test_integrations/phoenix_app/config/config.exs @@ -57,8 +57,7 @@ config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id] -# Use Jason for JSON parsing in Phoenix -config :phoenix, :json_library, Jason +config :phoenix, :json_library, if(Code.ensure_loaded?(JSON), do: JSON, else: Jason) # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/test_integrations/phoenix_app/test/support/test_helpers.ex b/test_integrations/phoenix_app/test/support/test_helpers.ex index eca6a617..0df21667 100644 --- a/test_integrations/phoenix_app/test/support/test_helpers.ex +++ b/test_integrations/phoenix_app/test/support/test_helpers.ex @@ -1,7 +1,17 @@ defmodule Sentry.TestHelpers do import ExUnit.Assertions - alias Sentry.Config + @spec decode!(String.t()) :: term() + def decode!(binary) do + assert {:ok, data} = Sentry.JSON.decode(binary, Sentry.Config.json_library()) + data + end + + @spec decode!(term()) :: String.t() + def encode!(data) do + assert {:ok, binary} = Sentry.JSON.encode(data, Sentry.Config.json_library()) + binary + end @spec put_test_config(keyword()) :: :ok def put_test_config(config) when is_list(config) do @@ -46,7 +56,7 @@ defmodule Sentry.TestHelpers do @spec decode_envelope!(binary()) :: [{header :: map(), item :: map()}] def decode_envelope!(binary) do [id_line | rest] = String.split(binary, "\n") - {:ok, %{"event_id" => _}} = Config.json_library().decode(id_line) + %{"event_id" => _} = decode!(id_line) decode_envelope_items(rest) end @@ -54,13 +64,9 @@ defmodule Sentry.TestHelpers do items |> Enum.chunk_every(2) |> Enum.flat_map(fn - [header, item] -> - {:ok, header} = Config.json_library().decode(header) - {:ok, item} = Config.json_library().decode(item) - [{header, item}] + [header, item] -> [{decode!(header), decode!(item)}] - [""] -> - [] + [""] -> [] end) end end