Skip to content

Commit adc99cf

Browse files
committed
Refactor and improve docs for client reports
1 parent 549d5be commit adc99cf

File tree

10 files changed

+180
-157
lines changed

10 files changed

+180
-157
lines changed

lib/sentry.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +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])
344+
ClientReport.Sender.record_discarded_events(:event_processor, [event])
345345
:ignored
346346

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

lib/sentry/application.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ defmodule Sentry.Application do
2626
{Registry, keys: :unique, name: Sentry.Transport.SenderRegistry},
2727
Sentry.Sources,
2828
Sentry.Dedupe,
29-
Sentry.ClientReport,
29+
Sentry.ClientReport.Sender,
3030
{Sentry.Integrations.CheckInIDMappings,
3131
[
3232
max_expected_check_in_time:

lib/sentry/client.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ defmodule Sentry.Client do
8282
:unsampled ->
8383
# See https://github.com/getsentry/develop/pull/551/files
8484
Sentry.put_last_event_id_and_source(event.event_id, event.source)
85-
ClientReport.record_discarded_events(:sample_rate, [event])
85+
ClientReport.Sender.record_discarded_events(:sample_rate, [event])
8686
:unsampled
8787

8888
:excluded ->

lib/sentry/client_report.ex

Lines changed: 7 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
defmodule Sentry.ClientReport do
22
@moduledoc """
3-
A struct and GenServer implementation to represent and manage **client reports** for Sentry.
3+
This module represents the data structure for a **client report**.
44
5-
Client reports are used to provide insights into which events are being dropped and for what reason.
5+
Client reports are used to provide insights into which events are being
6+
dropped (that is, not sent to Sentry) and for what reason.
67
78
This module is responsible for recording, storing, and periodically sending these client
89
reports to Sentry. You can choose to turn off these reports by configuring the
9-
option `send_client_reports?`.
10+
`:send_client_reports` option.
1011
1112
Refer to <https://develop.sentry.dev/sdk/client-reports/> for more details.
1213
@@ -15,9 +16,6 @@ defmodule Sentry.ClientReport do
1516

1617
@moduledoc since: "10.8.0"
1718

18-
use GenServer
19-
alias Sentry.{Client, Config, Envelope}
20-
2119
@client_report_reasons [
2220
:ratelimit_backoff,
2321
:queue_overflow,
@@ -48,100 +46,10 @@ defmodule Sentry.ClientReport do
4846
discarded_events: [%{reason: reason(), category: String.t(), quantity: pos_integer()}]
4947
}
5048

49+
@enforce_keys [:timestamp, :discarded_events]
5150
defstruct [:timestamp, discarded_events: %{}]
5251

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-
10352
@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
53+
@spec reasons() :: [reason(), ...]
54+
def reasons, do: @client_report_reasons
14755
end

lib/sentry/client_report/sender.ex

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
defmodule Sentry.ClientReport.Sender do
2+
@moduledoc false
3+
4+
# This module is responsible for storing client reports and periodically "flushing"
5+
# them to Sentry.
6+
7+
use GenServer
8+
9+
alias Sentry.{Client, ClientReport, Config, Envelope}
10+
11+
@send_interval 30_000
12+
13+
@client_report_reasons ClientReport.reasons()
14+
15+
@spec start_link([]) :: GenServer.on_start()
16+
def start_link(opts \\ []) do
17+
GenServer.start_link(__MODULE__, nil, name: Keyword.get(opts, :name, __MODULE__))
18+
end
19+
20+
@spec record_discarded_events(atom(), [item], GenServer.server()) :: :ok
21+
when item:
22+
Sentry.Attachment.t()
23+
| Sentry.CheckIn.t()
24+
| ClientReport.t()
25+
| Sentry.Event.t()
26+
def record_discarded_events(reason, event_items, genserver \\ __MODULE__)
27+
when is_list(event_items) do
28+
# We silently ignore events whose reasons aren't valid because we have to add it to the allowlist in Snuba
29+
# https://develop.sentry.dev/sdk/client-reports/
30+
if Enum.member?(@client_report_reasons, reason) do
31+
Enum.each(
32+
event_items,
33+
fn item ->
34+
GenServer.cast(
35+
genserver,
36+
{:record_discarded_events, reason, Envelope.get_data_category(item)}
37+
)
38+
end
39+
)
40+
end
41+
end
42+
43+
## Callbacks
44+
45+
@impl true
46+
def init(nil) do
47+
schedule_report()
48+
{:ok, _state = %{}}
49+
end
50+
51+
@impl true
52+
def handle_cast({:record_discarded_events, reason, category}, discarded_events) do
53+
{:noreply, Map.update(discarded_events, {reason, category}, 1, &(&1 + 1))}
54+
end
55+
56+
@impl true
57+
def handle_info(:send_report, state) do
58+
if map_size(state) != 0 and Config.dsn() != nil and Config.send_client_reports?() do
59+
client_report =
60+
%ClientReport{
61+
timestamp:
62+
DateTime.utc_now()
63+
|> DateTime.truncate(:second)
64+
|> DateTime.to_iso8601()
65+
|> String.trim_trailing("Z"),
66+
discarded_events:
67+
Enum.map(state, fn {{reason, category}, quantity} ->
68+
%{
69+
reason: reason,
70+
category: category,
71+
quantity: quantity
72+
}
73+
end)
74+
}
75+
76+
Client.send_client_report(client_report)
77+
end
78+
79+
schedule_report()
80+
{:noreply, %{}}
81+
end
82+
83+
defp schedule_report do
84+
Process.send_after(self(), :send_report, @send_interval)
85+
end
86+
end

lib/sentry/envelope.ex

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,16 @@ defmodule Sentry.Envelope do
4646
}
4747
end
4848

49+
@doc """
50+
Returns the "data category" of the envelope's contents (to be used in client reports and more).
51+
"""
52+
@doc since: "10.8.0"
4953
@spec get_data_category(Attachment.t() | CheckIn.t() | ClientReport.t() | Event.t()) ::
5054
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
55+
def get_data_category(%Attachment{}), do: "attachment"
56+
def get_data_category(%CheckIn{}), do: "monitor"
57+
def get_data_category(%ClientReport{}), do: "internal"
58+
def get_data_category(%Event{}), do: "error"
6659

6760
@doc """
6861
Encodes the envelope into its binary representation.

lib/sentry/transport.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ defmodule Sentry.Transport do
6666
)
6767

6868
{:retry_after, _delay_ms} ->
69-
ClientReport.record_discarded_events(:ratelimit_backoff, envelope_items)
69+
ClientReport.Sender.record_discarded_events(:ratelimit_backoff, envelope_items)
7070
{:error, ClientError.new(:too_many_retries)}
7171

7272
{:error, _reason} when retries_left != [] ->
@@ -83,11 +83,11 @@ defmodule Sentry.Transport do
8383
)
8484

