Skip to content

Commit

Permalink
Add support for client reports (#801)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrea Leopardi <an.leopardi@gmail.com>
  • Loading branch information
savhappy and whatyouhide authored Oct 21, 2024
1 parent c529713 commit 549d5be
Show file tree
Hide file tree
Showing 10 changed files with 356 additions and 12 deletions.
3 changes: 2 additions & 1 deletion lib/sentry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ defmodule Sentry do
> with `:source_code_exclude_patterns`.
"""

alias Sentry.{CheckIn, Client, ClientError, Config, Event, LoggerUtils, Options}
alias Sentry.{CheckIn, Client, ClientError, ClientReport, Config, Event, LoggerUtils, Options}

require Logger

Expand Down Expand Up @@ -341,6 +341,7 @@ defmodule Sentry do
cond do
is_nil(event.message) and event.exception == [] ->
LoggerUtils.log("Cannot report event without message or exception: #{inspect(event)}")
ClientReport.record_discarded_events(:event_processor, [event])
:ignored

# If we're in test mode, let's send the event down the pipeline anyway.
Expand Down
1 change: 1 addition & 0 deletions lib/sentry/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defmodule Sentry.Application do
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
Sentry.Sources,
Sentry.Dedupe,
Sentry.ClientReport,
{Sentry.Integrations.CheckInIDMappings,
[
max_expected_check_in_time:
Expand Down
16 changes: 16 additions & 0 deletions lib/sentry/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Sentry.Client do
alias Sentry.{
CheckIn,
ClientError,
ClientReport,
Config,
Dedupe,
Envelope,
Expand Down Expand Up @@ -81,6 +82,7 @@ defmodule Sentry.Client do
:unsampled ->
# See https://github.com/getsentry/develop/pull/551/files
Sentry.put_last_event_id_and_source(event.event_id, event.source)
ClientReport.record_discarded_events(:sample_rate, [event])
:unsampled

:excluded ->
Expand All @@ -91,6 +93,20 @@ defmodule Sentry.Client do
end
end

@spec send_client_report(ClientReport.t()) ::
{:ok, client_report_id :: String.t()} | {:error, ClientError.t()}
def send_client_report(%ClientReport{} = client_report) do
client = Config.client()

# This is a "private" option, only really used in testing.
request_retries =
Application.get_env(:sentry, :request_retries, Transport.default_retries())

client_report
|> Envelope.from_client_report()
|> Transport.encode_and_post_envelope(client, request_retries)
end

defp sample_event(sample_rate) do
cond do
sample_rate == 1 -> :ok
Expand Down
147 changes: 147 additions & 0 deletions lib/sentry/client_report.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule Sentry.ClientReport do
@moduledoc """
A struct and GenServer implementation to represent and manage **client reports** for Sentry.
Client reports are used to provide insights into which events are being dropped and for what reason.
This module is responsible for recording, storing, and periodically sending these client
reports to Sentry. You can choose to turn off these reports by configuring the
option `send_client_reports?`.
Refer to <https://develop.sentry.dev/sdk/client-reports/> for more details.
*Available since v10.8.0*.
"""

@moduledoc since: "10.8.0"

use GenServer
alias Sentry.{Client, Config, Envelope}

@client_report_reasons [
:ratelimit_backoff,
:queue_overflow,
:cache_overflow,
:network_error,
:sample_rate,
:before_send,
:event_processor,
:insufficient_data,
:backpressure,
:send_error,
:internal_sdk_error
]

@typedoc """
The possible reasons of the discarded event.
"""
@typedoc since: "10.8.0"
@type reason() ::
unquote(Enum.reduce(@client_report_reasons, &quote(do: unquote(&1) | unquote(&2))))

@typedoc """
The struct for a **client report**.
"""
@typedoc since: "10.8.0"
@type t() :: %__MODULE__{
timestamp: String.t() | number(),
discarded_events: [%{reason: reason(), category: String.t(), quantity: pos_integer()}]
}

defstruct [:timestamp, discarded_events: %{}]

@send_interval 30_000

@doc false
@spec start_link([]) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, name: Keyword.get(opts, :name, __MODULE__))
end

@doc false
@spec record_discarded_events(
reason(),
[item]
) :: :ok
when item:
Sentry.Attachment.t()
| Sentry.CheckIn.t()
| Sentry.ClientReport.t()
| Sentry.Event.t()
def record_discarded_events(reason, event_items, genserver \\ __MODULE__)
when is_list(event_items) do
if Enum.member?(@client_report_reasons, reason) do
_ =
event_items
|> Enum.each(
&GenServer.cast(
genserver,
{:record_discarded_events, reason, Envelope.get_data_category(&1)}
)
)
end

# We silently ignore events whose reasons aren't valid because we have to add it to the allowlist in Snuba
# https://develop.sentry.dev/sdk/client-reports/

:ok
end

@doc false
@impl true
def init(state) do
schedule_report()
{:ok, state}
end

@doc false
@impl true
def handle_cast({:record_discarded_events, reason, category}, discarded_events) do
{:noreply, Map.update(discarded_events, {reason, category}, 1, &(&1 + 1))}
end

@doc false
@impl true
def handle_info(:send_report, discarded_events) do
if map_size(discarded_events) != 0 do
discarded_events =
discarded_events
|> Enum.map(fn {{reason, category}, quantity} ->
%{
reason: reason,
category: category,
quantity: quantity
}
end)

client_report =
%__MODULE__{
timestamp: timestamp(),
discarded_events: discarded_events
}

_ =
if Config.dsn() != nil && Config.send_client_reports?() do
Client.send_client_report(client_report)
end

schedule_report()
{:noreply, %{}}
else
# state is nil so nothing to send but keep looping
schedule_report()
{:noreply, %{}}
end
end

defp schedule_report do
Process.send_after(self(), :send_report, @send_interval)
end

defp timestamp do
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.to_iso8601()
|> String.trim_trailing("Z")
end
end
13 changes: 13 additions & 0 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ defmodule Sentry.Config do
[`:jason`](https://hex.pm/packages/jason) as a dependency of your application.
"""
],
send_client_reports: [
type: :boolean,
default: true,
doc: """
Send diagnostic client reports about discarded events, interval is set to send a report
once every 30 seconds if any discarded events exist.
See [Client Reports](https://develop.sentry.dev/sdk/client-reports/) in Sentry docs.
*Available since v10.8.0*.
"""
],
server_name: [
type: :string,
doc: """
Expand Down Expand Up @@ -571,6 +581,9 @@ defmodule Sentry.Config do
@spec dedup_events?() :: boolean()
def dedup_events?, do: fetch!(:dedup_events)

@spec send_client_reports?() :: boolean()
def send_client_reports?, do: fetch!(:send_client_reports)

@spec test_mode?() :: boolean()
def test_mode?, do: fetch!(:test_mode)

Expand Down
49 changes: 45 additions & 4 deletions lib/sentry/envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ defmodule Sentry.Envelope do
@moduledoc false
# https://develop.sentry.dev/sdk/envelopes/

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

@type t() :: %__MODULE__{
event_id: UUID.t(),
items: [Event.t() | Attachment.t() | CheckIn.t(), ...]
items: [Event.t() | Attachment.t() | CheckIn.t() | ClientReport.t(), ...]
}

@enforce_keys [:event_id, :items]
Expand Down Expand Up @@ -34,6 +34,36 @@ defmodule Sentry.Envelope do
}
end

@doc """
Creates a new envelope containing the client report.
"""
@doc since: "10.8.0"
@spec from_client_report(ClientReport.t()) :: t()
def from_client_report(%ClientReport{} = client_report) do
%__MODULE__{
event_id: UUID.uuid4_hex(),
items: [client_report]
}
end

@spec get_data_category(Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t()) ::
String.t()
def get_data_category(%mod{} = type) when mod in [Attachment, CheckIn, ClientReport, Event] do
case type do
%Attachment{} ->
"attachment"

%CheckIn{} ->
"monitor"

%ClientReport{} ->
"internal"

%Event{} ->
"error"
end
end

@doc """
Encodes the envelope into its binary representation.
Expand All @@ -60,7 +90,7 @@ defmodule Sentry.Envelope do
defp item_to_binary(json_library, %Event{} = event) do
case event |> Sentry.Client.render_event() |> json_library.encode() do
{:ok, encoded_event} ->
header = ~s({"type": "event", "length": #{byte_size(encoded_event)}})
header = ~s({"type":"event","length":#{byte_size(encoded_event)}})
[header, ?\n, encoded_event, ?\n]

{:error, _reason} = error ->
Expand All @@ -85,11 +115,22 @@ defmodule Sentry.Envelope do
defp item_to_binary(json_library, %CheckIn{} = check_in) do
case check_in |> CheckIn.to_map() |> json_library.encode() do
{:ok, encoded_check_in} ->
header = ~s({"type": "check_in", "length": #{byte_size(encoded_check_in)}})
header = ~s({"type":"check_in","length":#{byte_size(encoded_check_in)}})
[header, ?\n, encoded_check_in, ?\n]

{:error, _reason} = error ->
throw(error)
end
end

defp item_to_binary(json_library, %ClientReport{} = client_report) do
case client_report |> Map.from_struct() |> json_library.encode() do
{:ok, encoded_client_report} ->
header = ~s({"type":"client_report","length":#{byte_size(encoded_client_report)}})
[header, ?\n, encoded_client_report, ?\n]

{:error, _reason} = error ->
throw(error)
end
end
end
37 changes: 31 additions & 6 deletions lib/sentry/transport.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Sentry.Transport do

# This module is exclusively responsible for encoding and POSTing envelopes to Sentry.

alias Sentry.{ClientError, Config, Envelope, LoggerUtils}
alias Sentry.{ClientError, ClientReport, Config, Envelope, LoggerUtils}

@default_retries [1000, 2000, 4000, 8000]
@sentry_version 5
Expand All @@ -29,18 +29,24 @@ defmodule Sentry.Transport do
case Envelope.to_binary(envelope) do
{:ok, body} ->
{endpoint, headers} = get_endpoint_and_headers()
post_envelope_with_retries(client, endpoint, headers, body, retries)
post_envelope_with_retries(client, endpoint, headers, body, retries, envelope.items)

{:error, reason} ->
{:error, ClientError.new({:invalid_json, reason})}
end

_ = maybe_log_send_result(result, envelope.items)

result
end

defp post_envelope_with_retries(client, endpoint, headers, payload, retries_left) do
defp post_envelope_with_retries(
client,
endpoint,
headers,
payload,
retries_left,
envelope_items
) do
case request(client, endpoint, headers, payload) do
{:ok, id} ->
{:ok, id}
Expand All @@ -49,20 +55,39 @@ defmodule Sentry.Transport do
# own retry.
{:retry_after, delay_ms} when retries_left != [] ->
Process.sleep(delay_ms)
post_envelope_with_retries(client, endpoint, headers, payload, tl(retries_left))

post_envelope_with_retries(
client,
endpoint,
headers,
payload,
tl(retries_left),
envelope_items
)

{:retry_after, _delay_ms} ->
ClientReport.record_discarded_events(:ratelimit_backoff, envelope_items)
{:error, ClientError.new(:too_many_retries)}

{:error, _reason} when retries_left != [] ->
[sleep_interval | retries_left] = retries_left
Process.sleep(sleep_interval)
post_envelope_with_retries(client, endpoint, headers, payload, retries_left)

post_envelope_with_retries(
client,
endpoint,
headers,
payload,
retries_left,
envelope_items
)

{:error, {:http, {status, headers, body}}} ->
ClientReport.record_discarded_events(:send_error, envelope_items)
{:error, ClientError.server_error(status, headers, body)}

{:error, reason} ->
ClientReport.record_discarded_events(:send_error, envelope_items)
{:error, ClientError.new(reason)}
end
end
Expand Down
Loading

0 comments on commit 549d5be

Please sign in to comment.