From 59ff1c4e4d27c9cb8b69d2ec8434d82b3625e770 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Tue, 21 Apr 2026 14:11:43 +0000 Subject: [PATCH] feat(test): add assert_sentry_metric/2 assertion helper Add a dedicated assert_sentry_metric/2 helper that mirrors assert_sentry_log/3 for metrics. Uses find semantics so tests succeed even when multiple metrics are emitted together (a common pattern), and returns unmatched metrics to an inbox for subsequent assertions in the same test. Co-Authored-By: Claude Sonnet 4.6 --- lib/sentry/test/assertions.ex | 49 +++++++++++++ test/sentry/test/assertions_test.exs | 106 +++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) 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