8585
{:error, {:http, {status, headers, body}}} ->
86-
ClientReport.record_discarded_events(:send_error, envelope_items)
86+
ClientReport.Sender.record_discarded_events(:send_error, envelope_items)
8787
{:error, ClientError.server_error(status, headers, body)}
8888

8989
{:error, reason} ->
90-
ClientReport.record_discarded_events(:send_error, envelope_items)
90+
ClientReport.Sender.record_discarded_events(:send_error, envelope_items)
9191
{:error, ClientError.new(reason)}
9292
end
9393
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ defmodule Sentry.Mixfile do
5050
groups_for_modules: [
5151
"Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext, Sentry.LiveViewHook],
5252
Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler],
53-
"Data Structures": [Sentry.Attachment, Sentry.CheckIn],
53+
"Data Structures": [Sentry.Attachment, Sentry.CheckIn, Sentry.ClientReport],
5454
HTTP: [Sentry.HTTPClient, Sentry.HackneyClient],
5555
Interfaces: [~r/^Sentry\.Interfaces/],
5656
Testing: [Sentry.Test]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
defmodule Sentry.ClientReportTest do
2+
use Sentry.Case, async: false
3+
4+
import Sentry.TestHelpers
5+
6+
alias Sentry.ClientReport.Sender
7+
alias Sentry.Event
8+
9+
setup do
10+
original_retries =
11+
Application.get_env(:sentry, :request_retries, Sentry.Transport.default_retries())
12+
13+
on_exit(fn -> Application.put_env(:sentry, :request_retries, original_retries) end)
14+
15+
Application.put_env(:sentry, :request_retries, [])
16+
17+
bypass = Bypass.open()
18+
put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1")
19+
%{bypass: bypass}
20+
end
21+
22+
describe "record_discarded_events/2 + flushing" do
23+
test "succefully records the discarded event to the client report", %{bypass: bypass} do
24+
start_supervised!({Sender, name: :test_client_report})
25+
26+
events = [
27+
%Event{
28+
event_id: Sentry.UUID.uuid4_hex(),
29+
timestamp: "2024-10-12T13:21:13"
30+
}
31+
]
32+
33+
assert :ok = Sender.record_discarded_events(:before_send, events, :test_client_report)
34+
35+
assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 1}
36+
37+
assert :ok = Sender.record_discarded_events(:before_send, events, :test_client_report)
38+
39+
assert :sys.get_state(:test_client_report) == %{{:before_send, "error"} => 2}
40+
41+
assert :ok = Sender.record_discarded_events(:event_processor, events, :test_client_report)
42+
assert :ok = Sender.record_discarded_events(:network_error, events, :test_client_report)
43+
44+
assert :sys.get_state(:test_client_report) == %{
45+
{:before_send, "error"} => 2,
46+
{:event_processor, "error"} => 1,
47+
{:network_error, "error"} => 1
48+
}
49+
50+
send(Process.whereis(:test_client_report), :send_report)
51+
52+
Bypass.expect(bypass, fn conn ->
53+
{:ok, body, conn} = Plug.Conn.read_body(conn)
54+
55+
assert [{%{"type" => "client_report", "length" => _}, client_report}] =
56+
decode_envelope!(body)
57+
58+
assert client_report["discarded_events"] == [
59+
%{"reason" => "before_send", "category" => "error", "quantity" => 2},
60+
%{"reason" => "event_processor", "category" => "error", "quantity" => 1},
61+
%{"reason" => "network_error", "category" => "error", "quantity" => 1}
62+
]
63+
64+
assert client_report["timestamp"] =~ ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/
65+
66+
Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>)
67+
end)
68+
69+
assert :sys.get_state(:test_client_report) == %{}
70+
end
71+
end
72+
end

0 commit comments

Comments
 (0)