Skip to content

Commit a59a4c5

Browse files
committed
feat(tests): re-enable manual pid allowance
1 parent 363b5f2 commit a59a4c5

4 files changed

Lines changed: 312 additions & 15 deletions

File tree

lib/sentry/test.ex

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,19 +251,83 @@ defmodule Sentry.Test do
251251
end
252252

253253
@doc """
254-
Allows `pid_to_allow` to collect events back to the root process via `owner_pid`.
254+
Allows `pid_to_allow` to collect events back to `owner_pid`'s test scope.
255+
256+
Use this when an unrelated process — one that does not appear in the
257+
current test's `$callers` chain — needs to have its captured events
258+
routed into this test's collector. Typical examples include Broadway
259+
workers, processes started by `phoenix_test_playwright`, or
260+
long-lived `GenServer`s that outlive the calling test process.
261+
262+
`pid_to_allow` may be a pid or a zero-arity function returning a pid;
263+
the function form is resolved on call and is convenient when the pid
264+
is not known until later.
265+
266+
This function is idempotent for the same `owner_pid`. It raises
267+
`ArgumentError` when `owner_pid` has not yet called `setup_sentry/1`
268+
(or `start_collecting_sentry_reports/0`), and raises when a different
269+
live test scope already owns `pid_to_allow`.
270+
271+
Cleanup is automatic: allow entries are removed when the test exits
272+
via the same `on_exit` callback registered by `setup_sentry/1`.
273+
274+
## Example
275+
276+
setup do
277+
Sentry.Test.setup_sentry()
278+
end
279+
280+
test "events from a Broadway worker are captured" do
281+
{:ok, worker_pid} = MyApp.Worker.start_link()
282+
:ok = Sentry.Test.allow_sentry_reports(self(), worker_pid)
283+
284+
send(worker_pid, :do_work_that_reports)
285+
286+
assert_receive {:done, _}
287+
assert [%Sentry.Event{}] = Sentry.Test.pop_sentry_reports()
288+
end
255289
256-
> #### Deprecated {: .warning}
257-
>
258-
> This function is deprecated and will be removed in v13.0.0.
259-
> Child processes are automatically tracked via the `$callers` mechanism.
260-
> There is no need to explicitly allow processes.
261290
"""
262-
@doc since: "10.2.0"
263-
@doc deprecated: "Child processes are now automatically tracked via $callers"
291+
@doc since: "13.0.2"
264292
@spec allow_sentry_reports(pid(), pid() | (-> pid())) :: :ok
265-
def allow_sentry_reports(_owner_pid, _pid_to_allow) do
266-
:ok
293+
def allow_sentry_reports(owner_pid, pid_or_fun) when is_pid(owner_pid) do
294+
allowed_pid = resolve_allowed_pid(pid_or_fun)
295+
296+
case Sentry.Test.Registry.claim_collector_allow(owner_pid, allowed_pid) do
297+
:ok ->
298+
# Also route per-test config overrides (DSN, before_send hooks,
299+
# the internal collector callback, etc.) through the owner's
300+
# scope so that Sentry callbacks invoked from `allowed_pid`
301+
# resolve to the same configuration the test set up.
302+
Sentry.Test.Config.allow(owner_pid, allowed_pid)
303+
:ok
304+
305+
{:error, :no_collector} ->
306+
raise ArgumentError,
307+
"owner #{inspect(owner_pid)} is not collecting Sentry reports; " <>
308+
"call Sentry.Test.setup_sentry/1 or " <>
309+
"Sentry.Test.start_collecting_sentry_reports/0 first"
310+
311+
{:error, {:taken, existing_owner}} ->
312+
raise ArgumentError,
313+
"cannot allow #{inspect(allowed_pid)} for #{inspect(owner_pid)}: " <>
314+
"already allowed by another live test scope " <>
315+
"(owner: #{inspect(existing_owner)})"
316+
end
317+
end
318+
319+
defp resolve_allowed_pid(pid) when is_pid(pid), do: pid
320+
321+
defp resolve_allowed_pid(fun) when is_function(fun, 0) do
322+
case fun.() do
323+
pid when is_pid(pid) ->
324+
pid
325+
326+
other ->
327+
raise ArgumentError,
328+
"expected the function passed to allow_sentry_reports/2 to return a pid, " <>
329+
"got: #{inspect(other)}"
330+
end
267331
end
268332

