Skip to content
132 changes: 103 additions & 29 deletions lib/sentry/test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ defmodule Sentry.Test do
Opens a Bypass on a random port, configures the DSN to point to it,
wires up `before_send` / `before_send_log` callbacks to capture structs
in an isolated ETS table, and starts a per-test `Sentry.TelemetryProcessor`
(via `setup_telemetry_processor/0`) so that assertions work for events
(via `setup_telemetry_processor/1`) so that assertions work for events
that travel through the TelemetryProcessor pipeline (logs, metrics, or
`send_result: :none`).

Expand All @@ -79,6 +79,23 @@ defmodule Sentry.Test do
Any extra Sentry config options (e.g., `dedup_events: false`, `traces_sample_rate: 1.0`)
will be forwarded to the test config.

The reserved `:telemetry_processor` option is *not* forwarded to the test
config. Instead, its value (a keyword list) is passed to the per-test
`Sentry.TelemetryProcessor` (e.g. `buffer_configs`, `buffer_capacities`,
`scheduler_weights`, `transport_capacity`). This replaces the need to
manually `stop_supervised!/1` and re-`start_supervised!/2` the processor.

The reserved `:collect_envelopes` option is *not* forwarded to the test
config either. When set, a Bypass envelope collector is wired up
automatically and its reference is returned under the `:ref` key:

* `true` — set up the collector with no options;
* a keyword list — forwarded to `setup_bypass_envelope_collector/2`
(e.g. `[type: "check_in"]` to only collect a given item type).

This collapses the common `bypass = setup_sentry(...); ref =
setup_bypass_envelope_collector(bypass)` two-step into one call.

## Examples

setup do
Expand All @@ -89,28 +106,38 @@ defmodule Sentry.Test do
Sentry.Test.setup_sentry(dedup_events: false)
end

Replacing the auto-started processor with a custom-configured one:
Configuring the per-test processor (e.g. a smaller log batch size):

setup do
%{telemetry_processor: name} = ctx = Sentry.Test.setup_sentry()
stop_supervised!(name)

