Skip to content

Allow the usage of JSON for Elixir 1.18+ #844

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,43 @@ 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 HTTP client by itself. Sentry will default to trying to use [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
```

For Elixir 1.18+, `JSON` kernel module will be used by default to serialize JSON data.

For Elixir lower than 1.18, Sentry will default to trying to use [Jason] for JSON serialization. To use it, do:

```elixir
defp deps do
[
# ...

{:sentry, "~> 10.0"},
{:jason, "~> 1.4"}
]
end
```

To use `Jason` or other JSON library for Elixir 1.18+, it is required to define it as a compile-time configuration:

```elixir
# config.exs
config :sentry, json_library: Jason
```

### Configuration

Sentry has a range of configuration options, but most applications will have a configuration that looks like the following:
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)
26 changes: 12 additions & 14 deletions lib/sentry/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,6 @@ defmodule Sentry.Client do

@spec render_event(Event.t()) :: map()
def render_event(%Event{} = event) do
json_library = Config.json_library()

event
|> Event.remove_non_payload_keys()
|> update_if_present(:breadcrumbs, fn bcs -> Enum.map(bcs, &Map.from_struct/1) end)
Expand All @@ -218,9 +216,9 @@ defmodule Sentry.Client do
Map.from_struct(message)
end)
|> update_if_present(:request, &(&1 |> Map.from_struct() |> remove_nils()))
|> update_if_present(:extra, &sanitize_non_jsonable_values(&1, json_library))
|> update_if_present(:user, &sanitize_non_jsonable_values(&1, json_library))
|> update_if_present(:tags, &sanitize_non_jsonable_values(&1, json_library))
|> update_if_present(:extra, &sanitize_non_jsonable_values/1)
|> update_if_present(:user, &sanitize_non_jsonable_values/1)
|> update_if_present(:tags, &sanitize_non_jsonable_values/1)
|> update_if_present(:exception, fn list -> Enum.map(list, &render_exception/1) end)
|> update_if_present(:threads, fn list -> Enum.map(list, &render_thread/1) end)
end
Expand Down Expand Up @@ -255,27 +253,27 @@ defmodule Sentry.Client do
:maps.filter(fn _key, value -> not is_nil(value) end, map)
end

defp sanitize_non_jsonable_values(map, json_library) do
defp sanitize_non_jsonable_values(map) do
# We update the existing map instead of building a new one from scratch
# due to performance reasons. See the docs for :maps.map/2.
Enum.reduce(map, map, fn {key, value}, acc ->
case sanitize_non_jsonable_value(value, json_library) do
case sanitize_non_jsonable_value(value) do
:unchanged -> acc
{:changed, value} -> Map.put(acc, key, value)
end
end)
end

# For performance, skip all the keys that we know for sure are JSON encodable.
defp sanitize_non_jsonable_value(value, _json_library)
defp sanitize_non_jsonable_value(value)
when is_binary(value) or is_number(value) or is_boolean(value) or is_nil(value) do
:unchanged
end

defp sanitize_non_jsonable_value(value, json_library) when is_list(value) do
defp sanitize_non_jsonable_value(value) when is_list(value) do
{mapped, changed?} =
Enum.map_reduce(value, _changed? = false, fn value, changed? ->
case sanitize_non_jsonable_value(value, json_library) do
case sanitize_non_jsonable_value(value) do
:unchanged -> {value, changed?}
{:changed, value} -> {value, true}
end
Expand All @@ -288,14 +286,14 @@ defmodule Sentry.Client do
end
end

defp sanitize_non_jsonable_value(value, json_library)
defp sanitize_non_jsonable_value(value)
when is_map(value) and not is_struct(value) do
{:changed, sanitize_non_jsonable_values(value, json_library)}
{:changed, sanitize_non_jsonable_values(value)}
end

defp sanitize_non_jsonable_value(value, json_library) do
defp sanitize_non_jsonable_value(value) do
try do
json_library.encode(value)
Sentry.JSON.encode(value)
catch
_type, _reason -> {:changed, inspect(value)}
else
Expand Down
14 changes: 9 additions & 5 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,16 @@ defmodule Sentry.Config do
],
json_library: [
type: {:custom, __MODULE__, :__validate_json_library__, []},
default: Jason,
deprecated: "JSON kernel module is available for Elixir 1.18+.",
type_doc: "`t:module/0`",
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
`encode/1` and `decode/1` functions.

This configuration should be set at compile-time.

Defaults to `Jason` if `JSON` kernel module is not available. If you use the
default configuration with Elixir version lower than 1.18, make sure to add
[`:jason`](https://hex.pm/packages/jason) as a dependency of your application.
"""
],
Expand Down Expand Up @@ -569,9 +574,6 @@ defmodule Sentry.Config do
@spec report_deps?() :: boolean()
def report_deps?, do: fetch!(:report_deps)

@spec json_library() :: module()
def json_library, do: fetch!(:json_library)

@spec log_level() :: :debug | :info | :warning | :warn | :error
def log_level, do: fetch!(:log_level)

Expand Down Expand Up @@ -693,6 +695,8 @@ defmodule Sentry.Config do
{:error, "nil is not a valid value for the :json_library option"}
end

def __validate_json_library__(JSON = mod), do: {:ok, mod}

def __validate_json_library__(mod) when is_atom(mod) do
try do
with {:ok, %{}} <- mod.decode("{}"),
Expand Down
22 changes: 10 additions & 12 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Sentry.Envelope do
@moduledoc false
# https://develop.sentry.dev/sdk/envelopes/

alias Sentry.{Attachment, CheckIn, ClientReport, Config, Event, UUID}
alias Sentry.{Attachment, CheckIn, ClientReport, Event, UUID}

@type t() :: %__MODULE__{
event_id: UUID.t(),
Expand Down Expand Up @@ -65,23 +65,21 @@ defmodule Sentry.Envelope do
"""
@spec to_binary(t()) :: {:ok, binary()} | {:error, any()}
def to_binary(%__MODULE__{} = envelope) do
json_library = Config.json_library()

headers_iodata =
case envelope.event_id do
nil -> "{{}}\n"
event_id -> ~s({"event_id":"#{event_id}"}\n)
end

items_iodata = Enum.map(envelope.items, &item_to_binary(json_library, &1))
items_iodata = Enum.map(envelope.items, &item_to_binary/1)

{:ok, IO.iodata_to_binary([headers_iodata, items_iodata])}
catch
{:error, _reason} = error -> error
end

defp item_to_binary(json_library, %Event{} = event) do
case event |> Sentry.Client.render_event() |> json_library.encode() do
defp item_to_binary(%Event{} = event) do
case event |> Sentry.Client.render_event() |> Sentry.JSON.encode() do
{:ok, encoded_event} ->
header = ~s({"type":"event","length":#{byte_size(encoded_event)}})
[header, ?\n, encoded_event, ?\n]
Expand All @@ -91,7 +89,7 @@ defmodule Sentry.Envelope do
end
end

defp item_to_binary(json_library, %Attachment{} = attachment) do
defp item_to_binary(%Attachment{} = attachment) do
header = %{"type" => "attachment", "length" => byte_size(attachment.data)}

header =
Expand All @@ -100,13 +98,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)

[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
defp item_to_binary(%CheckIn{} = check_in) do
case check_in |> CheckIn.to_map() |> Sentry.JSON.encode() do
{:ok, encoded_check_in} ->
header = ~s({"type":"check_in","length":#{byte_size(encoded_check_in)}})
[header, ?\n, encoded_check_in, ?\n]
Expand All @@ -116,8 +114,8 @@ defmodule Sentry.Envelope do
end
end

defp item_to_binary(json_library, %ClientReport{} = client_report) do
case client_report |> Map.from_struct() |> json_library.encode() do
defp item_to_binary(%ClientReport{} = client_report) do
case client_report |> Map.from_struct() |> Sentry.JSON.encode() do
{:ok, encoded_client_report} ->
header = ~s({"type":"client_report","length":#{byte_size(encoded_client_report)}})
[header, ?\n, encoded_client_report, ?\n]
Expand Down
28 changes: 28 additions & 0 deletions lib/sentry/json.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Sentry.JSON do
@moduledoc false

@default_library if(Code.ensure_loaded?(JSON), do: JSON, else: Jason)
@library Application.compile_env(:sentry, :json_library, @default_library)

@spec decode(String.t()) :: {:ok, term()} | {:error, term()}
if @library == JSON do
def decode(binary) do
{:ok, JSON.decode!(binary)}
rescue
error -> {:error, error}
end
else
defdelegate decode(binary), to: @library
end

@spec encode(term()) :: {:ok, String.t()} | {:error, term()}
if @library == JSON do
def encode(data) do
{:ok, JSON.encode!(data)}
rescue
error -> {:error, error}
end
else
defdelegate encode(data), to: @library
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) do
{:ok, Map.get(json, "id")}
else
{:ok, 429, headers, _body} ->
Expand Down
10 changes: 5 additions & 5 deletions pages/setup-with-plug-and-phoenix.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ You can capture errors in Plug (and Phoenix) applications with `Sentry.PlugConte

If you are using Phoenix:

1. Add `Sentry.PlugCapture` above the `use Phoenix.Endpoint` line in your endpoint file
1. Add `Sentry.PlugContext` below `Plug.Parsers`
1. Add `Sentry.PlugCapture` above the `use Phoenix.Endpoint` line in your endpoint file
1. Add `Sentry.PlugContext` below `Plug.Parsers`

```diff
defmodule MyAppWeb.Endpoint
Expand Down 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 All @@ -72,8 +72,8 @@ end

If you are in a non-Phoenix Plug application:

1. Add `Sentry.PlugCapture` at the top of your Plug application
1. Add `Sentry.PlugContext` below `Plug.Parsers` (if it is in your stack)
1. Add `Sentry.PlugCapture` at the top of your Plug application
1. Add `Sentry.PlugContext` below `Plug.Parsers` (if it is in your stack)

```diff
defmodule MyApp.Router do
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)
assert 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)
assert 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)
assert decoded_client_report = decode!(event_line)
assert decoded_client_report["timestamp"] == client_report.timestamp

assert decoded_client_report["discarded_events"] == [
Expand Down
Loading