Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
{"test/support/test_helpers.ex"},
{"lib/sentry/opentelemetry/sampler.ex", :pattern_match, 1},
{"lib/sentry/application.ex", :unmatched_return},
{"lib/sentry/test.ex"}
{"lib/sentry/test.ex"},
{"lib/sentry/test/registry.ex"}
]
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ jobs:
with:
path: |
deps
test_integrations/umbrella/deps
test_integrations/phoenix_app/deps
test_integrations/legacy_otel/deps
key: |
${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-deps-${{ hashFiles('**/mix.lock') }}
Expand Down
12 changes: 12 additions & 0 deletions lib/sentry/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,7 @@ defmodule Sentry.Config do
case NimbleOptions.validate(config_opts, @opts_schema) do
{:ok, opts} ->
opts
|> validate_test_mode_env()
|> normalize_included_environments()
|> normalize_environment()
|> handle_deprecated_before_send()
Expand Down Expand Up @@ -1060,6 +1061,17 @@ defmodule Sentry.Config do
end
end

defp validate_test_mode_env(opts) do
if Keyword.fetch!(opts, :test_mode) and not Code.ensure_loaded?(ExUnit) do
raise ArgumentError, """
test_mode: true is only allowed in the test environment. \
Remove it from your non-test configuration.
"""
end

opts
end
Comment thread
sentry[bot] marked this conversation as resolved.

# TODO: remove me on v11.0.0, :included_environments has been deprecated
# in v10.0.0.
defp normalize_included_environments(config) do
Expand Down
17 changes: 16 additions & 1 deletion lib/sentry/logger_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ defmodule Sentry.LoggerHandler do
default: Sentry.TelemetryProcessor,
type_doc: "`t:GenServer.server/0`",
doc: false
],
enable_logs: [
type: {:or, [:boolean, nil]},
default: nil,
doc: false
]
]

Expand Down Expand Up @@ -255,6 +260,7 @@ defmodule Sentry.LoggerHandler do
:sync_threshold,
:discard_threshold,
:telemetry_processor,
:enable_logs,
backends: []
]

Expand All @@ -269,7 +275,16 @@ defmodule Sentry.LoggerHandler do

handler_config = cast_config(%__MODULE__{}, sentry_config)

backends = [ErrorBackend] ++ if Config.enable_logs?(), do: [LogsBackend], else: []
# When :enable_logs is set on the handler config, it takes precedence over
# the global Config.enable_logs?() — which may not resolve correctly in the
# logger-server process during async tests.
enable_logs? =
case handler_config.enable_logs do
nil -> Config.enable_logs?()
bool -> bool
end

backends = [ErrorBackend] ++ if enable_logs?, do: [LogsBackend], else: []
handler_config = %{handler_config | backends: backends}

config = Map.put(config, :config, handler_config)
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
25 changes: 20 additions & 5 deletions lib/sentry/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -396,18 +396,22 @@ defmodule Sentry.Test do
{user_before_send_event, extra_config} = Keyword.pop(extra_config, :before_send_event)
{user_before_send_log, extra_config} = Keyword.pop(extra_config, :before_send_log)

# Use the caller-only registry lookup instead of `Sentry.Config.before_send/0`
# so the captured "original" callback is only this test's override (or the
# global default), never another concurrent test's wrapping callback.
original_before_send =
user_before_send || user_before_send_event || Sentry.Config.before_send()
user_before_send || user_before_send_event ||
original_config_value(:before_send)

original_before_send_log = user_before_send_log || Sentry.Config.before_send_log()
original_before_send_log =
user_before_send_log || original_config_value(:before_send_log)

# Build collecting callbacks that wrap the originals
new_before_send = build_collecting_callback(original_before_send)
new_before_send_log = build_collecting_callback(original_before_send_log)

# Always set a per-test DSN override so that config resolution never falls
# through to resolve_from_active_scopes (which could pick up another async
# test's DSN). When no DSN is provided, use the default Bypass DSN.
# Always set a per-test DSN override. When no DSN is provided, use the
# default Bypass DSN.
extra_config =
if Keyword.has_key?(extra_config, :dsn) do
extra_config
Expand Down Expand Up @@ -483,6 +487,17 @@ defmodule Sentry.Test do
end)
end