start_supervised!(
{Sentry.TelemetryProcessor,
name: name, buffer_configs: %{log: %{batch_size: 1}}},
id: name
Sentry.Test.setup_sentry(
telemetry_processor: [buffer_configs: %{log: %{batch_size: 1}}]
)
end

ctx
Collecting envelopes directly as the ExUnit setup return:

setup do
Sentry.Test.setup_sentry(collect_envelopes: true, traces_sample_rate: 1.0)
end

test "...", %{ref: ref} do
# ...
end

"""
@doc since: "13.0.0"
@spec setup_sentry(keyword()) :: %{bypass: term(), telemetry_processor: atom()}
@spec setup_sentry(keyword()) :: %{
:bypass => term(),
:telemetry_processor => atom(),
optional(:ref) => reference()
}
def setup_sentry(extra_config \\ []) do
ensure_bypass_loaded!()
ensure_nimble_ownership_loaded!()

{tp_opts, extra_config} = Keyword.pop(extra_config, :telemetry_processor, [])
{collect_envelopes, extra_config} = Keyword.pop(extra_config, :collect_envelopes, false)

# Open a per-test Bypass and stub the envelope endpoint
bypass = Bypass.open()

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

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

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

%{bypass: bypass, telemetry_processor: processor_name}
case collect_envelopes do
false ->
%{bypass: bypass, telemetry_processor: processor_name}

collect ->
collector_opts = if is_list(collect), do: collect, else: []

%{
bypass: bypass,
telemetry_processor: processor_name,
ref: setup_bypass_envelope_collector(bypass, collector_opts)
}
end
end

@doc """
Expand All @@ -153,34 +192,69 @@ defmodule Sentry.Test do
Must be called from within an ExUnit test because it uses
`ExUnit.Callbacks.start_supervised!/2` for automatic cleanup.

If a per-test processor is already registered for this test (for example
when using `Sentry.Case`), this function is idempotent and returns the
existing processor name instead of starting a new one.
## Options

`tp_opts` is a keyword list forwarded to the per-test
`Sentry.TelemetryProcessor` child spec (e.g. `buffer_configs`,
`buffer_capacities`, `scheduler_weights`, `transport_capacity`).

Idempotency depends on `tp_opts`:

* with no `tp_opts`, an already-registered live processor (for example
one started by `Sentry.Case`) is reused and its name returned;
* with `tp_opts`, an already-registered live processor is stopped and
restarted under the same name with the given options, so callers no
longer need to `stop_supervised!/1` + `start_supervised!/2` manually.
"""
@doc since: "13.0.0"
@spec setup_telemetry_processor() :: atom()
def setup_telemetry_processor do
@spec setup_telemetry_processor(keyword()) :: atom()
def setup_telemetry_processor(tp_opts \\ []) do
case Process.get(:sentry_telemetry_processor) do
name when is_atom(name) and not is_nil(name) ->
if processor_alive?(name), do: name, else: start_telemetry_processor()
cond do
not processor_alive?(name) -> start_telemetry_processor(tp_opts)
tp_opts == [] -> name
true -> restart_telemetry_processor(name, tp_opts)
end

_ ->
start_telemetry_processor()
start_telemetry_processor(tp_opts)
end
end

defp start_telemetry_processor do
defp start_telemetry_processor(tp_opts) do
uid = System.unique_integer([:positive])
processor_name = :"test_telemetry_processor_#{uid}"

ExUnit.Callbacks.start_supervised!(
{Sentry.TelemetryProcessor,
name: processor_name, processor_resolver: &Sentry.Test.Registry.lookup_processor_for/1},
id: processor_name
)
start_processor_child(processor_name, tp_opts)

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

tag_scheduler(processor_name)
processor_name
end

defp restart_telemetry_processor(name, tp_opts) do
ExUnit.Callbacks.stop_supervised!(name)
start_processor_child(name, tp_opts)
# The process dictionary already holds `name`; the new scheduler pid
# must be re-tagged since the old one died with the old supervisor.
tag_scheduler(name)
name
end

defp start_processor_child(name, tp_opts) do
opts =
[name: name, processor_resolver: &Sentry.Test.Registry.lookup_processor_for/1]
|> Keyword.merge(tp_opts)

ExUnit.Callbacks.start_supervised!({Sentry.TelemetryProcessor, opts}, id: name)
end

defp tag_scheduler(processor_name) do
scheduler_pid = Sentry.TelemetryProcessor.get_scheduler(processor_name)

if scheduler_pid do
Expand All @@ -191,7 +265,7 @@ defmodule Sentry.Test do
tag_processor_for_allowed_pid(self(), scheduler_pid)
end

processor_name
:ok
end

defp processor_alive?(name) do
Expand Down Expand Up @@ -366,9 +440,9 @@ defmodule Sentry.Test do
# callback drops the event.
#
# The owner's processor name is looked up from its process
# dictionary; tests set it in `setup_telemetry_processor/0`. If the
# dictionary; tests set it in `setup_telemetry_processor/1`. If the
# owner has no per-test processor (e.g. legacy
# `start_collecting/1` without `setup_telemetry_processor/0`), the
# `start_collecting/1` without `setup_telemetry_processor/1`), the
# tag is skipped and the buffered event still falls back to the
# global processor — the same behaviour as before this change.
defp tag_processor_for_allowed_pid(owner_pid, allowed_pid) do
Expand Down
107 changes: 107 additions & 0 deletions lib/sentry/test/assertions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ defmodule Sentry.Test.Assertions do
import ExUnit.Assertions, only: [flunk: 1]

@default_timeout 1000
@refute_timeout 100
@max_poll_interval 50

@type_to_pop %{
Expand Down Expand Up @@ -246,6 +247,112 @@ defmodule Sentry.Test.Assertions do
find_item!(items, criteria, "report")
end

@doc """
Asserts that exactly one transaction envelope is collected via `ref` and
matches the given `criteria`.

This is shorthand for:

assert_sentry_report(collect_sentry_transactions(ref, 1), criteria)

Reserved option in `criteria`:

* `:timeout` — ms to wait for the envelope (default: `#{1000}`)

Use with a collector created via
`Sentry.Test.setup_sentry(collect_envelopes: true)`.

Returns the matched transaction map.

## Examples

test "GET /transaction", %{conn: conn, ref: ref} do
get(conn, ~p"/transaction")

assert_sentry_transaction(ref,
transaction: "test_span",
contexts: %{trace: %{op: "test_span"}}
)
end

"""
@doc since: "13.0.2"
@spec assert_sentry_transaction(reference(), keyword()) :: map()
def assert_sentry_transaction(ref, criteria \\ []) when is_reference(ref) do
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
transactions = Sentry.Test.collect_sentry_transactions(ref, 1, timeout: timeout)
tx = unwrap_single!(transactions, "transaction", timeout)
assert_fields!(tx, criteria, "transaction")
tx
end

@doc """
Collects up to `:count` transaction envelopes via `ref` and returns the
first one matching `criteria`. Raises if no transaction matches.

This is shorthand for:

find_sentry_report!(collect_sentry_transactions(ref, count, timeout: timeout), criteria)

Reserved options in `criteria`:

* `:count` — max number of envelopes to collect (default: `1`)
* `:timeout` — ms to wait for each envelope (default: `#{1000}`)

## Examples

find_sentry_transaction!(ref,
count: 10,
timeout: 2000,
transaction: "PhoenixAppWeb.UserLive.Index.handle_event#save",
contexts: %{trace: %{origin: "opentelemetry_phoenix"}}
)

"""
@doc since: "13.0.2"
@spec find_sentry_transaction!(reference(), keyword()) :: map()
def find_sentry_transaction!(ref, criteria) when is_reference(ref) do
{count, criteria} = Keyword.pop(criteria, :count, 1)
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
transactions = Sentry.Test.collect_sentry_transactions(ref, count, timeout: timeout)
find_item!(transactions, criteria, "transaction")
end

@doc """
Asserts that **no** Sentry check-in envelope reaches the Bypass envelope
collector identified by `ref` within `timeout` ms (default
`#{@refute_timeout}`).

The `Sentry.TelemetryProcessor` pipeline is flushed first, so a check-in
that was buffered (rather than sent synchronously) is still detected
rather than silently slipping past the timeout window.

Use with a collector created via
`Sentry.Test.setup_sentry(collect_envelopes: [type: "check_in"])` (or
`Sentry.Test.setup_bypass_envelope_collector/2` with `type: "check_in"`),
so that only check-in envelopes are forwarded to the test process.

## Examples

test "ignores non-cron jobs", %{ref: ref} do
:telemetry.execute([:oban, :job, :start], %{}, %{job: %Oban.Job{}})
refute_sentry_check_in(ref)
end

"""
@doc since: "13.0.2"
@spec refute_sentry_check_in(reference(), timeout()) :: :ok
def refute_sentry_check_in(ref, timeout \\ @refute_timeout) when is_reference(ref) do
maybe_flush(timeout)

receive do
{:bypass_envelope, ^ref, body} ->
flunk("expected no check-in to be sent, but received envelope: #{body}")
after
timeout -> :ok
end
end
Comment thread
solnic marked this conversation as resolved.

# --- Private helpers ---

defp pop_for_type(type) do
Expand Down
Loading
Loading