269333
@doc """
@@ -698,6 +762,10 @@ defmodule Sentry.Test do
698762
:ok
699763
end
700764
end
765+
766+
# Drop any allow_sentry_reports/2 entries pointing at this test's
767+
# collector table.
768+
:ets.match_delete(@registry_table, {:_, collector_table})
701769
end
702770

703771
if :ets.whereis(collector_table) != :undefined do

lib/sentry/test/registry.ex

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ defmodule Sentry.Test.Registry do
4747
GenServer.call(__MODULE__, {:claim_allow, owner_pid, allowed_pid, mode})
4848
end
4949

50+
@doc """
51+
Atomic claim of `allowed_pid` onto the collector table currently
52+
registered for `owner_pid`. Used by `Sentry.Test.allow_sentry_reports/2`
53+
so that processes which are not `$callers` descendants of the test
54+
(Broadway workers, `phoenix_test_playwright` drivers, long-lived
55+
GenServers, etc.) can have their captured events routed into the
56+
test's collector ETS table.
57+
58+
Returns:
59+
60+
* `:ok` — claim succeeded (or was already held by `owner_pid`)
61+
* `{:error, :no_collector}` — `owner_pid` has not called
62+
`setup_sentry/1` / `start_collecting_sentry_reports/0` yet
63+
* `{:error, {:taken, existing_owner}}` — a different live test
64+
already owns `allowed_pid`; clears stale entries from dead owners
65+
automatically.
66+
"""
67+
@spec claim_collector_allow(pid(), pid()) ::
68+
:ok | {:error, :no_collector} | {:error, {:taken, pid()}}
69+
def claim_collector_allow(owner_pid, allowed_pid)
70+
when is_pid(owner_pid) and is_pid(allowed_pid) do
71+
GenServer.call(__MODULE__, {:claim_collector_allow, owner_pid, allowed_pid})
72+
end
73+
5074
@doc """
5175
Removes every allow entry whose owner is `owner_pid`. Atomic batch
5276
delete via `:ets.match_delete/2` — safe to call from a test's on_exit
@@ -120,6 +144,57 @@ defmodule Sentry.Test.Registry do
120144
{:reply, reply, state}
121145
end
122146

147+
def handle_call({:claim_collector_allow, owner_pid, allowed_pid}, _from, state) do
148+
reply =
149+
case :ets.lookup(@table, owner_pid) do
150+
[{^owner_pid, table}] ->
151+
claim_collector_allow(allowed_pid, table, owner_pid)
152+
153+
[] ->
154+
{:error, :no_collector}
155+
end
156+
157+
{:reply, reply, state}
158+
end
159+
160+
defp claim_collector_allow(allowed_pid, table, owner_pid) do
161+
case :ets.lookup(@table, allowed_pid) do
162+
[] ->
163+
true = :ets.insert_new(@table, {allowed_pid, table})
164+
:ok
165+
166+
[{^allowed_pid, ^table}] ->
167+
:ok
168+
169+
[{^allowed_pid, other_table}] ->
170+
# Find the live owner (if any) of the conflicting table.
171+
case :ets.match(@table, {:"$1", other_table}) do
172+
[] ->
173+
true = :ets.insert(@table, {allowed_pid, table})
174+
:ok
175+
176+
owners ->
177+
live =
178+
owners
179+
|> List.flatten()
180+
|> Enum.reject(&(&1 == allowed_pid))
181+
|> Enum.find(&Process.alive?/1)
182+
183+
cond do
184+
is_nil(live) ->
185+
true = :ets.insert(@table, {allowed_pid, table})
186+
:ok
187+
188+
live == owner_pid ->
189+
:ok
190+
191+
true ->
192+
{:error, {:taken, live}}
193+
end
194+
end
195+
end
196+
end
197+
123198
# Starts a global Bypass instance that acts as a silent HTTP sink for all tests.
124199
# This ensures every test has a valid DSN even without calling setup_sentry/1,
125200
# preserving backward compatibility where capture_* returns {:ok, ""}.

test/sentry/test_test.exs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,6 @@ defmodule Sentry.TestTest do
154154
SentryTest.setup_sentry()
155155
end
156156

157-
test "allow_sentry_reports/2 is a no-op" do
158-
assert :ok = SentryTest.allow_sentry_reports(self(), self())
159-
end
160-
161157
test "start_collecting/1 is a no-op when already collecting" do
162158
assert :ok = SentryTest.start_collecting()
163159
end
@@ -167,6 +163,63 @@ defmodule Sentry.TestTest do
167163
end
168164
end
169165

166+
# Reproduction for https://github.com/getsentry/sentry-elixir/issues/1052
167+
#
168+
# When events are captured from a process that is not a `$callers`
169+
# descendant of the test (e.g. Broadway workers, processes started by
170+
# `phoenix_test_playwright`, long-lived GenServers, etc.), there is
171+
# currently no way to have those events collected by the test.
172+
#
173+
# `Sentry.Test.allow_sentry_reports/2` used to provide this manual
174+
# allowance but was turned into a no-op. These tests document the
175+
# missing behavior.
176+
describe "allow_sentry_reports/2 (issue #1052)" do
177+
setup do
178+
SentryTest.setup_sentry()
179+
end
180+
181+
test "events from a process without $callers are not collected" do
182+
test_pid = self()
183+
184+
pid =
185+
spawn(fn ->
186+
assert [] == Process.get(:"$callers", [])
187+
assert {:ok, _} = Sentry.capture_message("from unrelated process", result: :sync)
188+
send(test_pid, :done)
189+
end)
190+
191+
ref = Process.monitor(pid)
192+
assert_receive :done, 5000
193+
assert_receive {:DOWN, ^ref, :process, ^pid, _}, 5000
194+
195+
assert SentryTest.pop_sentry_reports() == []
196+
end
197+
198+
test "allow_sentry_reports/2 should let an unrelated process report into the test" do
199+
test_pid = self()
200+
201+
pid =
202+
spawn(fn ->
203+
receive do
204+
:go ->
205+
assert {:ok, _} = Sentry.capture_message("from allowed process", result: :sync)
206+
send(test_pid, :done)
207+
end
208+
end)
209+
210+
ref = Process.monitor(pid)
211+
212+
assert :ok = SentryTest.allow_sentry_reports(self(), pid)
213+
214+
send(pid, :go)
215+
assert_receive :done, 5000
216+
assert_receive {:DOWN, ^ref, :process, ^pid, _}, 5000
217+
218+
assert [%Sentry.Event{} = event] = SentryTest.pop_sentry_reports()
219+
assert event.message.formatted == "from allowed process"
220+
end
221+
end
222+
170223
describe "before_send wrapping" do
171224
test "wraps existing before_send callback" do
172225
test_pid = self()

