Packages
sentry
13.2.0
13.3.0
13.2.0
13.1.0
13.0.1
13.0.0
12.0.3
12.0.2
12.0.1
12.0.0
11.0.4
11.0.3
11.0.2
11.0.1
11.0.0
10.10.0
10.9.0
10.8.1
10.8.0
10.7.1
10.7.0
10.6.2
10.6.1
10.6.0
10.5.0
10.4.0
10.3.0
10.2.1
10.2.0
10.2.0-rc.2
10.2.0-rc.1
10.1.0
10.0.3
10.0.2
10.0.1
10.0.0
9.1.0
9.0.0
8.1.0
8.0.6
8.0.5
8.0.4
8.0.3
8.0.2
8.0.1
8.0.0
8.0.0-rc.2
8.0.0-rc.1
8.0.0-rc.0
retired
7.2.5
7.2.4
7.2.3
7.2.2
7.2.1
7.2.0
7.1.0
7.0.6
7.0.5
7.0.4
7.0.3
7.0.2
7.0.1
7.0.0
6.4.2
6.4.1
6.4.0
6.3.0
6.2.1
6.2.0
6.1.0
6.0.5
6.0.4
6.0.3
6.0.2
6.0.1
6.0.0
5.0.1
5.0.0
4.0.3
4.0.2
4.0.1
4.0.0
3.0.0
2.2.0
2.1.0
2.0.2
2.0.1
2.0.0
1.1.2
1.1.1
1.1.0
1.0.0
0.3.2
0.3.1
0.3.0
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
The Official Elixir client for Sentry
Current section
Files
Jump to
Current section
Files
lib/sentry/test/scope/registry.ex
defmodule Sentry.Test.Scope.Registry do
@moduledoc false
# Lifecycle and resolution for `Sentry.Test.Scope`.
#
# Storage:
#
# * `{:sentry_test_scope, owner_pid} -> %Scope{}` in `:persistent_term` —
# one entry per active test scope, owns its overrides.
# * `:sentry_test_pid_routing` ETS table (named, public, set) —
# single merged routing table owned by `Sentry.Test.Registry`.
# Rows are 3-tuples
# `{allowed_pid, owner_pid_or_nil, processor_name_or_nil}`: the
# owner field is the reverse index mapping each explicitly-routed
# pid back to the scope that claimed it (read here via
# `lookup_allow_owner/1`); the processor field routes buffered
# events (logs, metrics) from that pid to a per-test
# `Sentry.TelemetryProcessor`. Direct ETS reads on the config
# read path; conflict-checked writes serialize through that
# GenServer for atomic check-and-insert. Owner exit is handled by
# that GenServer's `:DOWN` monitor (routing-row prune +
# `handle_owner_down/1`), not `ExUnit.Callbacks.on_exit/1`.
# * `@counter_key -> :counters.t()` — atomic counter for cheap
# "any active scopes?" short-circuits, so config reads in
# production cost essentially nothing.
#
# Resolution (`resolve/1`) tries three strategies in order, each O(1)
# per candidate pid (no iteration over active scopes):
#
# 1. by_callers — walk `[pid | $callers]`; try `fetch/1` on each
# (the candidate is itself a scope owner) and the
# reverse-allow lookup (the candidate is allowed
# by some scope).
# 2. by_ancestor — walk ancestors transitively; try `fetch/1` on
# each (an ancestor is a scope owner — covers
# GenServers started via `start_supervised/1`).
# 3. by_allow — walk `[pid | ancestors]`; reverse-allow lookup
# for each candidate (the pid was explicitly
# routed onto a scope via
# `Sentry.Test.allow_sentry_reports/2` /
# `Sentry.Test.Config.allow/2`, or auto-allowed in
# `Sentry.Test.Config.put/1` — all of which claim
# through `Sentry.Test.Registry.claim_allow/3`).
#
# Globally-supervised processes (`:logger`, `:logger_sup`,
# `Sentry.Supervisor`) have no caller/ancestor link to any test and
# rely on strategy 3. `Sentry.Test.Config.put/1` soft-allows them onto
# the calling scope so routing is explicit and bounded to the owning
# test. An implicit "single active scope" fallback would otherwise
# route every stray log event (e.g. OTP's asynchronous
# callback-crashed meta-event emitted after a handler callback raises)
# to whichever test happens to be active when the event arrives — a
# cross-test leak we cannot tolerate.
alias Sentry.Test.Registry, as: TestRegistry
alias Sentry.Test.Scope
@counter_key :sentry_test_scope_counter
@scope_key :sentry_test_scope
@ancestor_walk_depth 8
# `Process.info(pid, :parent)` was added in OTP 25. On older releases the
# call raises `ArgumentError` for an unrecognized info item, so we gate the
# parent-pid walk at compile time instead. On OTP < 25 the ancestor walk
# falls back to `$ancestors` only — losing parent-link resolution for
# processes started via `spawn/spawn_monitor`, but the SDK still works.
@otp_release :erlang.system_info(:otp_release) |> List.to_integer()
@supports_parent_info @otp_release >= 25
@type resolution :: {:ok, Scope.t()} | :none
@spec maybe_init() :: :ok
def maybe_init do
case :persistent_term.get(@counter_key, nil) do
nil -> :persistent_term.put(@counter_key, :counters.new(1, [:atomics]))
_ref -> :ok
end
:ok
end
@spec fetch(pid()) :: {:ok, Scope.t()} | :error
def fetch(owner_pid) when is_pid(owner_pid) do
case :persistent_term.get({@scope_key, owner_pid}, :__not_set__) do
:__not_set__ -> :error
%Scope{} = scope -> {:ok, scope}
end
end
@doc """
Atomically updates the scope owned by `owner_pid`, creating a new
scope on first call and asking `Sentry.Test.Registry` to monitor
the owner pid so cleanup runs from the registry's `:DOWN` handler
when the owner exits.
Not concurrency-safe across processes — each test only mutates its
own scope from its own process, so in practice there is no
contention.
"""
@spec update(pid(), (Scope.t() -> Scope.t())) :: Scope.t()
def update(owner_pid, fun) when is_pid(owner_pid) and is_function(fun, 1) do
scope =
case fetch(owner_pid) do
{:ok, existing} ->
existing
:error ->
new_scope = Scope.new(owner_pid)
bump_counter()
TestRegistry.monitor_owner(owner_pid)
new_scope
end
updated = fun.(scope)
:persistent_term.put({@scope_key, owner_pid}, updated)
updated
end
@doc """
Returns the live owner_pid that has explicitly allowed `allowed_pid`,
or `nil` when no live scope owns it. Direct ETS read; safe to call
on the hot config-read path.
"""
@spec lookup_allow_owner(pid()) :: pid() | nil
def lookup_allow_owner(allowed_pid) when is_pid(allowed_pid) do
TestRegistry.lookup_allow_owner(allowed_pid)
end
@doc """
Strict claim: routes `allowed_pid` onto `owner_pid`'s scope. Raises
`Sentry.Test.Scope.AllowConflictError` when a live peer scope already
owns the pid. Idempotent for the same owner. The check-and-insert is
atomic — see `Sentry.Test.Registry.claim_allow/3`.
"""
@spec strict_allow!(pid(), pid()) :: :ok
def strict_allow!(owner_pid, allowed_pid) when is_pid(owner_pid) and is_pid(allowed_pid) do
case TestRegistry.claim_allow(owner_pid, allowed_pid, :strict) do
:ok ->
:ok
{:error, {:taken, existing_owner}} ->
raise Sentry.Test.Scope.AllowConflictError,
allowed_pid: allowed_pid,
existing_owner: existing_owner,
new_owner: owner_pid
end
end
@doc """
Soft claim: routes `allowed_pid` onto `owner_pid`'s scope when free.
Silent no-op when a live peer scope already owns the pid (the first
active scope to claim a global wins; concurrent scopes fall through
to the default config rather than to another test's scope). `nil`
pids — e.g. from `Process.whereis/1` for an unregistered name — are
ignored. The check-and-insert is atomic — see
`Sentry.Test.Registry.claim_allow/3`.
"""
@spec soft_allow(pid(), pid() | nil) :: :ok
def soft_allow(_owner_pid, nil), do: :ok
def soft_allow(owner_pid, allowed_pid) when is_pid(owner_pid) and is_pid(allowed_pid) do
case TestRegistry.claim_allow(owner_pid, allowed_pid, :soft) do
:ok -> :ok
:skipped -> :ok
end
end
@doc """
Scope-state half of owner cleanup. Erases the persistent_term scope
entry and decrements the active-scope counter. Called from
`Sentry.Test.Registry`'s `:DOWN` handler — the routing-table half
is pruned there, atomically with monitor-map removal.
Idempotent: only decrements the counter when the entry was actually
present, so duplicate DOWNs cannot drive it negative.
"""
@spec handle_owner_down(pid()) :: :ok
def handle_owner_down(owner_pid) when is_pid(owner_pid) do
case :persistent_term.get({@scope_key, owner_pid}, :__not_set__) do
:__not_set__ ->
:ok
%Scope{} ->
:persistent_term.erase({@scope_key, owner_pid})
decrement_counter()
:ok
end
end
@spec active_count() :: non_neg_integer()
def active_count do
case :persistent_term.get(@counter_key, nil) do
nil -> 0
ref -> :counters.get(ref, 1)
end
end
@doc """
Returns the override for `key` from the first scope on `[self() | $callers]`
that has it set, or `:default` if no direct caller has overridden it.
Bypasses the full `resolve/1` chain (no allow / ancestor walk) so
callers — see `Sentry.Test.setup_collector/1` — can read this test's
"original" config value without picking up a concurrent test's
wrapping callback.
"""
@spec lookup_caller_override(atom()) :: {:ok, term()} | :default
def lookup_caller_override(key) when is_atom(key) do
pids = [self() | Process.get(:"$callers", [])]
Enum.find_value(pids, :default, fn pid ->
with {:ok, scope} <- fetch(pid),
{:ok, _value} = found <- Scope.fetch_override(scope, key) do
found
else
_ -> nil
end
end)
end
@doc """
Returns the list of currently-active scope structs. Scans
`:persistent_term.get/0` (an O(N) operation across the whole BEAM
term table) — reserve for non-hot-path uses (debugging, tests of the
registry itself). Resolution uses the reverse-index lookups above.
"""
@spec list_active() :: [Scope.t()]
def list_active do
for {{@scope_key, pid}, %Scope{} = scope} <- :persistent_term.get(),
Process.alive?(pid),
do: scope
end
@spec resolve(pid()) :: resolution()
def resolve(pid) when is_pid(pid) do
if active_count() == 0 do
:none
else
with :none <- resolve_by_callers(pid),
:none <- resolve_by_ancestor(pid),
:none <- resolve_by_allow(pid) do
:none
end
end
end
## Private helpers
defp resolve_by_callers(pid) do
candidates = [pid | Process.get(:"$callers", [])]
resolve_via_owner_or_allow(candidates)
end
defp resolve_by_ancestor(pid) do
ancestors = collect_ancestors(pid, @ancestor_walk_depth, MapSet.new([pid]))
resolve_via_owner(ancestors)
end
defp resolve_by_allow(pid) do
candidates = [pid | collect_ancestors(pid, @ancestor_walk_depth, MapSet.new([pid]))]
resolve_via_allow(candidates)
end
# Two scope-cleanup races can return `:error` here: a scope can be
# unregistered between when we look it up and when we'd act on it
# (`fetch(candidate)` and `fetch(owner)`). In both cases recurse to
# the next candidate so the caller never sees `:error` — only
# `{:ok, scope}` or `:none`. Letting `:error` escape would
# short-circuit the `with` chain in `resolve/1` with a non-`:none`
# value and crash the `case` in `Sentry.Test.Config.namespace/1`.
defp resolve_via_owner_or_allow([]), do: :none
defp resolve_via_owner_or_allow([candidate | rest]) do
case fetch(candidate) do
{:ok, _scope} = found ->
found
:error ->
case lookup_allow_owner(candidate) do
nil ->
resolve_via_owner_or_allow(rest)
owner ->
case fetch(owner) do
{:ok, _scope} = found -> found
:error -> resolve_via_owner_or_allow(rest)
end
end
end
end
defp resolve_via_owner([]), do: :none
defp resolve_via_owner([candidate | rest]) do
case fetch(candidate) do
{:ok, _scope} = found -> found
:error -> resolve_via_owner(rest)
end
end
defp resolve_via_allow([]), do: :none
defp resolve_via_allow([candidate | rest]) do
case lookup_allow_owner(candidate) do
nil ->
resolve_via_allow(rest)
owner ->
case fetch(owner) do
{:ok, _scope} = found -> found
:error -> resolve_via_allow(rest)
end
end
end
defp bump_counter do
case :persistent_term.get(@counter_key, nil) do
nil -> :ok
ref -> :counters.add(ref, 1, 1)
end
end
defp decrement_counter do
case :persistent_term.get(@counter_key, nil) do
nil -> :ok
ref -> :counters.sub(ref, 1, 1)
end
end
defp collect_ancestors(_pid, 0, _seen), do: []
defp collect_ancestors(pid, depth, seen) do
direct = pid |> ancestors_of() |> Enum.reject(&MapSet.member?(seen, &1))
seen = Enum.reduce(direct, seen, &MapSet.put(&2, &1))
{collected_reversed, _final_seen} =
Enum.reduce(direct, {[], seen}, fn ancestor, {acc, seen_acc} ->
sub = collect_ancestors(ancestor, depth - 1, seen_acc)
seen_acc = Enum.reduce(sub, seen_acc, &MapSet.put(&2, &1))
{Enum.reverse(sub) ++ acc, seen_acc}
end)
direct ++ Enum.reverse(collected_reversed)
end
defp ancestors_of(pid) when is_pid(pid) do
dict_ancestors =
case Process.info(pid, :dictionary) do
{:dictionary, dict} ->
dict
|> Keyword.get(:"$ancestors", [])
|> Enum.map(fn
name when is_atom(name) -> Process.whereis(name)
p when is_pid(p) -> p
_ -> nil
end)
nil ->
[]
end
(dict_ancestors ++ parent_of(pid))
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
defp ancestors_of(_), do: []
if @supports_parent_info do
defp parent_of(pid) do
case Process.info(pid, :parent) do
{:parent, parent_pid} when is_pid(parent_pid) -> [parent_pid]
{:parent, _} -> []
nil -> []
end
end
else
defp parent_of(_pid), do: []
end
end
defmodule Sentry.Test.Scope.AllowConflictError do
@moduledoc false
defexception [:allowed_pid, :existing_owner, :new_owner]
@impl true
def message(%{allowed_pid: allowed, existing_owner: existing, new_owner: new_owner}) do
"cannot allow #{inspect(allowed)} under #{inspect(new_owner)}: " <>
"it is already allowed by #{inspect(existing)}"
end
end