# Reads `key` from this test's per-process scope (or any caller's scope on
# `[self() | $callers]`), falling back to the global config value. Skips the
# full namespace resolver so the captured "original" callback is never
# another concurrent test's wrapping callback.
defp original_config_value(key) do
case Sentry.Test.Scope.Registry.lookup_caller_override(key) do
{:ok, value} -> value
:default -> :persistent_term.get({:sentry_config, key}, nil)
end
end

defp build_collecting_callback(nil) do
fn struct ->
case find_collector() do
Expand Down
183 changes: 64 additions & 119 deletions lib/sentry/test/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,37 @@ defmodule Sentry.Test.Config do

## How It Works

Per-test overrides are stored in `:persistent_term` keyed by `{:sentry_config, test_pid, key}`.
The `namespace/1` function walks the current process's caller chain (`$callers`) to find
overrides set by the test process, so child processes (e.g., `Task`s) automatically
inherit the test's configuration.
Each test's overrides live in a `Sentry.Test.Scope` struct stored in
`:persistent_term` under `{:sentry_test_scope, test_pid}`. The `namespace/1`
function asks `Sentry.Test.Scope.Registry` to resolve a scope for the
current process by trying three strategies in order:

1. Walking `[self() | Process.get(:"$callers", [])]`.
2. Walking the `:"$ancestors"` chain transitively against each scope's
`allowed_pids` (populated via `allow/2` and by the auto-allow of
globally-supervised pids on the first `put/1` call).
3. Walking the `:"$ancestors"` chain against each scope's owner pid —
covers GenServers started via `start_supervised/1`.

Globally-supervised processes (`:logger`, `:logger_sup`,
`Sentry.Supervisor`) have no caller/ancestor link back to any test.
`put/1` auto-soft-allows them onto the calling scope so strategy 2
routes their config queries to the right test transparently, without
requiring downstream suites to call `allow/2` themselves.

Overrides are automatically cleaned up when the test exits via
`ExUnit.Callbacks.on_exit/1`.
"""

For processes that don't have `$callers` pointing to the test process (such as
GenServers started via `start_supervised!/1`), use `allow/2` to explicitly grant
them access to the test's configuration.
alias Sentry.Test.Scope
alias Sentry.Test.Scope.Registry

Overrides are automatically cleaned up when the test exits via `ExUnit.Callbacks.on_exit/1`.
"""
# Globally-supervised pids that the SDK needs to route per-test config
# through (log-handler lifecycle + the SDK supervisor). Auto-allowed onto
# every scope created via `put/1` so downstream test suites do not have
# to opt in explicitly. Atoms are resolved lazily at auto-allow time via
# `Process.whereis/1` — they may not be registered when the app boots.
@auto_allow_globals [:logger, :logger_sup, Sentry.Supervisor]

@doc """
Activates per-test configuration isolation if `test_mode: true` is configured
Expand All @@ -40,7 +60,7 @@ defmodule Sentry.Test.Config do
def maybe_activate do
if Sentry.Config.test_mode?() and Sentry.Config.namespace() == {Sentry.Config, :namespace} do
:persistent_term.put({:sentry_config, :namespace}, {__MODULE__, :namespace})
:persistent_term.put(:sentry_test_config_scope_counter, :counters.new(1, [:atomics]))
Registry.maybe_init()
end

:ok
Expand All @@ -49,35 +69,20 @@ defmodule Sentry.Test.Config do
@doc """
Resolves config namespace for the current process.

The resolution order is:

1. Walks `[self() | Process.get(:"$callers", [])]` looking for per-test overrides.
2. Checks whether the process was explicitly allowed via `allow/2`.
3. As a last resort, scans all active test scopes (registered via `put/1`).
This fallback only applies when exactly one scope is active, making it
safe for `async: false` tests only.

Returns `{:ok, value}` if an override is found, or `:default` to fall back
to global configuration.
"""
@spec namespace(atom()) :: {:ok, term()} | :default
def namespace(key) do
scopes = [self() | Process.get(:"$callers", [])]

case find_override(scopes, key) do
{:ok, _value} = found ->
found

:default ->
# Check if this process was explicitly allowed by a test process via allow/2.
case :persistent_term.get({:sentry_test_config_allowed, self()}, nil) do
nil ->
# Last resort: scan all active test scopes (safe only for async: false tests).
resolve_from_active_scopes(key)

owner_pid ->
find_override([owner_pid], key)
case Registry.resolve(self()) do
{:ok, scope} ->
case Scope.fetch_override(scope, key) do
{:ok, value} -> {:ok, value}
:error -> :default
end

:none ->
:default
end
end

Expand All @@ -99,43 +104,32 @@ defmodule Sentry.Test.Config do
"""
@spec put(keyword()) :: :ok
def put(config) when is_list(config) do
test_pid = self()

