diff --git a/lib/sentry/test/assertions.ex b/lib/sentry/test/assertions.ex index 77cb4737..56ae1476 100644 --- a/lib/sentry/test/assertions.ex +++ b/lib/sentry/test/assertions.ex @@ -24,6 +24,11 @@ defmodule Sentry.Test.Assertions do 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() @@ -44,6 +49,7 @@ defmodule Sentry.Test.Assertions do 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" @@ -179,6 +185,49 @@ defmodule Sentry.Test.Assertions do 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`. diff --git a/test/sentry/test/assertions_test.exs b/test/sentry/test/assertions_test.exs index 5762360d..8895d0b3 100644 --- a/test/sentry/test/assertions_test.exs +++ b/test/sentry/test/assertions_test.exs @@ -456,4 +456,110 @@ defmodule Sentry.Test.AssertionsTest do :ets.insert(table, {System.unique_integer([:monotonic]), metric}) metric end + + describe "assert_sentry_metric/2" do + setup do + SentryTest.setup_sentry() + end + + test "finds metric by type when multiple metrics exist" do + insert_metric(type: :counter, name: "clicks", value: 5) + insert_metric(type: :distribution, name: "response.time", value: 42.5) + + metric = assert_sentry_metric(:counter, name: "clicks") + assert %Sentry.Metric{} = metric + assert metric.type == :counter + assert metric.name == "clicks" + assert metric.value == 5 + end + + test "uses find semantics - succeeds when target is among many" do + insert_metric(type: :counter, name: "sync.read.entities", value: 1) + insert_metric(type: :distribution, name: "sync.read.entities.count", value: 10) + + metric = assert_sentry_metric(:distribution, name: "sync.read.entities.count") + assert metric.value == 10 + end + + test "returns unmatched metrics to inbox for subsequent assertions" do + insert_metric(type: :counter, name: "first.metric", value: 1) + insert_metric(type: :distribution, name: "second.metric", value: 99.9) + + assert_sentry_metric(:counter, name: "first.metric") + # Second assertion must still find the distribution metric + assert_sentry_metric(:distribution, name: "second.metric") + end + + test "matches additional criteria beyond type" do + insert_metric( + type: :counter, + name: "button.clicks", + value: 1, + attributes: %{button_id: "submit"} + ) + + insert_metric( + type: :counter, + name: "button.clicks", + value: 1, + attributes: %{button_id: "cancel"} + ) + + metric = + assert_sentry_metric(:counter, + name: "button.clicks", + attributes: %{button_id: "submit"} + ) + + assert metric.attributes[:button_id] == "submit" + end + + test "fails when no matching metric found" do + insert_metric(type: :counter, name: "other.metric") + + assert_raise ExUnit.AssertionError, ~r/No matching Sentry metric found/, fn -> + assert_sentry_metric(:gauge, name: "nonexistent", timeout: 10) + end + end + + test "fails when type doesn't match" do + insert_metric(type: :counter, name: "my.metric") + + assert_raise ExUnit.AssertionError, ~r/No matching Sentry metric found/, fn -> + assert_sentry_metric(:gauge, name: "my.metric", timeout: 10) + end + end + + test "respects :timeout option" do + before = System.monotonic_time(:millisecond) + + assert_raise ExUnit.AssertionError, ~r/No matching Sentry metric found/, fn -> + assert_sentry_metric(:counter, name: "missing", timeout: 50) + end + + elapsed = System.monotonic_time(:millisecond) - before + assert elapsed < 500, "expected fast failure, waited #{elapsed}ms" + end + + test "awaits async metrics" do + table = Process.get(:sentry_test_collector) + + Task.start(fn -> + Process.sleep(30) + + metric = + struct!(Sentry.Metric, + type: :counter, + name: "async.metric", + value: 1, + timestamp: System.system_time(:nanosecond) / 1_000_000_000 + ) + + :ets.insert(table, {System.unique_integer([:monotonic]), metric}) + end) + + metric = assert_sentry_metric(:counter, name: "async.metric", timeout: 500) + assert metric.name == "async.metric" + end + end end