Skip to content
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

Add support for client reports #801

Merged
merged 15 commits into from
Oct 21, 2024
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
149 changes: 149 additions & 0 deletions lib/sentry/client_report.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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
]
whatyouhide marked this conversation as resolved.
Show resolved Hide resolved

@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
}

IO.inspect(client_report)

whatyouhide marked this conversation as resolved.
Show resolved Hide resolved
_ =
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 @@ -147,6 +147,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 @@ -562,6 +572,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
whatyouhide marked this conversation as resolved.
Show resolved Hide resolved
%__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
Loading
Loading