Skip to content

Commit 549d5be

Browse files
Add support for client reports (#801)
Co-authored-by: Andrea Leopardi <[email protected]>
1 parent c529713 commit 549d5be

File tree

10 files changed

+356
-12
lines changed

10 files changed

+356
-12
lines changed

lib/sentry.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ defmodule Sentry do
179179
> with `:source_code_exclude_patterns`.
180180
"""
181181

182-
alias Sentry.{CheckIn, Client, ClientError, Config, Event, LoggerUtils, Options}
182+
alias Sentry.{CheckIn, Client, ClientError, ClientReport, Config, Event, LoggerUtils, Options}
183183

184184
require Logger
185185

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

346347
# If we're in test mode, let's send the event down the pipeline anyway.

lib/sentry/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ defmodule Sentry.Application do
2626
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
2727
Sentry.Sources,
2828
Sentry.Dedupe,
29+
Sentry.ClientReport,
2930
{Sentry.Integrations.CheckInIDMappings,
3031
[
3132
max_expected_check_in_time:

lib/sentry/client.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ defmodule Sentry.Client do
88
alias Sentry.{
99
CheckIn,
1010
ClientError,
11+
ClientReport,
1112
Config,
1213
Dedupe,
1314
Envelope,
@@ -81,6 +82,7 @@ defmodule Sentry.Client do
8182
:unsampled ->
8283
# See https://github.com/getsentry/develop/pull/551/files
8384
Sentry.put_last_event_id_and_source(event.event_id, event.source)
85+
ClientReport.record_discarded_events(:sample_rate, [event])
8486
:unsampled
8587

8688
:excluded ->
@@ -91,6 +93,20 @@ defmodule Sentry.Client do
9193
end
9294
end
9395

96+
@spec send_client_report(ClientReport.t()) ::
97+
{:ok, client_report_id :: String.t()} | {:error, ClientError.t()}
98+
def send_client_report(%ClientReport{} = client_report) do
99+
client = Config.client()
100+
101+
# This is a "private" option, only really used in testing.
102+
request_retries =
103+
Application.get_env(:sentry, :request_retries, Transport.default_retries())
104+
105+
client_report
106+
|> Envelope.from_client_report()
107+
|> Transport.encode_and_post_envelope(client, request_retries)
108+
end
109+
94110
defp sample_event(sample_rate) do
95111
cond do
96112
sample_rate == 1 -> :ok

lib/sentry/client_report.ex

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
defmodule Sentry.ClientReport do
2+
@moduledoc """
3+
A struct and GenServer implementation to represent and manage **client reports** for Sentry.
4+
5+
Client reports are used to provide insights into which events are being dropped and for what reason.
6+
7+
This module is responsible for recording, storing, and periodically sending these client
8+
reports to Sentry. You can choose to turn off these reports by configuring the
9+
option `send_client_reports?`.
10+
11+
Refer to <https://develop.sentry.dev/sdk/client-reports/> for more details.
12+
13+
*Available since v10.8.0*.
14+
"""
15+
16+
@moduledoc since: "10.8.0"
17+
18+
use GenServer
19+
alias Sentry.{Client, Config, Envelope}
20+
21+
@client_report_reasons [
22+
:ratelimit_backoff,
23+
:queue_overflow,
24+
:cache_overflow,
25+
:network_error,
26+
:sample_rate,
27+
:before_send,
28+
:event_processor,
29+
:insufficient_data,
30+
:backpressure,
31+
:send_error,
32+
:internal_sdk_error
33+
]
34+
35+
@typedoc """
36+
The possible reasons of the discarded event.
37+
"""
38+
@typedoc since: "10.8.0"
39+
@type reason() ::
40+
unquote(Enum.reduce(@client_report_reasons, &quote(do: unquote(&1) | unquote(&2))))
41+
42+
@typedoc """
43+
The struct for a **client report**.
44+
"""
45+
@typedoc since: "10.8.0"
46+
@type t() :: %__MODULE__{
47+
timestamp: String.t() | number(),
48+
discarded_events: [%{reason: reason(), category: String.t(), quantity: pos_integer()}]
49+
}
50+
51+
defstruct [:timestamp, discarded_events: %{}]
52+
53+
@send_interval 30_000
54+
55+
@doc false
56+
@spec start_link([]) :: GenServer.on_start()
57+
def start_link(opts \\ []) do
58+
GenServer.start_link(__MODULE__, %{}, name: Keyword.get(opts, :name, __MODULE__))
59+
end
60+
61+
@doc false
62+
@spec record_discarded_events(
63+
reason(),
64+
[item]
65+
) :: :ok
66+
when item:
67+
Sentry.Attachment.t()
68+
| Sentry.CheckIn.t()
69+
| Sentry.ClientReport.t()
70+
| Sentry.Event.t()
71+
def record_discarded_events(reason, event_items, genserver \\ __MODULE__)
72+
when is_list(event_items) do
73+
if Enum.member?(@client_report_reasons, reason) do
74+
_ =
75+
event_items
76+
|> Enum.each(
77+
&GenServer.cast(
78+
genserver,
79+
{:record_discarded_events, reason, Envelope.get_data_category(&1)}
80+
)
81+
)
82+
end
83+
84+
# We silently ignore events whose reasons aren't valid because we have to add it to the allowlist in Snuba
85+
# https://develop.sentry.dev/sdk/client-reports/
86+
87+
:ok
88+
end
89+
90+
@doc false
91+
@impl true
92+
def init(state) do
93+
schedule_report()
94+
{:ok, state}
95+
end
96+
97+
@doc false
98+
@impl true
99+
def handle_cast({:record_discarded_events, reason, category}, discarded_events) do
100+
{:noreply, Map.update(discarded_events, {reason, category}, 1, &(&1 + 1))}
101+
end
102+
103+
@doc false
104+
@impl true
105+
def handle_info(:send_report, discarded_events) do
106+
if map_size(discarded_events) != 0 do
107+
discarded_events =
108+
discarded_events
109+
|> Enum.map(fn {{reason, category}, quantity} ->
110+
%{
111+
reason: reason,
112+
category: category,
113+
quantity: quantity
114+
}
115+
end)
116+
117+
client_report =
118+
%__MODULE__{
119+
timestamp: timestamp(),
120+
discarded_events: discarded_events
121+
}
122+
123+
_ =
124+
if Config.dsn() != nil && Config.send_client_reports?() do
125+
Client.send_client_report(client_report)
126+
end
127+
128+
schedule_report()
129+
{:noreply, %{}}
130+
else
131+
# state is nil so nothing to send but keep looping
132+
schedule_report()
133+
{:noreply, %{}}
134+
end
135+
end
136+
137+
defp schedule_report do
138+
Process.send_after(self(), :send_report, @send_interval)
139+
end
140+
141+
defp timestamp do
142+
DateTime.utc_now()
143+
|> DateTime.truncate(:second)
144+
|> DateTime.to_iso8601()
145+
|> String.trim_trailing("Z")
146+
end
147+
end

lib/sentry/config.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ defmodule Sentry.Config do
156156
[`:jason`](https://hex.pm/packages/jason) as a dependency of your application.
157157
"""
158158
],
159+
send_client_reports: [
160+
type: :boolean,
161+
default: true,
162+
doc: """
163+
Send diagnostic client reports about discarded events, interval is set to send a report
164+
once every 30 seconds if any discarded events exist.
165+
See [Client Reports](https://develop.sentry.dev/sdk/client-reports/) in Sentry docs.
166+
*Available since v10.8.0*.
167+
"""
168+
],
159169
server_name: [
160170
type: :string,
161171
doc: """
@@ -571,6 +581,9 @@ defmodule Sentry.Config do
571581
@spec dedup_events?() :: boolean()
572582
def dedup_events?, do: fetch!(:dedup_events)
573583

584+
@spec send_client_reports?() :: boolean()
585+
def send_client_reports?, do: fetch!(:send_client_reports)
586+
574587
@spec test_mode?() :: boolean()
575588
def test_mode?, do: fetch!(:test_mode)
576589

lib/sentry/envelope.ex

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ defmodule Sentry.Envelope do
22
@moduledoc false
33
# https://develop.sentry.dev/sdk/envelopes/
44

5-
alias Sentry.{Attachment, CheckIn, Config, Event, UUID}
5+
alias Sentry.{Attachment, CheckIn, ClientReport, Config, Event, UUID}
66

77
@type t() :: %__MODULE__{
88
event_id: UUID.t(),
9-
items: [Event.t() | Attachment.t() | CheckIn.t(), ...]
9+
items: [Event.t() | Attachment.t() | CheckIn.t() | ClientReport.t(), ...]
1010
}
1111

1212
@enforce_keys [:event_id, :items]
@@ -34,6 +34,36 @@ defmodule Sentry.Envelope do
3434
}
3535
end
3636

37+
@doc """
38+
Creates a new envelope containing the client report.
39+
"""
40+
@doc since: "10.8.0"
41+
@spec from_client_report(ClientReport.t()) :: t()
42+
def from_client_report(%ClientReport{} = client_report) do
43+
%__MODULE__{
44+
event_id: UUID.uuid4_hex(),
45+
items: [client_report]
46+
}
47+
end
48+
49+
@spec get_data_category(Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t()) ::
50+
String.t()
51+
def get_data_category(%mod{} = type) when mod in [Attachment, CheckIn, ClientReport, Event] do
52+
case type do
53+
%Attachment{} ->
54+
"attachment"
55+
56+
%CheckIn{} ->
57+
"monitor"
58+
59+
%ClientReport{} ->
60+
"internal"
61+
62+
%Event{} ->
63+
"error"
64+
end
65+
end
66+
3767
@doc """
3868
Encodes the envelope into its binary representation.
3969
@@ -60,7 +90,7 @@ defmodule Sentry.Envelope do
6090
defp item_to_binary(json_library, %Event{} = event) do
6191
case event |> Sentry.Client.render_event() |> json_library.encode() do
6292
{:ok, encoded_event} ->
63-
header = ~s({"type": "event", "length": #{byte_size(encoded_event)}})
93+
header = ~s({"type":"event","length":#{byte_size(encoded_event)}})
6494
[header, ?\n, encoded_event, ?\n]
6595

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

91121
{:error, _reason} = error ->
92122
throw(error)
93123
end
94124
end
125+
126+
defp item_to_binary(json_library, %ClientReport{} = client_report) do
127+
case client_report |> Map.from_struct() |> json_library.encode() do
128+
{:ok, encoded_client_report} ->
129+
header = ~s({"type":"client_report","length":#{byte_size(encoded_client_report)}})
130+
[header, ?\n, encoded_client_report, ?\n]
131+
132+
{:error, _reason} = error ->
133+
throw(error)
134+
end
135+
end
95136
end

lib/sentry/transport.ex

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Sentry.Transport do
33

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

6-
alias Sentry.{ClientError, Config, Envelope, LoggerUtils}
6+
alias Sentry.{ClientError, ClientReport, Config, Envelope, LoggerUtils}
77

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

3434
{:error, reason} ->
3535
{:error, ClientError.new({:invalid_json, reason})}
3636
end
3737

3838
_ = maybe_log_send_result(result, envelope.items)
39-
4039
result
4140
end
4241

43-
defp post_envelope_with_retries(client, endpoint, headers, payload, retries_left) do
42+
defp post_envelope_with_retries(
43+
client,
44+
endpoint,
45+
headers,
46+
payload,
47+
retries_left,
48+
envelope_items
49+
) do
4450
case request(client, endpoint, headers, payload) do
4551
{:ok, id} ->
4652
{:ok, id}
@@ -49,20 +55,39 @@ defmodule Sentry.Transport do
4955
# own retry.
5056
{:retry_after, delay_ms} when retries_left != [] ->
5157
Process.sleep(delay_ms)
52-
post_envelope_with_retries(client, endpoint, headers, payload, tl(retries_left))
58+
59+
post_envelope_with_retries(
60+
client,
61+
endpoint,
62+
headers,
63+
payload,
64+
tl(retries_left),
65+
envelope_items
66+
)
5367

5468
{:retry_after, _delay_ms} ->
69+
ClientReport.record_discarded_events(:ratelimit_backoff, envelope_items)
5570
{:error, ClientError.new(:too_many_retries)}
5671

5772
{:error, _reason} when retries_left != [] ->
5873
[sleep_interval | retries_left] = retries_left
5974
Process.sleep(sleep_interval)
60-
post_envelope_with_retries(client, endpoint, headers, payload, retries_left)
75+
76+
post_envelope_with_retries(
77+
client,
78+
endpoint,
79+
headers,
80+
payload,
81+
retries_left,
82+
envelope_items
83+
)
6184

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

6589
{:error, reason} ->
90+
ClientReport.record_discarded_events(:send_error, envelope_items)
6691
{:error, ClientError.new(reason)}
6792
end
6893
end

0 commit comments

Comments
 (0)