test_integrations/phoenix_app/test/phoenix_app/oban_test.exs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do
44

55
import ExUnit.CaptureLog
66
import Sentry.TestHelpers
7-
7+
88
require OpenTelemetry.Tracer
99

1010
alias Sentry.Integrations.Oban.ErrorReporter
@@ -438,4 +438,105 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do
438438
assert event["tags"]["oban_worker"] == "NonExistent.Worker.Module"
439439
end
440440
end
441+
442+
describe "allow_sentry_reports/2 with a real Oban worker process" do
443+
setup do
444+
Sentry.Test.setup_sentry()
445+
Sentry.Integrations.Oban.ErrorReporter.attach()
446+
on_exit(fn -> :telemetry.detach(Sentry.Integrations.Oban.ErrorReporter) end)
447+
end
448+
449+
test "events from the worker process are dropped without an explicit allow" do
450+
run_failing_worker_in_detached_process(before_perform: fn -> :ok end)
451+
452+
assert [] == Sentry.Test.pop_sentry_reports()
453+
end
454+
455+
test "events from the worker process are captured when allowed via a telemetry hook" do
456+
test_pid = self()
457+
handler_id = {:sentry_allow_test, System.unique_integer([:positive])}
458+
459+
try do
460+
:telemetry.attach(
461+
handler_id,
462+
[:oban, :job, :start],
463+
fn _event, _measurements, _metadata, _config ->
464+
Sentry.Test.allow_sentry_reports(test_pid, self())
465+
end,
466+
nil
467+
)
468+
469+
run_failing_worker_in_detached_process(before_perform: fn -> :ok end)
470+
after
471+
:telemetry.detach(handler_id)
472+
end
473+
474+
assert [%Sentry.Event{} = event] = Sentry.Test.pop_sentry_reports()
475+
476+
assert [exception] = event.exception
477+
assert exception.type == "RuntimeError"
478+
assert exception.value == "intentional failure for testing"
479+
480+
assert event.tags[:oban_worker] ==
481+
"Sentry.Integrations.Phoenix.ObanTest.FailingWorker"
482+
end
483+
end
484+
485+
defp run_failing_worker_in_detached_process(opts) do
486+
parent = self()
487+
ref = make_ref()
488+
489+
spawn(fn ->
490+
Keyword.fetch!(opts, :before_perform).()
491+
492+
job = %Oban.Job{
493+
id: System.unique_integer([:positive]),
494+
args: %{"should_fail" => true},
495+
worker: "Sentry.Integrations.Phoenix.ObanTest.FailingWorker",
496+
queue: "background",
497+
attempt: 1,
498+
max_attempts: 1,
499+
meta: %{},
500+
inserted_at: DateTime.utc_now(),
501+
scheduled_at: DateTime.utc_now(),
502+
attempted_at: DateTime.utc_now()
503+
}
504+
505+
start_metadata = %{job: job, conf: %{name: Oban}}
506+
:telemetry.execute([:oban, :job, :start], %{system_time: System.system_time()}, start_metadata)
507+
508+
{kind, reason, stacktrace} =
509+
try do
510+
FailingWorker.perform(job)
511+
{:ok, nil, []}
512+
catch
513+
kind, reason -> {kind, reason, __STACKTRACE__}
514+
end
515+
516+
exception_metadata =
517+
Map.merge(start_metadata, %{
518+
kind: kind,
519+
reason: reason,
520+
error: reason,
521+
stacktrace: stacktrace,
522+
state: :failure
523+
})
524+
525+
:telemetry.execute(
526+
[:oban, :job, :exception],
527+
%{duration: 0},
528+
exception_metadata
529+
)
530+
531+
send(parent, {ref, :done})
532+
end)
533+
534+
receive do
535+
{^ref, :done} -> :ok
536+
after
537+
5_000 -> flunk("worker process did not finish in time")
538+
end
539+
540+
Process.sleep(50)
541+
end
441542
end

0 commit comments

Comments
 (0)