Skip to content

Commit 479ba04

Browse files
authored
feat(tests): more test helpers and features (#1060)
* feat(tests): support for `telemetry_processor` opt in `setup_sentry` * feat(tests): support for `collect_envelopes` opt in `setup_sentry` * feat(tests): add `refute_sentry_check_in` helper * refa(tests): simplify metrics test * refa(tests): simplify telemetry processor integration test * refa(tests): simplify oban integration test * refa(tests): simplify and improve cron tests * feat(test): add helpers to ease finding and asserting on transactions * refa(tests): leverage the new transaction assertion helper
1 parent b2c30d7 commit 479ba04

13 files changed

Lines changed: 616 additions & 514 deletions

File tree

lib/sentry/test.ex

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ defmodule Sentry.Test do
6565
Opens a Bypass on a random port, configures the DSN to point to it,
6666
wires up `before_send` / `before_send_log` callbacks to capture structs
6767
in an isolated ETS table, and starts a per-test `Sentry.TelemetryProcessor`
68-
(via `setup_telemetry_processor/0`) so that assertions work for events
68+
(via `setup_telemetry_processor/1`) so that assertions work for events
6969
that travel through the TelemetryProcessor pipeline (logs, metrics, or
7070
`send_result: :none`).
7171
@@ -79,6 +79,23 @@ defmodule Sentry.Test do
7979
Any extra Sentry config options (e.g., `dedup_events: false`, `traces_sample_rate: 1.0`)
8080
will be forwarded to the test config.
8181
82+
The reserved `:telemetry_processor` option is *not* forwarded to the test
83+
config. Instead, its value (a keyword list) is passed to the per-test
84+
`Sentry.TelemetryProcessor` (e.g. `buffer_configs`, `buffer_capacities`,
85+
`scheduler_weights`, `transport_capacity`). This replaces the need to
86+
manually `stop_supervised!/1` and re-`start_supervised!/2` the processor.
87+
88+
The reserved `:collect_envelopes` option is *not* forwarded to the test
89+
config either. When set, a Bypass envelope collector is wired up
90+
automatically and its reference is returned under the `:ref` key:
91+
92+
* `true` — set up the collector with no options;
93+
* a keyword list — forwarded to `setup_bypass_envelope_collector/2`
94+
(e.g. `[type: "check_in"]` to only collect a given item type).
95+
96+
This collapses the common `bypass = setup_sentry(...); ref =
97+
setup_bypass_envelope_collector(bypass)` two-step into one call.
98+
8299
## Examples
83100
84101
setup do
@@ -89,28 +106,38 @@ defmodule Sentry.Test do
89106
Sentry.Test.setup_sentry(dedup_events: false)
90107
end
91108
92-
Replacing the auto-started processor with a custom-configured one:
109+
Configuring the per-test processor (e.g. a smaller log batch size):
93110
94111
setup do
95-
%{telemetry_processor: name} = ctx = Sentry.Test.setup_sentry()
96-
stop_supervised!(name)
97-
98-
start_supervised!(
99-
{Sentry.TelemetryProcessor,
100-
name: name, buffer_configs: %{log: %{batch_size: 1}}},
101-
id: name
112+
Sentry.Test.setup_sentry(
113+
telemetry_processor: [buffer_configs: %{log: %{batch_size: 1}}]
102114
)
115+
end
103116
104-
ctx
117+
Collecting envelopes directly as the ExUnit setup return:
118+
119+
setup do
120+
Sentry.Test.setup_sentry(collect_envelopes: true, traces_sample_rate: 1.0)
121+
end
122+
123+
test "...", %{ref: ref} do
124+
# ...
105125
end
106126
107127
"""
108128
@doc since: "13.0.0"
109-
@spec setup_sentry(keyword()) :: %{bypass: term(), telemetry_processor: atom()}
129+
@spec setup_sentry(keyword()) :: %{
130+
:bypass => term(),
131+
:telemetry_processor => atom(),
132+
optional(:ref) => reference()
133+
}
110134
def setup_sentry(extra_config \\ []) do
111135
ensure_bypass_loaded!()
112136
ensure_nimble_ownership_loaded!()
113137

138+
{tp_opts, extra_config} = Keyword.pop(extra_config, :telemetry_processor, [])
139+
{collect_envelopes, extra_config} = Keyword.pop(extra_config, :collect_envelopes, false)
140+
114141
# Open a per-test Bypass and stub the envelope endpoint
115142
bypass = Bypass.open()
116143

@@ -120,13 +147,25 @@ defmodule Sentry.Test do
120147

121148
# Start a per-test TelemetryProcessor before setup_collector/1 so that
122149
# the collector wires this test's scheduler into its registry.
123-
processor_name = setup_telemetry_processor()
150+
processor_name = setup_telemetry_processor(tp_opts)
124151

125152
# Set up collector with DSN pointing to this test's Bypass
126153
bypass_config = [dsn: "http://public:secret@localhost:#{bypass.port}/1"]
127154
setup_collector(bypass_config ++ extra_config)
128155

129-
%{bypass: bypass, telemetry_processor: processor_name}
156+
case collect_envelopes do
157+
false ->
158+
%{bypass: bypass, telemetry_processor: processor_name}
159+
160+
collect ->
161+
collector_opts = if is_list(collect), do: collect, else: []
162+
163+
%{
164+
bypass: bypass,
165+
telemetry_processor: processor_name,
166+
ref: setup_bypass_envelope_collector(bypass, collector_opts)
167+
}
168+
end
130169
end
131170

132171
@doc """
@@ -153,34 +192,69 @@ defmodule Sentry.Test do
153192
Must be called from within an ExUnit test because it uses
154193
`ExUnit.Callbacks.start_supervised!/2` for automatic cleanup.
155194
156-
If a per-test processor is already registered for this test (for example
157-
when using `Sentry.Case`), this function is idempotent and returns the
158-
existing processor name instead of starting a new one.
195+
## Options
196+
197+
`tp_opts` is a keyword list forwarded to the per-test
198+
`Sentry.TelemetryProcessor` child spec (e.g. `buffer_configs`,
199+
`buffer_capacities`, `scheduler_weights`, `transport_capacity`).
200+
201+
Idempotency depends on `tp_opts`:
202+
203+
* with no `tp_opts`, an already-registered live processor (for example
204+
one started by `Sentry.Case`) is reused and its name returned;
205+
* with `tp_opts`, an already-registered live processor is stopped and
206+
restarted under the same name with the given options, so callers no
207+
longer need to `stop_supervised!/1` + `start_supervised!/2` manually.
159208
"""
160209
@doc since: "13.0.0"
161-
@spec setup_telemetry_processor() :: atom()
162-
def setup_telemetry_processor do
210+
@spec setup_telemetry_processor(keyword()) :: atom()
211+
def setup_telemetry_processor(tp_opts \\ []) do
163212
case Process.get(:sentry_telemetry_processor) do
164213
name when is_atom(name) and not is_nil(name) ->
165-
if processor_alive?(name), do: name, else: start_telemetry_processor()
214+
cond do
215+
not processor_alive?(name) -> start_telemetry_processor(tp_opts)
216+
tp_opts == [] -> name
217+
true -> restart_telemetry_processor(name, tp_opts)
218+
end
166219

167220
_ ->
168-
start_telemetry_processor()
221+
start_telemetry_processor(tp_opts)
169222
end
170223
end
171224

172-
defp start_telemetry_processor do
225+
defp start_telemetry_processor(tp_opts) do
173226
uid = System.unique_integer([:positive])
174227
processor_name = :"test_telemetry_processor_#{uid}"
175228

176-
ExUnit.Callbacks.start_supervised!(
177-
{Sentry.TelemetryProcessor,
178-
name: processor_name, processor_resolver: &Sentry.Test.Registry.lookup_processor_for/1},
179-
id: processor_name
180-
)
229+
start_processor_child(processor_name, tp_opts)
181230

231+
# Must be set before tag_scheduler/1, which reads
232+
# `:sentry_telemetry_processor` from this process's dictionary via
233+
# `fetch_owner_processor/1`. Tagging would otherwise be a silent no-op.
182234
Process.put(:sentry_telemetry_processor, processor_name)
183235

236+
tag_scheduler(processor_name)
237+
processor_name
238+
end
239+
240+
defp restart_telemetry_processor(name, tp_opts) do
241+
ExUnit.Callbacks.stop_supervised!(name)
242+
start_processor_child(name, tp_opts)
243+
# The process dictionary already holds `name`; the new scheduler pid
244+
# must be re-tagged since the old one died with the old supervisor.
245+
tag_scheduler(name)
246+
name
247+
end
248+
249+
defp start_processor_child(name, tp_opts) do
250+
opts =
251+
[name: name, processor_resolver: &Sentry.Test.Registry.lookup_processor_for/1]
252+
|> Keyword.merge(tp_opts)
253+
254+
ExUnit.Callbacks.start_supervised!({Sentry.TelemetryProcessor, opts}, id: name)
255+
end
256+
257+
defp tag_scheduler(processor_name) do
184258
scheduler_pid = Sentry.TelemetryProcessor.get_scheduler(processor_name)
185259

186260
if scheduler_pid do
@@ -191,7 +265,7 @@ defmodule Sentry.Test do
191265
tag_processor_for_allowed_pid(self(), scheduler_pid)
192266
end
193267

194-
processor_name
268+
:ok
195269
end
196270

197271
defp processor_alive?(name) do
@@ -366,9 +440,9 @@ defmodule Sentry.Test do
366440
# callback drops the event.
367441
#
368442
# The owner's processor name is looked up from its process
369-
# dictionary; tests set it in `setup_telemetry_processor/0`. If the
443+
# dictionary; tests set it in `setup_telemetry_processor/1`. If the
370444
# owner has no per-test processor (e.g. legacy
371-
# `start_collecting/1` without `setup_telemetry_processor/0`), the
445+
# `start_collecting/1` without `setup_telemetry_processor/1`), the
372446
# tag is skipped and the buffered event still falls back to the
373447
# global processor — the same behaviour as before this change.
374448
defp tag_processor_for_allowed_pid(owner_pid, allowed_pid) do

lib/sentry/test/assertions.ex

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ defmodule Sentry.Test.Assertions do
5757
import ExUnit.Assertions, only: [flunk: 1]
5858

5959
@default_timeout 1000
60+
@refute_timeout 100
6061
@max_poll_interval 50
6162

6263
@type_to_pop %{
@@ -246,6 +247,112 @@ defmodule Sentry.Test.Assertions do
246247
find_item!(items, criteria, "report")
247248
end
248249

250+
@doc """
251+
Asserts that exactly one transaction envelope is collected via `ref` and
252+
matches the given `criteria`.
253+
254+
This is shorthand for:
255+
256+
assert_sentry_report(collect_sentry_transactions(ref, 1), criteria)
257+
258+
Reserved option in `criteria`:
259+
260+
* `:timeout` — ms to wait for the envelope (default: `#{1000}`)
261+
262+
Use with a collector created via
263+
`Sentry.Test.setup_sentry(collect_envelopes: true)`.
264+
265+
Returns the matched transaction map.
266+
267+
## Examples
268+
269+
test "GET /transaction", %{conn: conn, ref: ref} do
270+
get(conn, ~p"/transaction")
271+
272+
assert_sentry_transaction(ref,
273+
transaction: "test_span",
274+
contexts: %{trace: %{op: "test_span"}}
275+
)
276+
end
277+
278+
"""
279+
@doc since: "13.0.2"
280+
@spec assert_sentry_transaction(reference(), keyword()) :: map()
281+
def assert_sentry_transaction(ref, criteria \\ []) when is_reference(ref) do
282+
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
283+
transactions = Sentry.Test.collect_sentry_transactions(ref, 1, timeout: timeout)
284+
tx = unwrap_single!(transactions, "transaction", timeout)
285+
assert_fields!(tx, criteria, "transaction")
286+
tx
287+
end
288+
289+
@doc """
290+
Collects up to `:count` transaction envelopes via `ref` and returns the
291+
first one matching `criteria`. Raises if no transaction matches.
292+
293+
This is shorthand for:
294+
295+
find_sentry_report!(collect_sentry_transactions(ref, count, timeout: timeout), criteria)
296+
297+
Reserved options in `criteria`:
298+
299+
* `:count` — max number of envelopes to collect (default: `1`)
300+
* `:timeout` — ms to wait for each envelope (default: `#{1000}`)
301+
302+
## Examples
303+
304+
find_sentry_transaction!(ref,
305+
count: 10,
306+
timeout: 2000,
307+
transaction: "PhoenixAppWeb.UserLive.Index.handle_event#save",
308+
contexts: %{trace: %{origin: "opentelemetry_phoenix"}}
309+
)
310+
311+
"""
312+
@doc since: "13.0.2"
313+
@spec find_sentry_transaction!(reference(), keyword()) :: map()
314+
def find_sentry_transaction!(ref, criteria) when is_reference(ref) do
315+
{count, criteria} = Keyword.pop(criteria, :count, 1)
316+
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
317+
transactions = Sentry.Test.collect_sentry_transactions(ref, count, timeout: timeout)
318+
find_item!(transactions, criteria, "transaction")
319+
end
320+
321+
@doc """
322+
Asserts that **no** Sentry check-in envelope reaches the Bypass envelope
323+
collector identified by `ref` within `timeout` ms (default
324+
`#{@refute_timeout}`).
325+
326+
The `Sentry.TelemetryProcessor` pipeline is flushed first, so a check-in
327+
that was buffered (rather than sent synchronously) is still detected
328+
rather than silently slipping past the timeout window.
329+
330+
Use with a collector created via
331+
`Sentry.Test.setup_sentry(collect_envelopes: [type: "check_in"])` (or
332+
`Sentry.Test.setup_bypass_envelope_collector/2` with `type: "check_in"`),
333+
so that only check-in envelopes are forwarded to the test process.
334+
335+
## Examples
336+
337+
test "ignores non-cron jobs", %{ref: ref} do
338+
:telemetry.execute([:oban, :job, :start], %{}, %{job: %Oban.Job{}})
339+
refute_sentry_check_in(ref)
340+
end
341+
342+
"""
343+
@doc since: "13.0.2"
344+
@spec refute_sentry_check_in(reference(), timeout()) :: :ok
345+
def refute_sentry_check_in(ref, timeout \\ @refute_timeout) when is_reference(ref) do
346+
maybe_flush(timeout)
347+
348+
receive do
349+
{:bypass_envelope, ^ref, body} ->
350+
flunk("expected no check-in to be sent, but received envelope: #{body}")
351+
after
352+
timeout -> :ok
353+
end
354+
end
355+
249356
# --- Private helpers ---
250357

251358
defp pop_for_type(type) do

0 commit comments

Comments
 (0)