original_config =
for {key, val} <- config do
renamed_key =
case key do
:before_send_event -> :before_send
other -> other
end
entries = Enum.map(config, &validate_and_rename/1)

validated_config = Sentry.Config.validate!([{renamed_key, val}])
validated_val = Keyword.fetch!(validated_config, renamed_key)
_ =
Registry.update(self(), fn scope ->
Enum.reduce(entries, scope, fn {key, value}, acc ->
Scope.put_override(acc, key, value)
end)
end)

:persistent_term.put({:sentry_config, test_pid, renamed_key}, validated_val)

{renamed_key, validated_val}
end

register_scope(test_pid)

ExUnit.Callbacks.on_exit(fn ->
for {key, _val} <- original_config do
:persistent_term.erase({:sentry_config, test_pid, key})
end

unregister_scope(test_pid)
end)
auto_allow_globals()

:ok
end

defp auto_allow_globals do
owner = self()
Enum.each(@auto_allow_globals, &Registry.soft_allow(owner, Process.whereis(&1)))
end

@doc """
Allows `allowed_pid` to read the configuration of `owner_pid`'s test scope.

Use this when a supervised process (such as a `GenServer` started via
`start_supervised!/1`) does not inherit the test process's `$callers` chain
and therefore cannot resolve per-test configuration overrides on its own.
and cannot be reached via the `$ancestors` walk (for example, a
globally-registered process started at application boot).

The mapping is automatically cleaned up when the test exits.

Expand All @@ -145,72 +139,23 @@ defmodule Sentry.Test.Config do
Sentry.Test.Config.allow(self(), scheduler_pid)

"""
@spec allow(pid(), pid()) :: :ok
Comment thread
cursor[bot] marked this conversation as resolved.
def allow(owner_pid, allowed_pid) do
:persistent_term.put({:sentry_test_config_allowed, allowed_pid}, owner_pid)
@spec allow(pid(), pid() | nil) :: :ok
def allow(_owner_pid, nil), do: :ok

ExUnit.Callbacks.on_exit(fn ->
:persistent_term.erase({:sentry_test_config_allowed, allowed_pid})
end)

:ok
def allow(owner_pid, allowed_pid) when is_pid(owner_pid) and is_pid(allowed_pid) do
Registry.strict_allow!(owner_pid, allowed_pid)
end

## Private helpers

defp find_override(scopes, key) do
Enum.find_value(scopes, :default, fn pid ->
case :persistent_term.get({:sentry_config, pid, key}, :__not_set__) do
:__not_set__ -> nil
value -> {:ok, value}
end
end)
end

defp resolve_from_active_scopes(key) do
# Short-circuit when no test scopes are registered to avoid scanning
# all persistent terms via :persistent_term.get() on every config read.
if scope_count() == 0 do
:default
else
overrides =
for {{:sentry_test_config_scope, pid}, true} <- :persistent_term.get(),
Process.alive?(pid),
value = :persistent_term.get({:sentry_config, pid, key}, :__not_set__),
value != :__not_set__,
do: value

case overrides do
[single_value] -> {:ok, single_value}
_zero_or_ambiguous -> :default
defp validate_and_rename({key, value}) do
renamed =
case key do
:before_send_event -> :before_send
other -> other
end
end
end

defp scope_count do
case :persistent_term.get(:sentry_test_config_scope_counter, nil) do
nil -> 0
ref -> :counters.get(ref, 1)
end
end

defp register_scope(pid) do
already_registered? = :persistent_term.get({:sentry_test_config_scope, pid}, false)
:persistent_term.put({:sentry_test_config_scope, pid}, true)

unless already_registered? do
:counters.add(:persistent_term.get(:sentry_test_config_scope_counter), 1, 1)
end
end

defp unregister_scope(pid) do
case :persistent_term.get({:sentry_test_config_scope, pid}, false) do
true ->
:persistent_term.erase({:sentry_test_config_scope, pid})
:counters.sub(:persistent_term.get(:sentry_test_config_scope_counter), 1, 1)

false ->
:ok
end
validated = Sentry.Config.validate!([{renamed, value}])
{renamed, Keyword.fetch!(validated, renamed)}
end
end
Loading
Loading