Skip to content

Commit 79c57cc

Browse files
authored
Introduce Sentry.Test (#681)
1 parent 05c4c22 commit 79c57cc

File tree

7 files changed

+436
-10
lines changed

7 files changed

+436
-10
lines changed

config/config.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ if config_env() == :test do
1010
hackney_opts: [recv_timeout: 50, pool: :sentry_pool],
1111
send_result: :sync,
1212
send_max_attempts: 1,
13-
dedup_events: false
13+
dedup_events: false,
14+
test_mode: true
1415

1516
config :logger, backends: []
1617
end

lib/sentry/client.ex

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,18 +197,30 @@ defmodule Sentry.Client do
197197
end
198198

199199
defp encode_and_send(%Event{} = event, _result_type = :sync, client, request_retries) do
200-
send_result =
201-
event
202-
|> Envelope.from_event()
203-
|> Transport.post_envelope(client, request_retries)
200+
case Sentry.Test.maybe_collect(event) do
201+
:collected ->
202+
{:ok, ""}
204203

205-
_ = maybe_log_send_result(send_result, event)
206-
send_result
204+
:not_collecting ->
205+
send_result =
206+
event
207+
|> Envelope.from_event()
208+
|> Transport.post_envelope(client, request_retries)
209+
210+
_ = maybe_log_send_result(send_result, event)
211+
send_result
212+
end
207213
end
208214

209215
defp encode_and_send(%Event{} = event, _result_type = :none, client, _request_retries) do
210-
:ok = Transport.Sender.send_async(client, event)
211-
{:ok, ""}
216+
case Sentry.Test.maybe_collect(event) do
217+
:collected ->
218+
{:ok, ""}
219+
220+
:not_collecting ->
221+
:ok = Transport.Sender.send_async(client, event)
222+
{:ok, ""}
223+
end
212224
end
213225

214226
@spec render_event(Event.t()) :: map()

lib/sentry/config.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ defmodule Sentry.Config do
138138
log a message instead. Events are deduplicated by comparing their message, exception,
139139
stacktrace, and fingerprint. *Available since v10.0.0*.
140140
"""
141+
],
142+
test_mode: [
143+
type: :boolean,
144+
default: false,
145+
doc: """
146+
Whether to enable *test mode*. When test mode is enabled, the SDK will check whether
147+
there is a process **collecting events** and avoid sending those events if that's the
148+
case. This is useful for testing. See `Sentry.Test`.
149+
"""
141150
]
142151
]
143152

@@ -439,6 +448,9 @@ defmodule Sentry.Config do
439448
@spec dedup_events?() :: boolean()
440449
def dedup_events?, do: fetch!(:dedup_events)
441450

451+
@spec test_mode?() :: boolean()
452+
def test_mode?, do: fetch!(:test_mode)
453+
442454
@spec put_config(atom(), term()) :: :ok
443455
def put_config(key, value) when is_atom(key) do
444456
unless key in @valid_keys do

lib/sentry/test.ex

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
defmodule Sentry.Test do
2+
@moduledoc """
3+
Utilities for testing Sentry reports.
4+
5+
## Usage
6+
7+
This module is based on **collecting** reported events and then retrieving
8+
them to perform assertions. You can start collecting events from a process
9+
by calling `start_collecting_sentry_reports/0`. Then, you can use Sentry
10+
as normal and report events (through functions such as `Sentry.capture_message/1`
11+
or `Sentry.capture_exception/1`). Finally, you can retrieve the collected events
12+
by calling `pop_sentry_reports/0`.
13+
14+
## Examples
15+
16+
Let's imagine writing a test using the functions in this module. First, we need to
17+
start collecting events:
18+
19+
test "reporting from child processes" do
20+
parent_pid = self()
21+
22+
# Collect reports from self().
23+
assert :ok = Test.start_collecting_sentry_reports()
24+
25+
# <we'll fill this in below...>
26+
end
27+
28+
Now, we can report events as normal. For example, we can report an event from the
29+
parent process:
30+
31+
assert {:ok, ""} = Sentry.capture_message("Oops from parent process")
32+
33+
We can also report events from "child" processes.
34+
35+
# Spawn a child that waits for the :go message and then reports an event.
36+
{:ok, child_pid} =
37+
Task.start_link(fn ->
38+
receive do
39+
:go ->
40+
assert {:ok, ""} = Sentry.capture_message("Oops from child process")
41+
send(parent_pid, :done)
42+
end
43+
end)
44+
45+
# Start the child and wait for it to finish.
46+
send(child_pid, :go)
47+
assert_receive :done
48+
49+
Now, we can retrieve the collected events and perform assertions on them:
50+
51+
assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports()
52+
assert event1.message.formatted == "Oops from parent process"
53+
assert event2.message.formatted == "Oops from child process"
54+
55+
"""
56+
57+
@moduledoc since: "10.2.0"
58+
59+
@server __MODULE__.OwnershipServer
60+
@key :events
61+
62+
# Used internally when reporting an event, *before* reporting the actual event.
63+
@doc false
64+
@spec maybe_collect(Sentry.Event.t()) :: :collected | :not_collecting
65+
def maybe_collect(%Sentry.Event{} = event) do
66+
if Sentry.Config.test_mode?() do
67+
case NimbleOwnership.fetch_owner(@server, callers(), @key) do
68+
{:ok, owner_pid} ->
69+
result =
70+
NimbleOwnership.get_and_update(@server, owner_pid, @key, fn events ->
71+
{:collected, (events || []) ++ [event]}
72+
end)
73+
74+
case result do
75+
{:ok, :collected} ->
76+
:collected
77+
78+
{:error, error} ->
79+
raise ArgumentError, "cannot collect Sentry reports: #{Exception.message(error)}"
80+
end
81+
82+
:error ->
83+
:not_collecting
84+
end
85+
else
86+
:not_collecting
87+
end
88+
end
89+
90+
@doc """
91+
Starts collecting events from the current process.
92+
93+
This function starts collecting events reported from the current process. If you want to
94+
allow other processes to report events, you need to *allow* them to report events back
95+
to the current process. See `allow/2` for more information on allowances. If the current
96+
process is already *allowed by another process*, this function raises an error.
97+
98+
The `context` parameter is ignored. It's there so that this function can be used
99+
as an ExUnit **setup callback**. For example:
100+
101+
import Sentry.Test
102+
103+
setup :start_collecting_sentry_reports
104+
105+
"""
106+
@doc since: "10.2.0"
107+
@spec start_collecting_sentry_reports(map()) :: :ok
108+
def start_collecting_sentry_reports(_context \\ %{}) do
109+
# Make sure the ownership server is started (this is idempotent).
110+
ensure_ownership_server_started()
111+
112+
case NimbleOwnership.fetch_owner(@server, callers(), @key) do
113+
# No-op
114+
{tag, owner_pid} when tag in [:ok, :shared_owner] and owner_pid == self() ->
115+
:ok
116+
117+
{:shared_owner, _pid} ->
118+
raise ArgumentError,
119+
"Sentry.Test is in global mode and is already collecting reported events"
120+
121+
{:ok, another_pid} ->
122+
raise ArgumentError, "already collecting reported events from #{inspect(another_pid)}"
123+
124+
:error ->
125+
:ok
126+
end
127+
128+
{:ok, _} =
129+
NimbleOwnership.get_and_update(@server, self(), @key, fn events ->
130+
{:ignored, events || []}
131+
end)
132+
133+
:ok
134+
end
135+
136+
@doc """
137+
Allows `pid_to_allow` to collect events back to the root process via `owner_pid`.
138+
139+
`owner_pid` must be a PID that is currently collecting events or has been allowed
140+
to collect events. If that's not the case, this function raises an error.
141+
142+
`pid_to_allow` can also be a **function** that returns a PID. This is useful when
143+
you want to allow a registered process that is not yet started to collect events. For example:
144+
145+
Sentry.Test.allow_sentry_reports(self(), fn -> Process.whereis(:my_process) end)
146+
147+
"""
148+
@doc since: "10.2.0"
149+
@spec allow_sentry_reports(pid(), pid() | (-> pid())) :: :ok
150+
def allow_sentry_reports(owner_pid, pid_to_allow)
151+
when is_pid(owner_pid) and (is_pid(pid_to_allow) or is_function(pid_to_allow, 0)) do
152+
case NimbleOwnership.allow(@server, owner_pid, pid_to_allow, @key) do
153+
:ok ->
154+
:ok
155+
156+
{:error, reason} ->
157+
raise "failed to allow #{inspect(pid_to_allow)} to collect events: #{Exception.message(reason)}"
158+
end
159+
end
160+
161+
@doc """
162+
Pops all the collected events from the current process.
163+
164+
This function returns a list of all the events that have been collected from the current
165+
process and all the processes that were allowed through it. If the current process
166+
is not collecting events, this function raises an error.
167+
168+
After this function returns, the current process will still be collecting events, but
169+
the collected events will be reset to `[]`.
170+
171+
## Examples
172+
173+
iex> Sentry.Test.start_collecting_sentry_reports()
174+
:ok
175+
iex> Sentry.capture_message("Oops")
176+
{:ok, ""}
177+
iex> [%Sentry.Event{} = event] = Sentry.Test.pop_sentry_reports()
178+
iex> event.message.formatted
179+
"Oops"
180+
181+
"""
182+
@doc since: "10.2.0"
183+
@spec pop_sentry_reports() :: [Sentry.Event.t()]
184+
def pop_sentry_reports do
185+
result =
186+
NimbleOwnership.get_and_update(@server, self(), @key, fn
187+
nil -> {:not_collecting, []}
188+
events when is_list(events) -> {events, []}
189+
end)
190+
191+
case result do
192+
{:ok, :not_collecting} ->
193+
raise ArgumentError, "not collecting reported events from #{inspect(self())}"
194+
195+
{:ok, events} ->
196+
events
197+
198+
{:error, error} when is_exception(error) ->
199+
raise ArgumentError, "cannot pop Sentry reports: #{Exception.message(error)}"
200+
end
201+
end
202+
203+
## Helpers
204+
205+
defp ensure_ownership_server_started do
206+
case NimbleOwnership.start_link(name: @server) do
207+
{:ok, pid} ->
208+
pid
209+
210+
{:error, {:already_started, pid}} ->
211+
pid
212+
213+
{:error, reason} ->
214+
raise "could not start required processes for Sentry.Test: #{inspect(reason)}"
215+
end
216+
end
217+
218+
defp callers do
219+
[self()] ++ Process.get(:"$callers", [])
220+
end
221+
end

mix.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ defmodule Sentry.Mixfile do
4545
groups_for_modules: [
4646
"Plug and Phoenix": [Sentry.PlugCapture, Sentry.PlugContext],
4747
Loggers: [Sentry.LoggerBackend, Sentry.LoggerHandler],
48-
Interfaces: [~r/^Sentry\.Interfaces/]
48+
Interfaces: [~r/^Sentry\.Interfaces/],
49+
Testing: [Sentry.Test]
4950
],
5051
source_ref: "#{@version}",
5152
source_url: @source_url,
@@ -80,6 +81,7 @@ defmodule Sentry.Mixfile do
8081
defp deps do
8182
[
8283
{:nimble_options, "~> 1.0"},
84+
{:nimble_ownership, "~> 0.2.0"},
8385

8486
# Optional dependencies
8587
{:hackney, "~> 1.8", optional: true},

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
2020
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
2121
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
22+
"nimble_ownership": {:hex, :nimble_ownership, "0.2.1", "3e44c72ebe8dd213db4e13aff4090aaa331d158e72ce1891d02e0ffb05a1eb2d", [:mix], [], "hexpm", "bf38d2ef4fb990521a4ecf112843063c1f58a5c602484af4c7977324042badee"},
2223
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
2324
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
2425
"phoenix": {:hex, :phoenix, "1.5.8", "71cfa7a9bb9a37af4df98939790642f210e35f696b935ca6d9d9c55a884621a4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "35ded0a32f4836168c7ab6c33b88822eccd201bcd9492125a9bea4c54332d955"},

0 commit comments

Comments
 (0)