Skip to content

Commit

Permalink
Allow the usage of JSON for Elixir 1.18+ (#845)
Browse files Browse the repository at this point in the history
Co-Authored-By: [email protected]
  • Loading branch information
whatyouhide authored Jan 7, 2025
1 parent 722e316 commit f375551
Show file tree
Hide file tree
Showing 19 changed files with 169 additions and 53 deletions.
3 changes: 2 additions & 1 deletion .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[
{"test/support/example_plug_application.ex"}
{"test/support/example_plug_application.ex"},
{"test/support/test_helpers.ex"}
]
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,34 @@ 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
[
# ...

{: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:
Expand Down Expand Up @@ -130,7 +144,6 @@ Thanks to everyone who has contributed to this project so far.
<img src="https://contributors-img.web.app/image?repo=getsentry/sentry-elixir" />
</a>


## 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!
Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion lib/sentry/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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("{}"),
Expand Down
8 changes: 4 additions & 4 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions lib/sentry/json.ex
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/sentry/transport.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
2 changes: 1 addition & 1 deletion pages/setup-with-plug-and-phoenix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<script src="https://browser.sentry-cdn.com/5.9.1/bundle.min.js" integrity="sha384-/x1aHz0nKRd6zVUazsV6CbQvjJvr6zQL2CHbQZf3yoLkezyEtZUpqUNnOLW9Nt3v" crossorigin="anonymous"></script>
Expand Down
28 changes: 14 additions & 14 deletions test/envelope_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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"] == [
Expand Down
8 changes: 6 additions & 2 deletions test/plug_capture_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion test/sentry/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions test/sentry/json_test.exs
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion test/sentry/transport_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,17 @@ defmodule Sentry.TransportTest do
Plug.Conn.resp(conn, 200, ~s<invalid JSON>)
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
Expand Down
4 changes: 3 additions & 1 deletion test/support/example_plug_application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,7 +52,7 @@ defmodule Sentry.ExamplePlugApplication do
{event_id, :plug} ->
opts =
%{title: "Testing", eventId: event_id}
|> Jason.encode!()
|> TestHelpers.encode!()

"""
<script src="https://browser.sentry-cdn.com/5.9.1/bundle.min.js" integrity="sha384-/x1aHz0nKRd6zVUazsV6CbQvjJvr6zQL2CHbQZf3yoLkezyEtZUpqUNnOLW9Nt3v" crossorigin="anonymous"></script>
Expand Down
4 changes: 3 additions & 1 deletion test/support/test_error_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Loading

0 comments on commit f375551

Please sign in to comment.