Packages
sentry
13.0.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/assertions.ex
defmodule Sentry.Test.Assertions do
@moduledoc """
ExUnit assertion helpers for testing Sentry reports.
These helpers work with data collected by `Sentry.Test` and reduce
boilerplate when asserting on captured events, transactions, and logs.
## Usage
import Sentry.Test.Assertions
## Examples
Assert that exactly one event was captured with specific fields:
assert_sentry_report(:event, level: :error, message: %{formatted: "hello"})
Assert a transaction:
assert_sentry_report(:transaction, transaction: "my_span")
Use the log shorthand:
assert_sentry_log(:info, "User session started")
assert_sentry_log(:info, ~r/session started/, trace_id: "abc123")
Use the metric shorthand (find semantics — works with multiple co-emitted metrics):
assert_sentry_metric(:counter, name: "button.clicks")
assert_sentry_metric(:distribution, name: "response.time")
Find a specific event among many:
events = Sentry.Test.pop_sentry_reports()
event = find_sentry_report!(events, message: %{formatted: ~r/hello/})
## Awaiting Asynchronous Reports
Log and metric events flow through the `Sentry.TelemetryProcessor` pipeline
asynchronously. The type-form of `assert_sentry_report/2` and
`assert_sentry_log/3` await internally — they flush the telemetry pipeline
and poll the collector with exponential backoff until at least one matching
item is captured, up to a timeout (default `#{1000}ms`).
Tests typically do not need to call `Sentry.TelemetryProcessor.flush/0`
or `Process.sleep/1` before these assertions.
Override the default via the reserved `:timeout` keyword:
assert_sentry_report(:log, [level: :info, body: "hi"], timeout: 2000)
assert_sentry_log(:info, "hi", timeout: 2000)
assert_sentry_metric(:counter, name: "clicks", timeout: 2000)
"""
@moduledoc since: "13.0.0"
import ExUnit.Assertions, only: [flunk: 1]
@default_timeout 1000
@max_poll_interval 50
@type_to_pop %{
event: &Sentry.Test.pop_sentry_reports/0,
transaction: &Sentry.Test.pop_sentry_transactions/0,
log: &Sentry.Test.pop_sentry_logs/0,
metric: &Sentry.Test.pop_sentry_metrics/0
}
@doc """
Asserts that a report matches the given criteria.
This function has two forms:
## Auto-pop by type
When the first argument is a type atom (`:event`, `:transaction`, or `:log`),
it pops collected items internally — no need to call `pop_sentry_reports/0`
yourself. Asserts that exactly one item was captured and validates it.
* `:event` — pops from `Sentry.Test.pop_sentry_reports/0`
* `:transaction` — pops from `Sentry.Test.pop_sentry_transactions/0`
* `:log` — pops from `Sentry.Test.pop_sentry_logs/0`
## Explicit data
When the first argument is a map or single-element list, it validates the
item against the criteria directly. Use this with data from envelope
collection helpers.
## Criteria
Each key-value pair in `criteria` is checked against the item:
* **Regex** — matches with `=~/2`
* **Plain map** (not a struct) — recursive subset match: every key
in the expected map must exist in the actual value with a matching value
* **Any other value** — compared with `==/2`
Atom keys are resolved with a string-key fallback, so atom-key criteria
also work on decoded JSON maps.
Returns the matched item for further assertions.
## Examples
event = assert_sentry_report(:event,
level: :error,
source: :plug,
message: %{formatted: "hello"}
)
assert_sentry_report(:transaction, transaction: "test_span")
# With explicit data from envelope collection:
[event] = collect_sentry_events(ref, 1)
assert_sentry_report(event, "tags" => %{"oban_queue" => "default"})
"""
@doc since: "13.0.0"
def assert_sentry_report(type_or_item, criteria)
@spec assert_sentry_report(:event | :transaction | :log | :metric, keyword()) ::
Sentry.Event.t() | Sentry.Transaction.t() | Sentry.LogEvent.t() | Sentry.Metric.t()
def assert_sentry_report(type, criteria) when type in [:event, :transaction, :log, :metric] do
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
label = type_label(type)
items = await_items(type, timeout, &(length(&1) >= 1))
item = unwrap_single!(items, label, timeout)
assert_fields!(item, criteria, label)
item
end
@spec assert_sentry_report(map() | [map()], keyword() | [{binary(), term()}]) :: map()
def assert_sentry_report(item_or_list, criteria)
when (is_map(item_or_list) or is_list(item_or_list)) and
(is_list(criteria) or is_map(criteria)) do
item = unwrap_single!(item_or_list, "report")
assert_fields!(item, criteria, "report")
item
end
@doc """
Asserts that a log was captured matching the given level and body pattern.
Awaits asynchronously-captured logs: the pipeline is flushed and the
collector is polled until a log matching the criteria is found or the
timeout elapses (default `#{1000}ms`, overridable via the `:timeout`
reserved key in `extra_criteria`).
Once at least one candidate log is available, finds the first one matching
`level` and `body_pattern`. This uses find semantics (not assert-exactly-1)
because logs often come in batches.
The optional third argument is a keyword list of extra criteria to match
on any `Sentry.LogEvent` field, plus the reserved `:timeout` option.
Returns the matched log event.
## Examples
assert_sentry_log(:info, "User session started")
assert_sentry_log(:error, ~r/connection refused/)
assert_sentry_log(:info, "User session started", trace_id: "abc123")
assert_sentry_log(:info, "User session started", attributes: %{id: 312})
assert_sentry_log(:info, "slow path", timeout: 2000)
"""
@doc since: "13.0.0"
@spec assert_sentry_log(Sentry.LogEvent.level(), String.t() | Regex.t(), keyword()) ::
Sentry.LogEvent.t()
def assert_sentry_log(level, body_pattern, extra_criteria \\ [])
when is_atom(level) and (is_binary(body_pattern) or is_struct(body_pattern, Regex)) do
{timeout, extra_criteria} = Keyword.pop(extra_criteria, :timeout, @default_timeout)
criteria = [level: level, body: body_pattern] ++ extra_criteria
logs =
await_items(:log, timeout, fn items ->
Enum.any?(items, &matches_criteria?(&1, criteria))
end)
{match, remaining} = extract_first_match(logs, criteria)
put_inbox(:log, remaining)
match || flunk(format_find_error(logs, criteria, "log"))
end
@doc """
Asserts that a metric was captured matching the given type and criteria.
Awaits asynchronously-captured metrics: the pipeline is flushed and the
collector is polled until a metric matching the criteria is found or the
timeout elapses (default `#{1000}ms`, overridable via the `:timeout`
reserved key in `criteria`).
Uses find semantics (not assert-exactly-1), so this succeeds even when
multiple metrics were emitted together — as is common when a single
request records several measurements.
Unmatched metrics are returned to an inbox so that multiple successive
`assert_sentry_metric/2` calls in the same test each see a clean slate.
Returns the matched metric.
## Examples
assert_sentry_metric(:counter, name: "button.clicks")
assert_sentry_metric(:distribution, name: "response.time", value: 42.5)
assert_sentry_metric(:gauge, name: "memory.usage", attributes: %{pool: "main"})
assert_sentry_metric(:counter, name: "requests", timeout: 2000)
"""
@doc since: "13.0.0"
@spec assert_sentry_metric(:counter | :distribution | :gauge, keyword()) :: Sentry.Metric.t()
def assert_sentry_metric(type, criteria \\ [])
when type in [:counter, :distribution, :gauge] do
{timeout, criteria} = Keyword.pop(criteria, :timeout, @default_timeout)
criteria = [type: type] ++ criteria
metrics =
await_items(:metric, timeout, fn items ->
Enum.any?(items, &matches_criteria?(&1, criteria))
end)
{match, remaining} = extract_first_match(metrics, criteria)
put_inbox(:metric, remaining)
match || flunk(format_find_error(metrics, criteria, "metric"))
end
@doc """
Finds the first item in `items` that matches all `criteria`.
Raises with a descriptive error if no match is found. Works with both
structs (atom keys) and decoded JSON maps (string keys).
## Examples
events = Sentry.Test.pop_sentry_reports()
event = find_sentry_report!(events, message: %{formatted: ~r/hello/})
"""
@doc since: "13.0.0"
@spec find_sentry_report!([map()], keyword() | [{binary(), term()}]) :: map()
def find_sentry_report!(items, criteria) when is_list(items) do
find_item!(items, criteria, "report")
end
# --- Private helpers ---
defp pop_for_type(type) do
Map.fetch!(@type_to_pop, type).()
end
# Per-test inbox of unmatched items left over from prior assert_sentry_log
# calls. Destructively read at the start of each await; unmatched items
# are written back by callers that use find semantics.
@inbox_key {__MODULE__, :inbox}
defp take_inbox(type) do
inbox = Process.get(@inbox_key, %{})
Process.put(@inbox_key, Map.put(inbox, type, []))
Map.get(inbox, type, [])
end
defp put_inbox(type, items) do
inbox = Process.get(@inbox_key, %{})
Process.put(@inbox_key, Map.put(inbox, type, items))
:ok
end
defp await_items(type, timeout, done_fn) do
maybe_flush(timeout)
deadline = System.monotonic_time(:millisecond) + timeout
await_loop(type, deadline, 1, take_inbox(type), done_fn)
end
defp await_loop(type, deadline, sleep_ms, acc, done_fn) do
acc = acc ++ pop_for_type(type)
cond do
done_fn.(acc) ->
acc
System.monotonic_time(:millisecond) >= deadline ->
acc
true ->
Process.sleep(sleep_ms)
await_loop(type, deadline, min(sleep_ms * 2, @max_poll_interval), acc, done_fn)
end
end
# Drains the TelemetryProcessor pipeline synchronously. No-op when no
# per-test processor is registered (e.g., tests that bypass the pipeline
# by inserting directly into the collector ETS table).
defp maybe_flush(timeout) do
case Process.get(:sentry_telemetry_processor) do
nil ->
:ok
processor ->
scheduler = Sentry.TelemetryProcessor.scheduler_name(processor)
if Process.whereis(scheduler) do
try do
Sentry.TelemetryProcessor.flush(processor, timeout)
catch
:exit, _ -> :ok
end
end
:ok
end
end
defp matches_criteria?(item, criteria) do
Enum.all?(criteria, fn {key, expected} ->
match_value?(get_field(item, key), expected)
end)
end
defp extract_first_match(items, criteria) do
{match, rest_reversed} =
Enum.reduce(items, {nil, []}, fn item, {match, rest} ->
cond do
match != nil -> {match, [item | rest]}
matches_criteria?(item, criteria) -> {item, rest}
true -> {nil, [item | rest]}
end
end)
{match, Enum.reverse(rest_reversed)}
end
defp type_label(:event), do: "event"
defp type_label(:transaction), do: "transaction"
defp type_label(:log), do: "log"
defp type_label(:metric), do: "metric"
defp unwrap_single!(items_or_item, label, timeout \\ nil)
defp unwrap_single!([single], _label, _timeout), do: single
defp unwrap_single!(item, _label, _timeout) when is_map(item), do: item
defp unwrap_single!([], label, nil) do
flunk("""
Expected exactly 1 Sentry #{label}, got 0.
Make sure setup_sentry/1 was called and the event was sent with result: :sync.\
""")
end
defp unwrap_single!([], label, timeout) do
flunk("""
Expected 1 Sentry #{label} within #{timeout}ms, got 0.
Ensure the TelemetryProcessor is running for this test (via Sentry.Case or
Sentry.Test.setup_sentry/1) and that the event was emitted before the
assertion. For slow pipelines, pass a larger `:timeout` option.\
""")
end
defp unwrap_single!(list, label, _timeout) when is_list(list) do
flunk("""
Expected exactly 1 Sentry #{label}, got #{length(list)}.
Use find_sentry_report!/2 to search within multiple items.\
""")
end
defp assert_fields!(item, criteria, label) do
mismatches =
Enum.reduce(criteria, [], fn {key, expected}, acc ->
actual = get_field(item, key)
if match_value?(actual, expected) do
acc
else
[{key, expected, actual} | acc]
end
end)
unless mismatches == [] do
flunk(format_mismatch_error(Enum.reverse(mismatches), label))
end
end
defp find_item!(items, criteria, label) do
Enum.find(items, &matches_criteria?(&1, criteria)) ||
flunk(format_find_error(items, criteria, label))
end
defp get_field(data, key) when is_atom(key) do
case Map.fetch(data, key) do
{:ok, value} -> value
:error -> Map.get(data, to_string(key))
end
end
defp get_field(data, key) when is_binary(key) do
case Map.fetch(data, key) do
{:ok, value} ->
value
:error ->
try do
Map.get(data, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
end
end
defp match_value?(actual, %Regex{} = expected) do
is_binary(actual) and actual =~ expected
end
defp match_value?(actual, expected) when is_map(expected) and not is_struct(expected) do
is_map(actual) and
Enum.all?(expected, fn {k, v} ->
match_value?(get_field(actual, k), v)
end)
end
defp match_value?(actual, expected) do
actual == expected
end
defp format_mismatch_error(mismatches, label) do
fields =
Enum.map_join(mismatches, "\n\n", fn {key, expected, actual} ->
"""
#{inspect(key)}
expected: #{inspect(expected, limit: 5, printable_limit: 100)}
got: #{inspect(actual, limit: 5, printable_limit: 100)}\
"""
end)
"Sentry #{label} assertion failed:\n\n#{fields}"
end
defp format_find_error(items, criteria, label) do
"""
No matching Sentry #{label} found in #{length(items)} item(s).
Criteria: #{inspect(criteria, limit: 10, printable_limit: 200)}\
"""
end
end