From f87c11016f21ae82b2d605cf711e9378e34389e5 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Fri, 27 Mar 2026 10:30:35 +0000 Subject: [PATCH 1/3] wip(tests): remove Sentry.Test module and test_mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace in-memory event collection (Sentry.Test) with Bypass-based HTTP testing across all test suites. Tests now exercise the full send pipeline (DSN → Client → Transport → HTTP). --- AGENTS.md | 6 +- config/config.exs | 4 +- lib/sentry.ex | 7 - lib/sentry/client.ex | 86 +-- lib/sentry/config.ex | 15 - lib/sentry/telemetry/scheduler.ex | 58 +- lib/sentry/test.ex | 508 ------------------ mix.exs | 1 - .../integrations/oban/error_reporter_test.exs | 272 ++++++---- test/sentry/integrations/telemetry_test.exs | 79 ++- .../opentelemetry/span_processor_test.exs | 451 ++++++++-------- test/sentry/test_test.exs | 221 -------- test/sentry_test.exs | 14 +- test/support/test_helpers.ex | 121 +++++ test_integrations/phoenix_app/config/test.exs | 2 - .../test/phoenix_app/oban_test.exs | 94 ++-- .../test/phoenix_app/repo_test.exs | 32 +- .../controllers/exception_test.exs | 8 +- .../phoenix_app_web/controllers/logs_test.exs | 128 +++-- .../controllers/transaction_test.exs | 195 +++---- .../phoenix_app_web/live/user_live_test.exs | 37 +- .../phoenix_app/test/support/test_helpers.ex | 75 +++ test_integrations/umbrella/config/config.exs | 1 - 23 files changed, 977 insertions(+), 1438 deletions(-) delete mode 100644 lib/sentry/test.ex delete mode 100644 test/sentry/test_test.exs diff --git a/AGENTS.md b/AGENTS.md index 47840406..84a0f780 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ This is the official Sentry SDK for Elixir. It captures errors, monitors cron jo | `lib/sentry/logger_handler.ex` | Erlang logger handler | | `lib/sentry/plug_capture.ex` | Plug exception capture | | `lib/sentry/live_view_hook.ex` | Phoenix LiveView hook | -| `lib/sentry/test.ex` | Testing utilities | | `lib/mix/tasks/` | Mix tasks (install, test event, source packaging) | | `test/` | Unit and integration tests | | `test/support/` | Test helpers and shared utilities | @@ -97,14 +96,13 @@ Configuration is validated at application start using NimbleOptions, then cached ### Key Patterns -- **HTTP testing:** Use `Bypass` for HTTP-level tests with `send_result: :sync` -- **Buffered path testing:** Use `Sentry.Test.start_collecting_sentry_reports/0` + `assert_receive`/`pop_sentry_*` helpers +- **HTTP testing:** Use `Bypass` for HTTP-level tests with `send_result: :sync`. Use `setup_bypass/1` to open a Bypass instance and configure DSN, `setup_bypass_envelope_collector/1` to forward envelopes to the test process, and `collect_envelopes/3` + `extract_events/1` / `extract_transactions/1` / `extract_log_items/1` to retrieve and filter decoded envelope items. - **Test isolation:** Each test gets uniquely-named components (rate limiter, processor, span storage) via process dictionary and `:persistent_term` - **Config isolation:** Use `put_test_config/1` from test helpers for isolated config changes with automatic cleanup ### Test Configuration -Tests run with `send_result: :sync` and `test_mode: true` (set in `config/config.exs`). This bypasses the TelemetryProcessor pipeline for direct, synchronous event sending. +Tests run with `send_result: :sync` (set in `config/config.exs`). This bypasses the TelemetryProcessor pipeline for direct, synchronous event sending. All tests that send events use Bypass to capture HTTP requests — there is no in-memory event collection. ### Integration Tests diff --git a/config/config.exs b/config/config.exs index 63f06d0e..aed6efea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -6,11 +6,11 @@ if config_env() == :test do tags: %{}, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], - finch_request_opts: [receive_timeout: 50], + dsn: "http://public:secret@localhost:8999/1", + finch_request_opts: [receive_timeout: 2000], send_result: :sync, send_max_attempts: 1, dedup_events: false, - test_mode: true, traces_sample_rate: 1.0 config :sentry, request_retries: [] diff --git a/lib/sentry.ex b/lib/sentry.ex index a4202000..4148775a 100644 --- a/lib/sentry.ex +++ b/lib/sentry.ex @@ -369,10 +369,6 @@ defmodule Sentry do ClientReport.Sender.record_discarded_events(:event_processor, [event]) :ignored - # If we're in test mode, let's send the event down the pipeline anyway. - Config.test_mode?() -> - Client.send_event(event, options) - !Config.dsn() -> # We still validate options even if we're not sending the event. This aims at catching # configuration issues during development instead of only when deploying to production. @@ -392,9 +388,6 @@ defmodule Sentry do included_envs = Config.included_environments() cond do - Config.test_mode?() -> - Client.send_transaction(transaction, options) - !Config.dsn() -> # We still validate options even if we're not sending the event. This aims at catching # configuration issues during development instead of only when deploying to production. diff --git a/lib/sentry/client.ex b/lib/sentry/client.ex index c6bcb79c..e9ebd266 100644 --- a/lib/sentry/client.ex +++ b/lib/sentry/client.ex @@ -217,40 +217,25 @@ defmodule Sentry.Client do end defp encode_and_send(%Event{} = event, _result_type = :sync, client, request_retries) do - case Sentry.Test.maybe_collect(event) do - :collected -> - {:ok, ""} - - :not_collecting -> - send_result = - event - |> Envelope.from_event() - |> Transport.encode_and_post_envelope(client, request_retries) - - send_result - end + event + |> Envelope.from_event() + |> Transport.encode_and_post_envelope(client, request_retries) end defp encode_and_send(%Event{} = event, _result_type = :none, client, _request_retries) do - case Sentry.Test.maybe_collect(event) do - :collected -> - {:ok, ""} - - :not_collecting -> - if Config.telemetry_processor_category?(:error) do - case TelemetryProcessor.add(event) do - {:ok, {:rate_limited, data_category}} -> - ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category) - - :ok -> - :ok - end - else - :ok = Transport.Sender.send_async(client, event) - end + if Config.telemetry_processor_category?(:error) do + case TelemetryProcessor.add(event) do + {:ok, {:rate_limited, data_category}} -> + ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category) - {:ok, ""} + :ok -> + :ok + end + else + :ok = Transport.Sender.send_async(client, event) end + + {:ok, ""} end defp encode_and_send( @@ -259,18 +244,9 @@ defmodule Sentry.Client do client, request_retries ) do - case Sentry.Test.maybe_collect(transaction) do - :collected -> - {:ok, ""} - - :not_collecting -> - send_result = - transaction - |> Envelope.from_transaction() - |> Transport.encode_and_post_envelope(client, request_retries) - - send_result - end + transaction + |> Envelope.from_transaction() + |> Transport.encode_and_post_envelope(client, request_retries) end defp encode_and_send( @@ -279,25 +255,19 @@ defmodule Sentry.Client do client, _request_retries ) do - case Sentry.Test.maybe_collect(transaction) do - :collected -> - {:ok, ""} - - :not_collecting -> - if Config.telemetry_processor_category?(:transaction) do - case TelemetryProcessor.add(transaction) do - {:ok, {:rate_limited, data_category}} -> - ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category) - - :ok -> - :ok - end - else - :ok = Transport.Sender.send_async(client, transaction) - end + if Config.telemetry_processor_category?(:transaction) do + case TelemetryProcessor.add(transaction) do + {:ok, {:rate_limited, data_category}} -> + ClientReport.Sender.record_discarded_events(:ratelimit_backoff, data_category) - {:ok, ""} + :ok -> + :ok + end + else + :ok = Transport.Sender.send_async(client, transaction) end + + {:ok, ""} end @spec render_event(Event.t()) :: map() diff --git a/lib/sentry/config.ex b/lib/sentry/config.ex index 85126696..9a7aa573 100644 --- a/lib/sentry/config.ex +++ b/lib/sentry/config.ex @@ -179,8 +179,6 @@ defmodule Sentry.Config do doc: """ The DSN for your Sentry project. If this is not set, Sentry will not be enabled. If the `SENTRY_DSN` environment variable is set, it will be used as the default value. - If `:test_mode` is `true`, the `:dsn` option is sometimes ignored; see `Sentry.Test` - for more information. """ ], environment_name: [ @@ -365,16 +363,6 @@ defmodule Sentry.Config do stacktrace, and fingerprint. *Available since v10.0.0*. """ ], - test_mode: [ - type: :boolean, - default: false, - doc: """ - Whether to enable *test mode*. When test mode is enabled, the SDK will check whether - there is a process **collecting events** and avoid sending those events if that's the - case. This is useful for testing—see `Sentry.Test`. `:test_mode` works in tandem - with `:dsn`; this is described in detail in `Sentry.Test`. - """ - ], integrations: [ type: :keyword_list, doc: """ @@ -890,9 +878,6 @@ defmodule Sentry.Config do @spec send_client_reports?() :: boolean() def send_client_reports?, do: fetch!(:send_client_reports) - @spec test_mode?() :: boolean() - def test_mode?, do: fetch!(:test_mode) - @spec integrations() :: keyword() def integrations, do: fetch!(:integrations) diff --git a/lib/sentry/telemetry/scheduler.ex b/lib/sentry/telemetry/scheduler.ex index 68495df5..36b49619 100644 --- a/lib/sentry/telemetry/scheduler.ex +++ b/lib/sentry/telemetry/scheduler.ex @@ -291,21 +291,9 @@ defmodule Sentry.Telemetry.Scheduler do state end - defp process_and_send_event(%{on_envelope: on_envelope} = state, %Event{} = event, send_fn) do - # Skip test collection when on_envelope is set (used by unit tests) - if is_nil(on_envelope) do - case Sentry.Test.maybe_collect(event) do - :collected -> - state - - :not_collecting -> - envelope = Envelope.from_event(event) - send_fn.(state, envelope) - end - else - envelope = Envelope.from_event(event) - send_fn.(state, envelope) - end + defp process_and_send_event(state, %Event{} = event, send_fn) do + envelope = Envelope.from_event(event) + send_fn.(state, envelope) end defp process_and_send_check_in(state, %CheckIn{} = check_in, send_fn) do @@ -313,45 +301,17 @@ defmodule Sentry.Telemetry.Scheduler do send_fn.(state, envelope) end - defp process_and_send_transaction( - %{on_envelope: on_envelope} = state, - %Transaction{} = transaction, - send_fn - ) do - # Skip test collection when on_envelope is set (used by unit tests) - if is_nil(on_envelope) do - case Sentry.Test.maybe_collect(transaction) do - :collected -> - state - - :not_collecting -> - envelope = Envelope.from_transaction(transaction) - send_fn.(state, envelope) - end - else - envelope = Envelope.from_transaction(transaction) - send_fn.(state, envelope) - end + defp process_and_send_transaction(state, %Transaction{} = transaction, send_fn) do + envelope = Envelope.from_transaction(transaction) + send_fn.(state, envelope) end - defp process_and_send_logs(%{on_envelope: on_envelope} = state, log_events, send_fn) do + defp process_and_send_logs(state, log_events, send_fn) do processed_logs = apply_before_send_log_callbacks(log_events) if processed_logs != [] do - # Skip test collection when on_envelope is set (used by unit tests) - if is_nil(on_envelope) do - case Sentry.Test.maybe_collect_logs(processed_logs) do - :collected -> - state - - :not_collecting -> - envelope = Envelope.from_log_events(processed_logs) - send_fn.(state, envelope) - end - else - envelope = Envelope.from_log_events(processed_logs) - send_fn.(state, envelope) - end + envelope = Envelope.from_log_events(processed_logs) + send_fn.(state, envelope) else state end diff --git a/lib/sentry/test.ex b/lib/sentry/test.ex deleted file mode 100644 index 514806f1..00000000 --- a/lib/sentry/test.ex +++ /dev/null @@ -1,508 +0,0 @@ -defmodule Sentry.Test do - @moduledoc """ - Utilities for testing Sentry reports. - - ## Usage - - This module is based on **collecting** reported events and then retrieving - them to perform assertions. The functionality here is only available if the - `:test_mode` configuration option is set to `true`—see - [`Sentry`'s configuration section](sentry.html#module-configuration). - You can start collecting events from a process - by calling `start_collecting_sentry_reports/0`. Then, you can use Sentry - as normal and report events (through functions such as `Sentry.capture_message/1` - or `Sentry.capture_exception/1`). Finally, you can retrieve the collected events - by calling `pop_sentry_reports/0`. - - > #### Test Mode and DSN {: .info} - > - > If `:test_mode` is `true`, the `:dsn` option behaves differently. When `:dsn` is - > not set or `nil` and you're collecting events, you'll still be able to collect - > events—even if under normal circumstances a missing `:dsn` means events don't get - > reported. If `:dsn` is `nil` and you're not collecting events, the event is simply - > ignored. See the table below for a summary for this behavior. - - | `:test_mode` | `:dsn` | Collecting events? | Behavior | - |--------------|--------|--------------------|--------------------------------------------------------| - | `true` | `nil` | yes | Event is collected | - | `true` | `nil` | no | Event is ignored (silently) | - | `true` | set | yes | Event is collected | - | `true` | set | no | Makes HTTP request to configured DSN (could be Bypass) | - | `false` | `nil` | irrelevant | Ignores event | - | `false` | set | irrelevant | Makes HTTP request to configured DSN (could be Bypass) | - - ## Examples - - Let's imagine writing a test using the functions in this module. First, we need to - start collecting events: - - test "reporting from child processes" do - parent_pid = self() - - # Collect reports from self(). - assert :ok = Test.start_collecting_sentry_reports() - - # - end - - Now, we can report events as normal. For example, we can report an event from the - parent process: - - assert {:ok, ""} = Sentry.capture_message("Oops from parent process") - - We can also report events from "child" processes. - - # Spawn a child that waits for the :go message and then reports an event. - {:ok, child_pid} = - Task.start_link(fn -> - receive do - :go -> - assert {:ok, ""} = Sentry.capture_message("Oops from child process") - send(parent_pid, :done) - end - end) - - # Start the child and wait for it to finish. - send(child_pid, :go) - assert_receive :done - - Now, we can retrieve the collected events and perform assertions on them: - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops from parent process" - assert event2.message.formatted == "Oops from child process" - - """ - - @moduledoc since: "10.2.0" - - @server __MODULE__.OwnershipServer - @events_key :events - @transactions_key :transactions - @logs_key :logs - - # Used internally when reporting an event, *before* reporting the actual event. - @doc false - @spec maybe_collect(Sentry.Event.t()) :: :collected | :not_collecting - def maybe_collect(%Sentry.Event{} = event) do - maybe_collect(event, @events_key) - end - - # Used internally when reporting a transaction, *before* reporting the actual transaction. - @doc false - @spec maybe_collect(Sentry.Transaction.t()) :: :collected | :not_collecting - def maybe_collect(%Sentry.Transaction{} = transaction) do - maybe_collect(transaction, @transactions_key) - end - - # Used internally when reporting log events, *before* reporting the actual log events. - @doc false - @spec maybe_collect_logs([Sentry.LogEvent.t()]) :: :collected | :not_collecting - def maybe_collect_logs(log_events) when is_list(log_events) do - if Sentry.Config.test_mode?() do - dsn_set? = not is_nil(Sentry.Config.dsn()) - ensure_ownership_server_started() - - case NimbleOwnership.fetch_owner(@server, callers(), @logs_key) do - {:ok, owner_pid} -> - result = - NimbleOwnership.get_and_update( - @server, - owner_pid, - @logs_key, - fn logs -> - {:collected, (logs || []) ++ log_events} - end - ) - - case result do - {:ok, :collected} -> - :collected - - {:error, error} -> - raise ArgumentError, - "cannot collect Sentry logs: #{Exception.message(error)}" - end - - :error when dsn_set? -> - :not_collecting - - # If the :dsn option is not set and we didn't capture the item, it's alright, - # we can just swallow it. - :error -> - :collected - end - else - :not_collecting - end - end - - @doc false - def maybe_collect(item, collection_key) do - if Sentry.Config.test_mode?() do - dsn_set? = not is_nil(Sentry.Config.dsn()) - ensure_ownership_server_started() - - case NimbleOwnership.fetch_owner(@server, callers(), collection_key) do - {:ok, owner_pid} -> - result = - NimbleOwnership.get_and_update( - @server, - owner_pid, - collection_key, - fn items -> - {:collected, (items || []) ++ [item]} - end - ) - - case result do - {:ok, :collected} -> - :collected - - {:error, error} -> - raise ArgumentError, - "cannot collect Sentry #{collection_key}: #{Exception.message(error)}" - end - - :error when dsn_set? -> - :not_collecting - - # If the :dsn option is not set and we didn't capture the item, it's alright, - # we can just swallow it. - :error -> - :collected - end - else - :not_collecting - end - end - - @doc """ - Starts collecting events from the current process. - - This function starts collecting events reported from the current process. If you want to - allow other processes to report events, you need to *allow* them to report events back - to the current process. See `allow/2` for more information on allowances. If the current - process is already *allowed by another process*, this function raises an error. - - The `context` parameter is ignored. It's there so that this function can be used - as an ExUnit **setup callback**. For example: - - import Sentry.Test - - setup :start_collecting_sentry_reports - - For a more flexible way to start collecting events, see `start_collecting/1`. - """ - @doc since: "10.2.0" - @spec start_collecting_sentry_reports(map()) :: :ok - def start_collecting_sentry_reports(_context \\ %{}) do - start_collecting(key: @events_key) - start_collecting(key: @transactions_key) - start_collecting(key: @logs_key) - - # Allow the TelemetryProcessor scheduler to collect log events on behalf of this process. - # Logs flow through the scheduler (a separate process) and need explicit - # permission in NimbleOwnership to store collected items for the test process. - try do - processor = - Process.get(:sentry_telemetry_processor, Sentry.TelemetryProcessor.default_name()) - - scheduler_name = Sentry.TelemetryProcessor.scheduler_name(processor) - scheduler_pid = GenServer.whereis(scheduler_name) - - if scheduler_pid do - case NimbleOwnership.allow(@server, self(), scheduler_pid, @logs_key) do - :ok -> :ok - {:error, %NimbleOwnership.Error{reason: {:already_allowed, _}}} -> :ok - {:error, _} -> :ok - end - end - catch - :exit, _ -> :ok - end - - :ok - end - - @doc """ - Starts collecting events. - - This function starts collecting events reported from the given (*owner*) process. If you want to - allow other processes to report events, you need to *allow* them to report events back - to the owner process. See `allow/2` for more information on allowances. If the owner - process is already *allowed by another process*, this function raises an error. - - ## Options - - * `:owner` - the PID of the owner process that will collect the events. Defaults to `self/0`. - - * `:cleanup` - a boolean that controls whether collected resources around the owner process - should be cleaned up when the owner process exits. Defaults to `true`. If `false`, you'll - need to manually call `cleanup/1` to clean up the resources. - - ## Examples - - The `:cleanup` option can be used to implement expectation-based tests, akin to something - like [`Mox.expect/4`](https://hexdocs.pm/mox/1.1.0/Mox.html#expect/4). - - test "implementing an expectation-based test workflow" do - test_pid = self() - - Test.start_collecting(owner: test_pid, cleanup: false) - - on_exit(fn -> - assert [%Event{} = event] = Test.pop_sentry_reports(test_pid) - assert event.message.formatted == "Oops" - assert :ok = Test.cleanup(test_pid) - end) - - assert {:ok, ""} = Sentry.capture_message("Oops") - end - - """ - @doc since: "10.2.0" - @spec start_collecting(keyword()) :: :ok - def start_collecting(options \\ []) when is_list(options) do - key = Keyword.get(options, :key, @events_key) - owner_pid = Keyword.get(options, :owner, self()) - cleanup? = Keyword.get(options, :cleanup, true) - - callers = - if owner_pid == self() do - callers() - else - [owner_pid] - end - - # Make sure the ownership server is started (this is idempotent). - ensure_ownership_server_started() - - case NimbleOwnership.fetch_owner(@server, callers, key) do - # No-op - {tag, ^owner_pid} when tag in [:ok, :shared_owner] -> - :ok - - {:shared_owner, _other_pid} -> - raise ArgumentError, - "Sentry.Test is in global mode and is already collecting reported events" - - {:ok, other_pid} -> - raise ArgumentError, "already collecting reported events from #{inspect(other_pid)}" - - :error -> - :ok - end - - {:ok, _} = - NimbleOwnership.get_and_update(@server, self(), key, fn events -> - {:ignored, events || []} - end) - - if not cleanup? do - :ok = NimbleOwnership.set_owner_to_manual_cleanup(@server, owner_pid) - end - - :ok - end - - @doc """ - Cleans up test resources associated with `owner_pid`. - - See the `:cleanup` option in `start_collecting/1` and the corresponding - example for more information. - """ - @doc since: "10.2.0" - @spec cleanup(pid()) :: :ok - def cleanup(owner_pid) when is_pid(owner_pid) do - :ok = NimbleOwnership.cleanup_owner(@server, owner_pid) - end - - @doc """ - Allows `pid_to_allow` to collect events back to the root process via `owner_pid`. - - `owner_pid` must be a PID that is currently collecting events or has been allowed - to collect events. If that's not the case, this function raises an error. - - `pid_to_allow` can also be a **function** that returns a PID. This is useful when - you want to allow a registered process that is not yet started to collect events. For example: - - Sentry.Test.allow_sentry_reports(self(), fn -> Process.whereis(:my_process) end) - - """ - @doc since: "10.2.0" - @spec allow_sentry_reports(pid(), pid() | (-> pid())) :: :ok - def allow_sentry_reports(owner_pid, pid_to_allow) - when is_pid(owner_pid) and (is_pid(pid_to_allow) or is_function(pid_to_allow, 0)) do - case NimbleOwnership.allow(@server, owner_pid, pid_to_allow, @events_key) do - :ok -> - :ok - - {:error, reason} -> - raise "failed to allow #{inspect(pid_to_allow)} to collect events: #{Exception.message(reason)}" - end - end - - @doc """ - Pops all the collected events from the current process. - - This function returns a list of all the events that have been collected from the current - process and all the processes that were allowed through it. If the current process - is not collecting events, this function raises an error. - - After this function returns, the current process will still be collecting events, but - the collected events will be reset to `[]`. - - ## Examples - - iex> Sentry.Test.start_collecting_sentry_reports() - :ok - iex> Sentry.capture_message("Oops") - {:ok, ""} - iex> [%Sentry.Event{} = event] = Sentry.Test.pop_sentry_reports() - iex> event.message.formatted - "Oops" - - """ - @doc since: "10.2.0" - @spec pop_sentry_reports(pid()) :: [Sentry.Event.t()] - def pop_sentry_reports(owner_pid \\ self()) when is_pid(owner_pid) do - result = - try do - NimbleOwnership.get_and_update(@server, owner_pid, @events_key, fn - nil -> {:not_collecting, []} - events when is_list(events) -> {events, []} - end) - catch - :exit, {:noproc, _} -> - raise ArgumentError, "not collecting reported events from #{inspect(owner_pid)}" - end - - case result do - {:ok, :not_collecting} -> - raise ArgumentError, "not collecting reported events from #{inspect(owner_pid)}" - - {:ok, events} -> - events - - {:error, error} when is_exception(error) -> - raise ArgumentError, "cannot pop Sentry reports: #{Exception.message(error)}" - end - end - - @doc """ - Pops all the collected transactions from the current process. - - This function returns a list of all the transactions that have been collected from the current - process and all the processes that were allowed through it. If the current process - is not collecting transactions, this function raises an error. - - After this function returns, the current process will still be collecting transactions, but - the collected transactions will be reset to `[]`. - - ## Examples - - iex> Sentry.Test.start_collecting_sentry_reports() - :ok - iex> Sentry.send_transaction(Sentry.Transaction.new(%{span_id: "123", start_timestamp: "2024-10-12T13:21:13", timestamp: "2024-10-12T13:21:13", spans: []})) - {:ok, ""} - iex> [%Sentry.Transaction{}] = Sentry.Test.pop_sentry_transactions() - - """ - @doc since: "10.2.0" - @spec pop_sentry_transactions(pid()) :: [Sentry.Transaction.t()] - def pop_sentry_transactions(owner_pid \\ self()) when is_pid(owner_pid) do - result = - try do - NimbleOwnership.get_and_update(@server, owner_pid, @transactions_key, fn - nil -> {:not_collecting, []} - transactions when is_list(transactions) -> {transactions, []} - end) - catch - :exit, {:noproc, _} -> - raise ArgumentError, "not collecting reported transactions from #{inspect(owner_pid)}" - end - - case result do - {:ok, :not_collecting} -> - raise ArgumentError, "not collecting reported transactions from #{inspect(owner_pid)}" - - {:ok, transactions} -> - transactions - - {:error, error} when is_exception(error) -> - raise ArgumentError, "cannot pop Sentry transactions: #{Exception.message(error)}" - end - end - - @doc """ - Pops all the collected log events from the current process. - - This function returns a list of all the log events that have been collected from the current - process and all the processes that were allowed through it. If the current process - is not collecting log events, this function raises an error. - - After this function returns, the current process will still be collecting log events, but - the collected log events will be reset to `[]`. - - ## Examples - - iex> Sentry.Test.start_collecting_sentry_reports() - :ok - iex> log_event = %Sentry.LogEvent{ - ...> level: :info, - ...> body: "Test log message", - ...> timestamp: System.system_time(:microsecond) / 1_000_000 - ...> } - iex> Sentry.Test.maybe_collect_logs([log_event]) - :collected - iex> [%Sentry.LogEvent{} = collected] = Sentry.Test.pop_sentry_logs() - iex> collected.body - "Test log message" - - """ - @doc since: "11.0.0" - @spec pop_sentry_logs(pid()) :: [Sentry.LogEvent.t()] - def pop_sentry_logs(owner_pid \\ self()) when is_pid(owner_pid) do - result = - try do - NimbleOwnership.get_and_update(@server, owner_pid, @logs_key, fn - nil -> {:not_collecting, []} - logs when is_list(logs) -> {logs, []} - end) - catch - :exit, {:noproc, _} -> - raise ArgumentError, "not collecting reported logs from #{inspect(owner_pid)}" - end - - case result do - {:ok, :not_collecting} -> - raise ArgumentError, "not collecting reported logs from #{inspect(owner_pid)}" - - {:ok, logs} -> - logs - - {:error, error} when is_exception(error) -> - raise ArgumentError, "cannot pop Sentry logs: #{Exception.message(error)}" - end - end - - ## Helpers - - defp ensure_ownership_server_started do - case Supervisor.start_child(Sentry.Supervisor, NimbleOwnership.child_spec(name: @server)) do - {:ok, pid} -> - pid - - {:error, {:already_started, pid}} -> - pid - - {:error, reason} -> - raise "could not start required processes for Sentry.Test: #{inspect(reason)}" - end - end - - defp callers do - [self()] ++ Process.get(:"$callers", []) - end -end diff --git a/mix.exs b/mix.exs index fb233f64..0d5e1050 100644 --- a/mix.exs +++ b/mix.exs @@ -97,7 +97,6 @@ defmodule Sentry.Mixfile do defp deps do [ {:nimble_options, "~> 1.0"}, - {:nimble_ownership, "~> 0.3.0 or ~> 1.0"}, # Optional dependencies {:hackney, "~> 1.8", optional: true}, diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs index 3dda57bf..af4d8d07 100644 --- a/test/sentry/integrations/oban/error_reporter_test.exs +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -2,6 +2,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do use ExUnit.Case, async: true import ExUnit.CaptureLog + import Sentry.TestHelpers alias Sentry.Integrations.Oban.ErrorReporter @@ -15,33 +16,35 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do @worker_as_string "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" describe "handle_event/4" do - test "reports the correct error to Sentry" do - Sentry.Test.start_collecting() + setup do + setup_bypass() + end + + test "reports the correct error to Sentry", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, []) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} - assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert [%{"stacktrace" => %{"frames" => [stacktrace]}} = exception] = event["exception"] - assert exception.type == "RuntimeError" - assert exception.value == "oops" - assert exception.mechanism.handled == true - assert stacktrace.module == MyWorker + assert exception["type"] == "RuntimeError" + assert exception["value"] == "oops" + assert exception["mechanism"]["handled"] == true + assert stacktrace["module"] == "Elixir.Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert stacktrace.function == + assert stacktrace["function"] == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1" - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert %{job: %Oban.Job{}} = event.integration_meta.oban + assert event["tags"]["oban_queue"] == "default" + assert event["tags"]["oban_state"] == "available" + assert event["tags"]["oban_worker"] == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert event.fingerprint == [@worker_as_string, "{{ default }}"] + assert event["fingerprint"] == [@worker_as_string, "{{ default }}"] end - test "unwraps Oban.PerformErrors and reports the wrapped error" do - Sentry.Test.start_collecting() + test "unwraps Oban.PerformErrors and reports the wrapped error", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job( :error, @@ -51,101 +54,111 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do [] ) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} - assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert [%{"stacktrace" => %{"frames" => [stacktrace]}} = exception] = event["exception"] - assert exception.type == "RuntimeError" - assert exception.value == "oops" - assert exception.mechanism.handled == true - assert stacktrace.module == MyWorker + assert exception["type"] == "RuntimeError" + assert exception["value"] == "oops" + assert exception["mechanism"]["handled"] == true + assert stacktrace["module"] == "Elixir.Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert stacktrace.function == + assert stacktrace["function"] == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker.process/1" - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert %{job: %Oban.Job{}} = event.integration_meta.oban + assert event["tags"]["oban_queue"] == "default" + assert event["tags"]["oban_state"] == "available" + assert event["tags"]["oban_worker"] == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" - assert event.fingerprint == [@worker_as_string, "{{ default }}"] + assert event["fingerprint"] == [@worker_as_string, "{{ default }}"] end - test "reports normalized non-exception errors to Sentry" do - Sentry.Test.start_collecting() + test "reports normalized non-exception errors to Sentry", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, :undef, []) - assert [event] = Sentry.Test.pop_sentry_reports() - assert %{job: %Oban.Job{}} = event.integration_meta.oban + assert [event] = collect_envelopes(ref, 1) |> extract_events() + + assert event["message"] == nil - assert event.message == nil + assert [%{"stacktrace" => %{"frames" => [stacktrace]}} = exception] = event["exception"] - assert [%{stacktrace: %{frames: [stacktrace]}} = exception] = event.exception + assert exception["type"] == "UndefinedFunctionError" - assert exception.type == "UndefinedFunctionError" - assert exception.value == "function #{@worker_as_string}.process/1 is undefined or private" - assert exception.mechanism.handled == true - assert stacktrace.module == MyWorker - assert stacktrace.function == "#{@worker_as_string}.process/1" + assert exception["value"] == + "function #{@worker_as_string}.process/1 is undefined or private" - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == @worker_as_string + assert exception["mechanism"]["handled"] == true + assert stacktrace["module"] == "Elixir.Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" + assert stacktrace["function"] == "#{@worker_as_string}.process/1" - assert event.fingerprint == [@worker_as_string, "{{ default }}"] + assert event["tags"]["oban_queue"] == "default" + assert event["tags"]["oban_state"] == "available" + assert event["tags"]["oban_worker"] == @worker_as_string + + assert event["fingerprint"] == [@worker_as_string, "{{ default }}"] end - test "reports exits to Sentry" do - Sentry.Test.start_collecting() + test "reports exits to Sentry", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:exit, :oops, []) - assert [event] = Sentry.Test.pop_sentry_reports() - assert %{job: %Oban.Job{}} = event.integration_meta.oban + assert [event] = collect_envelopes(ref, 1) |> extract_events() - assert event.message == %Sentry.Interfaces.Message{ - message: "Oban job #{@worker_as_string} exited: %s", - params: [":oops"], - formatted: "Oban job #{@worker_as_string} exited: :oops" - } + assert event["message"]["message"] == "Oban job #{@worker_as_string} exited: %s" + assert event["message"]["params"] == [":oops"] + assert event["message"]["formatted"] == "Oban job #{@worker_as_string} exited: :oops" - assert event.exception == [] + assert event["exception"] == [] - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == @worker_as_string + assert event["tags"]["oban_queue"] == "default" + assert event["tags"]["oban_state"] == "available" + assert event["tags"]["oban_worker"] == @worker_as_string - assert event.fingerprint == [@worker_as_string, "{{ default }}"] + assert event["fingerprint"] == [@worker_as_string, "{{ default }}"] end - test "reports throws to Sentry" do - Sentry.Test.start_collecting() + test "reports throws to Sentry", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:throw, :this_was_not_caught, []) - assert [event] = Sentry.Test.pop_sentry_reports() - assert %{job: %Oban.Job{}} = event.integration_meta.oban + assert [event] = collect_envelopes(ref, 1) |> extract_events() + + assert event["message"]["message"] == + "Oban job #{@worker_as_string} exited with an uncaught throw: %s" + + assert event["message"]["params"] == [":this_was_not_caught"] - assert event.message == %Sentry.Interfaces.Message{ - message: "Oban job #{@worker_as_string} exited with an uncaught throw: %s", - params: [":this_was_not_caught"], - formatted: - "Oban job #{@worker_as_string} exited with an uncaught throw: :this_was_not_caught" - } + assert event["message"]["formatted"] == + "Oban job #{@worker_as_string} exited with an uncaught throw: :this_was_not_caught" - assert event.exception == [] + assert event["exception"] == [] - assert event.tags.oban_queue == "default" - assert event.tags.oban_state == "available" - assert event.tags.oban_worker == @worker_as_string + assert event["tags"]["oban_queue"] == "default" + assert event["tags"]["oban_state"] == "available" + assert event["tags"]["oban_worker"] == @worker_as_string - assert event.fingerprint == [@worker_as_string, "{{ default }}"] + assert event["fingerprint"] == [@worker_as_string, "{{ default }}"] end for reason <- [:cancel, :discard] do - test "doesn't report Oban.PerformError with reason #{inspect(reason)}" do - Sentry.Test.start_collecting() + test "doesn't report Oban.PerformError with reason #{inspect(reason)}", %{bypass: bypass} do + test_pid = self() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + # Only flag error events as unexpected. Stray transaction envelopes + # from background processes (e.g., OpenTelemetry span processor) may + # arrive due to concurrent persistent_term DSN writes in async tests. + if body =~ ~r/"type":\s*"event"/ do + send(test_pid, :unexpected_envelope) + end + + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) emit_telemetry_for_failed_job( :error, @@ -153,33 +166,34 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do [] ) - assert Sentry.Test.pop_sentry_reports() == [] + refute_receive :unexpected_envelope, 100 end end - test "includes custom tags when oban_tags_to_sentry_tags function config option is set and returns non empty map" do - Sentry.Test.start_collecting() + test "includes custom tags when oban_tags_to_sentry_tags function config option is set and returns non empty map", + %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: fn _job -> %{custom_tag: "custom_value"} end ) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.tags.custom_tag == "custom_value" + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert event["tags"]["custom_tag"] == "custom_value" end - test "handles oban_tags_to_sentry_tags errors gracefully" do - Sentry.Test.start_collecting() + test "handles oban_tags_to_sentry_tags errors gracefully", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: fn _job -> raise "tag transform error" end ) - assert [_event] = Sentry.Test.pop_sentry_reports() + assert [_event] = collect_envelopes(ref, 1) |> extract_events() end - test "handles invalid oban_tags_to_sentry_tags return values gracefully" do - Sentry.Test.start_collecting() + test "handles invalid oban_tags_to_sentry_tags return values gracefully", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) test_cases = [ 1, @@ -193,35 +207,47 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: fn _job -> invalid_value end ) - - assert [_event] = Sentry.Test.pop_sentry_reports() end) + + events = collect_envelopes(ref, length(test_cases)) |> extract_events() + assert length(events) == length(test_cases) end - test "supports MFA tuple for oban_tags_to_sentry_tags" do + test "supports MFA tuple for oban_tags_to_sentry_tags", %{bypass: bypass} do defmodule TestTagsTransform do def transform(_job), do: %{custom_tag: "custom_value"} end - Sentry.Test.start_collecting() + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: {TestTagsTransform, :transform} ) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.tags.custom_tag == "custom_value" + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert event["tags"]["custom_tag"] == "custom_value" end - test "should_report_error_callback skips when callback returns false" do + test "should_report_error_callback skips when callback returns false", %{bypass: bypass} do job = %{"id" => "123", "entity" => "user", "type" => "delete"} |> MyWorker.new() |> Ecto.Changeset.apply_action!(:validate) reason = %RuntimeError{message: "oops"} + test_pid = self() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + # Only forward error events. Stray transaction envelopes from + # background processes may arrive in async tests. + if body =~ ~r/"type":\s*"event"/ do + send(test_pid, {:envelope, body}) + end - Sentry.Test.start_collecting() + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) job_attempt_1 = Map.merge(job, %{attempt: 1, max_attempts: 3}) @@ -236,7 +262,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end ) - assert [] = Sentry.Test.pop_sentry_reports() + refute_receive {:envelope, _}, 100 # Final attempt: callback returns true -> report job_attempt_3 = Map.merge(job, %{attempt: 3, max_attempts: 3}) @@ -251,12 +277,14 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end ) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} - assert event.tags.oban_worker == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" + assert_receive {:envelope, body} + assert [event] = decode_envelope!(body) |> Enum.map(&elem(&1, 1)) + assert [exception] = event["exception"] + assert exception["type"] == "RuntimeError" + assert event["tags"]["oban_worker"] == "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" end - test "should_report_error_callback receives worker module and job" do + test "should_report_error_callback receives worker module and job", %{bypass: bypass} do job = %{"id" => "123", "entity" => "user", "type" => "delete"} |> MyWorker.new() @@ -265,7 +293,9 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do reason = %RuntimeError{message: "oops"} test_pid = self() - Sentry.Test.start_collecting() + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) assert :ok = ErrorReporter.handle_event( @@ -283,19 +313,22 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do assert received_job == job end - test "should_report_error_callback reports when callback returns true" do - Sentry.Test.start_collecting() + test "should_report_error_callback reports when callback returns true", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], should_report_error_callback: fn _worker, _job -> true end ) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert [exception] = event["exception"] + assert exception["type"] == "RuntimeError" + assert exception["value"] == "oops" end - test "should_report_error_callback handles errors gracefully and defaults to reporting" do - Sentry.Test.start_collecting() + test "should_report_error_callback handles errors gracefully and defaults to reporting", + %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) log = capture_log(fn -> @@ -308,13 +341,36 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do assert log =~ "Sentry.Integrations.Oban.ErrorReporterTest.MyWorker" assert log =~ "callback error" - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "oops"} + assert [event] = collect_envelopes(ref, 1) |> extract_events() + assert [exception] = event["exception"] + assert exception["type"] == "RuntimeError" + assert exception["value"] == "oops" end end ## Helpers + # Sets up a Bypass collector that only forwards error event envelopes. + # This filters out stray transaction envelopes from background processes + # (e.g., OpenTelemetry span processor) that may hit this Bypass due to + # concurrent persistent_term DSN writes in async tests. + defp setup_bypass_event_collector(bypass) do + test_pid = self() + ref = make_ref() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + if body =~ ~r/"type":\s*"event"/ do + send(test_pid, {:bypass_envelope, ref, body}) + end + + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + ref + end + defp emit_telemetry_for_failed_job(kind, reason, stacktrace, config \\ []) do job = %{"id" => "123", "entity" => "user", "type" => "delete"} diff --git a/test/sentry/integrations/telemetry_test.exs b/test/sentry/integrations/telemetry_test.exs index 412b6b97..0abf43c9 100644 --- a/test/sentry/integrations/telemetry_test.exs +++ b/test/sentry/integrations/telemetry_test.exs @@ -1,62 +1,93 @@ defmodule Sentry.Integrations.TelemetryTest do use ExUnit.Case, async: true + import Sentry.TestHelpers + alias Sentry.Integrations.Telemetry describe "handle_event/4" do - test "reports errors" do - Sentry.Test.start_collecting() + setup do + setup_bypass() + end + + test "reports errors", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) handle_failure_event(:error, %RuntimeError{message: "oops"}, []) - assert [event] = Sentry.Test.pop_sentry_reports() + assert [event] = collect_envelopes(ref, 1) |> extract_events() - assert event.tags == %{ - telemetry_handler_id: "my_handler", - event_name: "[:my_app, :some_event]" + assert event["tags"] == %{ + "telemetry_handler_id" => "my_handler", + "event_name" => "[:my_app, :some_event]" } - assert event.original_exception == %RuntimeError{message: "oops"} + assert [exception] = event["exception"] + assert exception["type"] == "RuntimeError" + assert exception["value"] == "oops" end - test "reports Erlang errors (normalized)" do - Sentry.Test.start_collecting() + test "reports Erlang errors (normalized)", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) handle_failure_event(:error, {:badmap, :foo}, []) - assert [event] = Sentry.Test.pop_sentry_reports() + assert [event] = collect_envelopes(ref, 1) |> extract_events() - assert event.tags == %{ - telemetry_handler_id: "my_handler", - event_name: "[:my_app, :some_event]" + assert event["tags"] == %{ + "telemetry_handler_id" => "my_handler", + "event_name" => "[:my_app, :some_event]" } - assert event.original_exception == %BadMapError{term: :foo} + assert [exception] = event["exception"] + assert exception["type"] == "BadMapError" + assert exception["value"] =~ "expected a map, got:" + assert exception["value"] =~ ":foo" end for kind <- [:throw, :exit] do - test "reports #{kind}s" do - Sentry.Test.start_collecting() + test "reports #{kind}s", %{bypass: bypass} do + ref = setup_bypass_event_collector(bypass) handle_failure_event(unquote(kind), :foo, []) - assert [event] = Sentry.Test.pop_sentry_reports() + assert [event] = collect_envelopes(ref, 1) |> extract_events() - assert event.message.message == "Telemetry handler %s failed" - assert event.message.formatted == "Telemetry handler my_handler failed" + assert event["message"]["message"] == "Telemetry handler %s failed" + assert event["message"]["formatted"] == "Telemetry handler my_handler failed" - assert event.tags == %{ - telemetry_handler_id: "my_handler", - event_name: "[:my_app, :some_event]" + assert event["tags"] == %{ + "telemetry_handler_id" => "my_handler", + "event_name" => "[:my_app, :some_event]" } - assert event.extra == %{kind: inspect(unquote(kind)), reason: ":foo"} + assert event["extra"] == %{"kind" => inspect(unquote(kind)), "reason" => ":foo"} - assert event.original_exception == nil + assert event["exception"] == [] end end end + # Sets up a Bypass collector that only forwards error event envelopes. + # This filters out stray transaction envelopes from background processes + # that may hit this Bypass due to concurrent persistent_term DSN writes in async tests. + defp setup_bypass_event_collector(bypass) do + test_pid = self() + ref = make_ref() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + if body =~ ~r/"type":\s*"event"/ do + send(test_pid, {:bypass_envelope, ref, body}) + end + + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + ref + end + defp handle_failure_event(kind, reason, stacktrace) do Telemetry.handle_event( [:telemetry, :handler, :failure], diff --git a/test/sentry/opentelemetry/span_processor_test.exs b/test/sentry/opentelemetry/span_processor_test.exs index f548ed7c..b2d06cfc 100644 --- a/test/sentry/opentelemetry/span_processor_test.exs +++ b/test/sentry/opentelemetry/span_processor_test.exs @@ -31,79 +31,86 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end + setup do + setup_bypass() + end + @tag span_storage: true - test "sends captured root spans as transactions" do + test "sends captured root spans as transactions", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) TestEndpoint.child_instrumented_function("one") - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.event_id - assert transaction.environment == "test" - assert transaction.transaction_info == %{source: :custom} - assert_valid_iso8601(transaction.timestamp) - assert_valid_iso8601(transaction.start_timestamp) - assert transaction.timestamp > transaction.start_timestamp - assert_valid_trace_id(transaction.contexts.trace.trace_id) - assert length(transaction.spans) == 0 + assert tx["event_id"] + assert tx["environment"] == "test" + assert tx["transaction_info"] == %{"source" => "custom"} + assert_valid_iso8601(tx["timestamp"]) + assert_valid_iso8601(tx["start_timestamp"]) + assert tx["timestamp"] > tx["start_timestamp"] + assert_valid_trace_id(tx["contexts"]["trace"]["trace_id"]) + assert length(tx["spans"]) == 0 end @tag span_storage: true - test "sends captured spans as transactions with child spans" do + test "sends captured spans as transactions with child spans", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) TestEndpoint.instrumented_function() - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() - - assert_valid_iso8601(transaction.timestamp) - assert_valid_iso8601(transaction.start_timestamp) - assert transaction.timestamp > transaction.start_timestamp - assert length(transaction.spans) == 2 - - [child_span_one, child_span_two] = transaction.spans - assert child_span_one.op == "child_instrumented_function_one" - assert child_span_two.op == "child_instrumented_function_two" - assert child_span_one.parent_span_id == transaction.contexts.trace.span_id - assert child_span_two.parent_span_id == transaction.contexts.trace.span_id - - assert_valid_iso8601(child_span_one.timestamp) - assert_valid_iso8601(child_span_one.start_timestamp) - assert_valid_iso8601(child_span_two.timestamp) - assert_valid_iso8601(child_span_two.start_timestamp) - - assert child_span_one.timestamp > child_span_one.start_timestamp - assert child_span_two.timestamp > child_span_two.start_timestamp - assert transaction.timestamp >= child_span_one.timestamp - assert transaction.timestamp >= child_span_two.timestamp - assert transaction.start_timestamp <= child_span_one.start_timestamp - assert transaction.start_timestamp <= child_span_two.start_timestamp - - assert_valid_trace_id(transaction.contexts.trace.trace_id) - assert_valid_trace_id(child_span_one.trace_id) - assert_valid_trace_id(child_span_two.trace_id) + [tx] = collect_envelopes(ref, 1) |> extract_transactions() + + assert_valid_iso8601(tx["timestamp"]) + assert_valid_iso8601(tx["start_timestamp"]) + assert tx["timestamp"] > tx["start_timestamp"] + assert length(tx["spans"]) == 2 + + [child_span_one, child_span_two] = tx["spans"] + assert child_span_one["op"] == "child_instrumented_function_one" + assert child_span_two["op"] == "child_instrumented_function_two" + assert child_span_one["parent_span_id"] == tx["contexts"]["trace"]["span_id"] + assert child_span_two["parent_span_id"] == tx["contexts"]["trace"]["span_id"] + + assert_valid_iso8601(child_span_one["timestamp"]) + assert_valid_iso8601(child_span_one["start_timestamp"]) + assert_valid_iso8601(child_span_two["timestamp"]) + assert_valid_iso8601(child_span_two["start_timestamp"]) + + assert child_span_one["timestamp"] > child_span_one["start_timestamp"] + assert child_span_two["timestamp"] > child_span_two["start_timestamp"] + assert tx["timestamp"] >= child_span_one["timestamp"] + assert tx["timestamp"] >= child_span_two["timestamp"] + assert tx["start_timestamp"] <= child_span_one["start_timestamp"] + assert tx["start_timestamp"] <= child_span_two["start_timestamp"] + + assert_valid_trace_id(tx["contexts"]["trace"]["trace_id"]) + assert_valid_trace_id(child_span_one["trace_id"]) + assert_valid_trace_id(child_span_two["trace_id"]) end @tag span_storage: true - test "removes span records from storage after sending a transaction", %{table_name: table_name} do + test "removes span records from storage after sending a transaction", %{ + table_name: table_name, + bypass: bypass + } do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) TestEndpoint.instrumented_function() - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert SpanStorage.get_root_span(transaction.contexts.trace.span_id, table_name: table_name) == + assert SpanStorage.get_root_span(tx["contexts"]["trace"]["span_id"], table_name: table_name) == nil assert [] == - SpanStorage.get_child_spans(transaction.contexts.trace.span_id, + SpanStorage.get_child_spans(tx["contexts"]["trace"]["span_id"], table_name: table_name ) end @@ -130,70 +137,76 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do describe "sampling behavior with root and child spans" do @tag span_storage: true - test "drops entire trace when root span is not sampled" do + test "drops entire trace when root span is not sampled", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 0.0) original_sampler = Application.get_env(:opentelemetry, :sampler) Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) - Sentry.Test.start_collecting_sentry_reports() + test_pid = self() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + send(test_pid, :unexpected_envelope) + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) Enum.each(1..5, fn _ -> TestEndpoint.instrumented_function() end) - assert [] = Sentry.Test.pop_sentry_transactions() + refute_receive :unexpected_envelope, 200 Application.put_env(:opentelemetry, :sampler, original_sampler) end @tag span_storage: true - test "samples entire trace when root span is sampled" do + test "samples entire trace when root span is sampled", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) TestEndpoint.instrumented_function() - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() - assert length(transaction.spans) == 2 + [tx] = collect_envelopes(ref, 1) |> extract_transactions() + assert length(tx["spans"]) == 2 - [child_span_one, child_span_two] = transaction.spans - assert transaction.contexts.trace.trace_id == child_span_one.trace_id - assert transaction.contexts.trace.trace_id == child_span_two.trace_id + [child_span_one, child_span_two] = tx["spans"] + assert tx["contexts"]["trace"]["trace_id"] == child_span_one["trace_id"] + assert tx["contexts"]["trace"]["trace_id"] == child_span_two["trace_id"] end @tag span_storage: true - test "child spans inherit parent sampling decision" do + test "child spans inherit parent sampling decision", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 0.5) original_sampler = Application.get_env(:opentelemetry, :sampler) Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Enum.each(1..10, fn _ -> TestEndpoint.instrumented_function() end) - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() - Enum.each(transactions, fn transaction -> - assert length(transaction.spans) == 2 + Enum.each(transactions, fn tx -> + assert length(tx["spans"]) == 2 - [child_span_one, child_span_two] = transaction.spans - assert transaction.contexts.trace.trace_id == child_span_one.trace_id - assert transaction.contexts.trace.trace_id == child_span_two.trace_id + [child_span_one, child_span_two] = tx["spans"] + assert tx["contexts"]["trace"]["trace_id"] == child_span_one["trace_id"] + assert tx["contexts"]["trace"]["trace_id"] == child_span_two["trace_id"] end) Application.put_env(:opentelemetry, :sampler, original_sampler) end @tag span_storage: true - test "nested child spans maintain hierarchy" do + test "nested child spans maintain hierarchy", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "root_span" do Tracer.with_span "level_1_child" do @@ -211,43 +224,43 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert length(transaction.spans) == 4 + assert length(tx["spans"]) == 4 - trace_id = transaction.contexts.trace.trace_id + trace_id = tx["contexts"]["trace"]["trace_id"] - Enum.each(transaction.spans, fn span -> - assert span.trace_id == trace_id + Enum.each(tx["spans"], fn span -> + assert span["trace_id"] == trace_id end) - span_names = Enum.map(transaction.spans, & &1.op) |> Enum.sort() + span_names = Enum.map(tx["spans"], & &1["op"]) |> Enum.sort() expected_names = ["level_1_child", "level_1_sibling", "level_2_child", "level_2_sibling"] assert span_names == expected_names end @tag span_storage: true - test "child-only spans without root are handled correctly" do + test "child-only spans without root are handled correctly", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) TestEndpoint.child_instrumented_function("standalone") - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert length(transaction.spans) == 0 - assert transaction.transaction == "child_instrumented_function_standalone" + assert length(tx["spans"]) == 0 + assert tx["transaction"] == "child_instrumented_function_standalone" end @tag span_storage: true - test "concurrent traces maintain independent sampling decisions" do + test "concurrent traces maintain independent sampling decisions", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 0.5) original_sampler = Application.get_env(:opentelemetry, :sampler) Application.put_env(:opentelemetry, :sampler, {Sentry.OpenTelemetry.Sampler, [drop: []]}) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) tasks = Enum.map(1..20, fn i -> @@ -262,12 +275,13 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Enum.each(tasks, &Task.await/1) - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() - Enum.each(transactions, fn transaction -> - assert length(transaction.spans) == 1 - [child_span] = transaction.spans - assert child_span.trace_id == transaction.contexts.trace.trace_id + Enum.each(transactions, fn tx -> + assert length(tx["spans"]) == 1 + [child_span] = tx["spans"] + assert child_span["trace_id"] == tx["contexts"]["trace"]["trace_id"] end) assert length(transactions) < 20 @@ -276,7 +290,7 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end @tag span_storage: true - test "span processor respects sampler drop configuration" do + test "span processor respects sampler drop configuration", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) original_sampler = Application.get_env(:opentelemetry, :sampler) @@ -287,7 +301,7 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do {Sentry.OpenTelemetry.Sampler, [drop: ["child_instrumented_function_one"]]} ) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "root_span" do Tracer.with_span "child_instrumented_function_one" do @@ -299,13 +313,14 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() - Enum.each(transactions, fn transaction -> - trace_id = transaction.contexts.trace.trace_id + Enum.each(transactions, fn tx -> + trace_id = tx["contexts"]["trace"]["trace_id"] - Enum.each(transaction.spans, fn span -> - assert span.trace_id == trace_id + Enum.each(tx["spans"], fn span -> + assert span["trace_id"] == trace_id end) end) @@ -313,10 +328,12 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end @tag span_storage: true - test "treats HTTP server request spans as transaction roots for distributed tracing" do + test "treats HTTP server request spans as transaction roots for distributed tracing", %{ + bypass: bypass + } do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) # Simulate an incoming HTTP request with an external parent span ID (from browser/client) # This represents a distributed trace where the client started the trace @@ -362,45 +379,46 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end # Should capture the HTTP request span as a transaction root despite having an external parent - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() # Verify transaction properties - assert transaction.transaction == "POST /api/users" - assert transaction.transaction_info == %{source: :custom} - assert length(transaction.spans) == 2 + assert tx["transaction"] == "POST /api/users" + assert tx["transaction_info"] == %{"source" => "custom"} + assert length(tx["spans"]) == 2 # Verify child spans are properly included - span_ops = Enum.map(transaction.spans, & &1.op) |> Enum.sort() + span_ops = Enum.map(tx["spans"], & &1["op"]) |> Enum.sort() assert span_ops == ["db", "db"] # Verify child spans have detailed data (like SQL queries) - [span1, span2] = transaction.spans - assert span1.description =~ "INSERT INTO" - assert span2.description =~ "INSERT INTO" - assert span1.data["db.system"] == :postgresql - assert span2.data["db.system"] == :postgresql - assert span1.data["db.statement"] =~ "INSERT INTO users" - assert span2.data["db.statement"] =~ "INSERT INTO notifications" + [span1, span2] = tx["spans"] + assert span1["description"] =~ "INSERT INTO" + assert span2["description"] =~ "INSERT INTO" + assert span1["data"]["db.system"] == "postgresql" + assert span2["data"]["db.system"] == "postgresql" + assert span1["data"]["db.statement"] =~ "INSERT INTO users" + assert span2["data"]["db.statement"] =~ "INSERT INTO notifications" # Verify all spans share the same trace ID (from the external parent) - trace_id = transaction.contexts.trace.trace_id + trace_id = tx["contexts"]["trace"]["trace_id"] - Enum.each(transaction.spans, fn span -> - assert span.trace_id == trace_id + Enum.each(tx["spans"], fn span -> + assert span["trace_id"] == trace_id end) # The transaction should have the external parent's trace ID - assert transaction.contexts.trace.trace_id == + assert tx["contexts"]["trace"]["trace_id"] == "1234567890abcdef1234567890abcdef" end @tag span_storage: true test "cleans up HTTP server span and children after sending distributed trace transaction", %{ - table_name: table_name + table_name: table_name, + bypass: bypass } do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) # Simulate an incoming HTTP request with an external parent span ID (from browser/client) external_trace_id = 0x1234567890ABCDEF1234567890ABCDEF @@ -430,11 +448,11 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end # Should capture the HTTP request span as a transaction - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() # Verify the HTTP server span was removed from storage # (even though it was stored as a child span due to having a remote parent) - http_server_span_id = transaction.contexts.trace.span_id + http_server_span_id = tx["contexts"]["trace"]["span_id"] remote_parent_span_id_str = "abcdef1234567890" # The HTTP server span should not exist in storage anymore @@ -455,10 +473,10 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do describe "get_op_description/1" do @tag span_storage: true - test "HTTP server span with url.path includes path in description" do + test "HTTP server span with url.path includes path in description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "GET /api/users", %{ kind: :server, @@ -470,17 +488,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "http.server" - assert transaction.contexts.trace.description == "GET /api/users" + assert tx["contexts"]["trace"]["op"] == "http.server" + assert tx["contexts"]["trace"]["description"] == "GET /api/users" end @tag span_storage: true - test "HTTP server span without url.path uses only method in description" do + test "HTTP server span without url.path uses only method in description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "GET", %{ kind: :server, @@ -491,17 +509,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "http.server" - assert transaction.contexts.trace.description == "GET" + assert tx["contexts"]["trace"]["op"] == "http.server" + assert tx["contexts"]["trace"]["description"] == "GET" end @tag span_storage: true - test "HTTP client span with url.path includes path in description" do + test "HTTP client span with url.path includes path in description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "GET /external/api", %{ kind: :client, @@ -513,17 +531,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "http.client" - assert transaction.contexts.trace.description == "GET /external/api" + assert tx["contexts"]["trace"]["op"] == "http.client" + assert tx["contexts"]["trace"]["description"] == "GET /external/api" end @tag span_storage: true - test "HTTP server span with client.address includes address in description" do + test "HTTP server span with client.address includes address in description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "POST /api/login", %{ kind: :server, @@ -536,17 +554,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "http.server" - assert transaction.contexts.trace.description == "POST /api/login from 192.168.1.100" + assert tx["contexts"]["trace"]["op"] == "http.server" + assert tx["contexts"]["trace"]["description"] == "POST /api/login from 192.168.1.100" end @tag span_storage: true - test "database span uses db op and query as description" do + test "database span uses db op and query as description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "SELECT users", %{ kind: :client, @@ -558,17 +576,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "db" - assert transaction.contexts.trace.description == "SELECT * FROM users WHERE id = $1" + assert tx["contexts"]["trace"]["op"] == "db" + assert tx["contexts"]["trace"]["description"] == "SELECT * FROM users WHERE id = $1" end @tag span_storage: true - test "database span without statement has nil description" do + test "database span without statement has nil description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "db.connect", %{ kind: :client, @@ -579,17 +597,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "db" - assert transaction.contexts.trace.description == nil + assert tx["contexts"]["trace"]["op"] == "db" + assert tx["contexts"]["trace"]["description"] == nil end @tag span_storage: true - test "Oban span uses queue.process op and worker as description" do + test "Oban span uses queue.process op and worker as description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "MyApp.Workers.EmailWorker process", %{ kind: :consumer, @@ -601,35 +619,34 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "queue.process" - assert transaction.contexts.trace.description == "MyApp.Workers.EmailWorker" - # Also verify transaction name uses worker name for Oban spans - assert transaction.transaction == "MyApp.Workers.EmailWorker" + assert tx["contexts"]["trace"]["op"] == "queue.process" + assert tx["contexts"]["trace"]["description"] == "MyApp.Workers.EmailWorker" + assert tx["transaction"] == "MyApp.Workers.EmailWorker" end @tag span_storage: true - test "generic span uses span name for both op and description" do + test "generic span uses span name for both op and description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "custom_operation" do Process.sleep(1) end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.contexts.trace.op == "custom_operation" - assert transaction.contexts.trace.description == "custom_operation" + assert tx["contexts"]["trace"]["op"] == "custom_operation" + assert tx["contexts"]["trace"]["description"] == "custom_operation" end @tag span_storage: true - test "child HTTP span has correct op and description" do + test "child HTTP span has correct op and description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "parent_operation" do Tracer.with_span "GET /external/service", %{ @@ -643,20 +660,20 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert length(transaction.spans) == 1 - [child_span] = transaction.spans + assert length(tx["spans"]) == 1 + [child_span] = tx["spans"] - assert child_span.op == "http.client" - assert child_span.description == "GET /external/service" + assert child_span["op"] == "http.client" + assert child_span["description"] == "GET /external/service" end @tag span_storage: true - test "child database span has correct op and description" do + test "child database span has correct op and description", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "parent_operation" do Tracer.with_span "db.query", %{ @@ -670,13 +687,13 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - assert [%Sentry.Transaction{} = transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert length(transaction.spans) == 1 - [child_span] = transaction.spans + assert length(tx["spans"]) == 1 + [child_span] = tx["spans"] - assert child_span.op == "db" - assert child_span.description == "INSERT INTO orders (user_id) VALUES (?)" + assert child_span["op"] == "db" + assert child_span["description"] == "INSERT INTO orders (user_id) VALUES (?)" end end @@ -684,14 +701,14 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do @tag span_storage: true test "in-progress child span is preserved and becomes transaction root when parent finishes first", %{ - table_name: table_name + table_name: table_name, + bypass: bypass } do - # This test reproduces the race condition where a parent span (HTTP request) - # finishes and sends its transaction before a child span (LiveView mount) - # has completed. The child span data should NOT be lost. put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) alias Sentry.OpenTelemetry.{SpanStorage, SpanRecord} @@ -824,9 +841,9 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do describe "span links" do @tag span_storage: true - test "root span with links includes links in trace context" do + test "root span with links includes links in trace context", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) # Create a source span and capture its context source_ctx = @@ -848,27 +865,28 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(10) end - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() linked_tx = - Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end) + Enum.find(transactions, fn tx -> tx["transaction"] == "GET /api/linked" end) assert linked_tx != nil - trace_links = linked_tx.contexts.trace.links + trace_links = linked_tx["contexts"]["trace"]["links"] assert is_list(trace_links) assert length(trace_links) == 1 [span_link] = trace_links - assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/) - assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/) - refute Map.has_key?(span_link, :attributes) + assert String.match?(span_link["trace_id"], ~r/^[a-f0-9]{32}$/) + assert String.match?(span_link["span_id"], ~r/^[a-f0-9]{16}$/) + refute Map.has_key?(span_link, "attributes") end @tag span_storage: true - test "root span with links preserves link attributes" do + test "root span with links preserves link attributes", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) source_ctx = Tracer.with_span "source_span" do @@ -888,19 +906,20 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(10) end - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() linked_tx = - Enum.find(transactions, fn tx -> tx.transaction == "GET /api/linked" end) + Enum.find(transactions, fn tx -> tx["transaction"] == "GET /api/linked" end) - [span_link] = linked_tx.contexts.trace.links - assert span_link.attributes == %{"my.key" => "my.value"} + [span_link] = linked_tx["contexts"]["trace"]["links"] + assert span_link["attributes"] == %{"my.key" => "my.value"} end @tag span_storage: true - test "child span with links includes links in the span struct" do + test "child span with links includes links in the span struct", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) source_ctx = Tracer.with_span "source_span" do @@ -921,26 +940,27 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() parent_tx = - Enum.find(transactions, fn tx -> tx.transaction == "GET /api/parent" end) + Enum.find(transactions, fn tx -> tx["transaction"] == "GET /api/parent" end) - assert length(parent_tx.spans) == 1 - [child_span] = parent_tx.spans + assert length(parent_tx["spans"]) == 1 + [child_span] = parent_tx["spans"] - assert is_list(child_span.links) - assert length(child_span.links) == 1 + assert is_list(child_span["links"]) + assert length(child_span["links"]) == 1 - [span_link] = child_span.links - assert String.match?(span_link.trace_id, ~r/^[a-f0-9]{32}$/) - assert String.match?(span_link.span_id, ~r/^[a-f0-9]{16}$/) + [span_link] = child_span["links"] + assert String.match?(span_link["trace_id"], ~r/^[a-f0-9]{32}$/) + assert String.match?(span_link["span_id"], ~r/^[a-f0-9]{16}$/) end @tag span_storage: true - test "spans without links have nil links" do + test "spans without links have nil links", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) Tracer.with_span "GET /api/no-links", %{ kind: :server, @@ -954,17 +974,17 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do end end - [transaction] = Sentry.Test.pop_sentry_transactions() + [tx] = collect_envelopes(ref, 1) |> extract_transactions() - refute Map.has_key?(transaction.contexts.trace, :links) - assert [child_span] = transaction.spans - assert child_span.links == nil + refute Map.has_key?(tx["contexts"]["trace"], "links") + assert [child_span] = tx["spans"] + assert child_span["links"] == nil end @tag span_storage: true - test "span with multiple links preserves all links" do + test "span with multiple links preserves all links", %{bypass: bypass} do put_test_config(environment_name: "test", traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + ref = setup_bypass_envelope_collector(bypass) source_ctx_1 = Tracer.with_span "source_1" do @@ -990,27 +1010,28 @@ defmodule Sentry.Opentelemetry.SpanProcessorTest do Process.sleep(10) end - transactions = Sentry.Test.pop_sentry_transactions() + Process.sleep(200) + transactions = collect_envelopes(ref, 100, timeout: 500) |> extract_transactions() linked_tx = - Enum.find(transactions, fn tx -> tx.transaction == "GET /api/multi-linked" end) + Enum.find(transactions, fn tx -> tx["transaction"] == "GET /api/multi-linked" end) - trace_links = linked_tx.contexts.trace.links + trace_links = linked_tx["contexts"]["trace"]["links"] assert length(trace_links) == 2 # Both links should have valid trace/span IDs Enum.each(trace_links, fn link -> - assert String.match?(link.trace_id, ~r/^[a-f0-9]{32}$/) - assert String.match?(link.span_id, ~r/^[a-f0-9]{16}$/) + assert String.match?(link["trace_id"], ~r/^[a-f0-9]{32}$/) + assert String.match?(link["span_id"], ~r/^[a-f0-9]{16}$/) end) # The two links should point to different spans - span_ids = Enum.map(trace_links, & &1.span_id) + span_ids = Enum.map(trace_links, & &1["span_id"]) assert length(Enum.uniq(span_ids)) == 2 # The link with attributes should preserve them - link_with_attrs = Enum.find(trace_links, &Map.has_key?(&1, :attributes)) - assert link_with_attrs.attributes == %{"order" => "second"} + link_with_attrs = Enum.find(trace_links, &Map.has_key?(&1, "attributes")) + assert link_with_attrs["attributes"] == %{"order" => "second"} end end end diff --git a/test/sentry/test_test.exs b/test/sentry/test_test.exs deleted file mode 100644 index 623da1e6..00000000 --- a/test/sentry/test_test.exs +++ /dev/null @@ -1,221 +0,0 @@ -defmodule Sentry.TestTest do - use ExUnit.Case, async: false - - import Sentry.TestHelpers - - alias Sentry.Event - alias Sentry.Test - alias Sentry.{LogEvent, TelemetryProcessor} - - doctest Test - - setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1", dedup_events: false) - %{bypass: bypass} - end - - test "within a single process" do - assert :ok = Test.start_collecting_sentry_reports() - - # Start with a clean slate. - assert Test.pop_sentry_reports() == [] - - assert {:ok, ""} = Sentry.capture_message("Oops") - assert {:ok, ""} = Sentry.capture_message("Another one") - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops" - assert event2.message.formatted == "Another one" - - # Make sure that popping actually removes the events. - assert Test.pop_sentry_reports() == [] - end - - test "collecting, reporting, and popping from different processes" do - process_count = Enum.random(5..10) - - fun = fn index -> - assert :ok = Test.start_collecting_sentry_reports() - - assert Test.pop_sentry_reports() == [] - - assert {:ok, ""} = Sentry.capture_message("Oops #{index}") - assert {:ok, ""} = Sentry.capture_message("Another one #{index}") - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops #{index}" - assert event2.message.formatted == "Another one #{index}" - - assert Test.pop_sentry_reports() == [] - - :ok - end - - assert 1..process_count - |> Enum.map(fn index -> Task.async(fn -> fun.(index) end) end) - |> Task.await_many(2000) == List.duplicate(:ok, process_count) - end - - test "reporting from child processes (that have $callers) is allowed even without explicit allowance" do - parent_pid = self() - - # Collect from self(). - assert :ok = Test.start_collecting_sentry_reports() - - {:ok, child_pid} = - Task.start_link(fn -> - receive do - :go -> - assert {:ok, ""} = Sentry.capture_message("Oops from child process") - send(parent_pid, :done) - end - end) - - assert {:ok, ""} = Sentry.capture_message("Oops from parent process") - - send(child_pid, :go) - assert_receive :done - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops from parent process" - assert event2.message.formatted == "Oops from child process" - end - - test "explicitly allowing other processes" do - parent_pid = self() - - # Collect from self(). - assert :ok = Test.start_collecting_sentry_reports() - - {:ok, child_pid} = - Task.start_link(fn -> - Process.delete(:"$callers") - - receive do - :go -> - assert {:ok, ""} = Sentry.capture_message("Oops from child process") - send(parent_pid, :done) - end - end) - - Test.allow_sentry_reports(parent_pid, child_pid) - - assert {:ok, ""} = Sentry.capture_message("Oops from parent process") - - send(child_pid, :go) - assert_receive :done - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops from parent process" - assert event2.message.formatted == "Oops from child process" - end - - test "explicitly allowing other processes with a lazy PID" do - parent_pid = self() - - # Collect from self() and allow the lazy child PID. - assert :ok = Test.start_collecting_sentry_reports() - Test.allow_sentry_reports(parent_pid, fn -> Process.whereis(:child) end) - - {:ok, child_pid} = - Task.start_link(fn -> - Process.delete(:"$callers") - - receive do - :go -> - assert {:ok, ""} = Sentry.capture_message("Oops from child process") - send(parent_pid, :done) - end - end) - - Process.register(child_pid, :child) - - Test.allow_sentry_reports(parent_pid, fn -> Process.whereis(:child) end) - - assert {:ok, ""} = Sentry.capture_message("Oops from parent process") - - send(child_pid, :go) - assert_receive :done - - assert [%Event{} = event1, %Event{} = event2] = Test.pop_sentry_reports() - assert event1.message.formatted == "Oops from parent process" - assert event2.message.formatted == "Oops from child process" - end - - test "reporting from non-allowed child processes", %{bypass: bypass} do - parent_pid = self() - - Bypass.expect(bypass, fn conn -> - assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert body =~ "Oops from child process" - Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) - end) - - # Collect from self(). - assert :ok = Test.start_collecting_sentry_reports() - - {:ok, child_pid} = - Task.start_link(fn -> - Process.delete(:"$callers") - - receive do - :go -> - send(parent_pid, {:done, Sentry.capture_message("Oops from child process")}) - end - end) - - monitor_ref = Process.monitor(child_pid) - assert {:ok, ""} = Sentry.capture_message("Oops from parent process") - - send(child_pid, :go) - assert_receive {:DOWN, ^monitor_ref, _, _, :normal}, 5000 - assert_receive {:done, {:ok, "340"}}, 1000 - - assert [%Event{} = event] = Test.pop_sentry_reports() - assert event.message.formatted == "Oops from parent process" - end - - test "implementing an expectation-based test workflow" do - test_pid = self() - - Test.start_collecting(owner: test_pid, cleanup: false) - - on_exit(fn -> - assert [%Event{} = event] = Test.pop_sentry_reports(test_pid) - assert event.message.formatted == "Oops" - assert :ok = Test.cleanup(test_pid) - end) - - assert {:ok, ""} = Sentry.capture_message("Oops") - end - - test "pop_sentry_logs works with per-test TelemetryProcessor" do - uid = System.unique_integer([:positive]) - processor_name = :"test_processor_logs_#{uid}" - - {:ok, _pid} = - start_supervised( - {TelemetryProcessor, name: processor_name, buffer_configs: %{log: %{batch_size: 1}}}, - id: processor_name - ) - - Process.put(:sentry_telemetry_processor, processor_name) - - assert :ok = Test.start_collecting_sentry_reports() - - log_event = %LogEvent{ - level: :info, - body: "per-test log", - timestamp: System.system_time(:microsecond) / 1_000_000 - } - - TelemetryProcessor.add(processor_name, log_event) - - # Give the scheduler time to process - Process.sleep(100) - - logs = Test.pop_sentry_logs() - assert [%LogEvent{body: "per-test log"}] = logs - end -end diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 84db63c9..8d3def03 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -122,7 +122,7 @@ defmodule SentryTest do end test "raises error with validate_and_ignore/1 in dev mode if opts passed are invalid " do - put_test_config(dsn: nil, test_mode: false) + put_test_config(dsn: nil) assert_raise NimbleOptions.ValidationError, fn -> NimbleOptions.validate!( @@ -138,18 +138,12 @@ defmodule SentryTest do ) end - test "does not send events if :dsn is not configured or nil (if not in test mode)" do - put_test_config(dsn: nil, test_mode: false) + test "does not send events if :dsn is not configured or nil" do + put_test_config(dsn: nil) event = Sentry.Event.transform_exception(%RuntimeError{message: "oops"}, []) assert :ignored = Sentry.send_event(event) end - test "if in test mode, swallows events if the :dsn is nil" do - put_test_config(dsn: nil, test_mode: true) - event = Sentry.Event.transform_exception(%RuntimeError{message: "oops"}, []) - assert {:ok, ""} = Sentry.send_event(event) - end - describe "send_check_in/1" do test "posts a check-in with all the explicit arguments", %{bypass: bypass} do put_test_config(environment_name: "test", release: "1.3.2") @@ -287,7 +281,7 @@ defmodule SentryTest do end test "ignores transaction when dsn is not configured", %{transaction: transaction} do - put_test_config(dsn: nil, test_mode: false) + put_test_config(dsn: nil) assert :ignored = Sentry.send_transaction(transaction) end diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 3355f048..01987f9c 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -123,6 +123,127 @@ defmodule Sentry.TestHelpers do ) end + @doc """ + Opens a Bypass instance and configures the DSN to point to it. + Returns a map with `:bypass` for use in test context. + + ## Options + + * Any extra config options to pass to `put_test_config/1`. + + ## Examples + + setup do + setup_bypass() + end + + setup do + setup_bypass(dedup_events: false) + end + + """ + @spec setup_bypass(keyword()) :: %{bypass: Bypass.t()} + def setup_bypass(extra_config \\ []) do + bypass = Bypass.open() + + # Stub all envelope requests by default so tests that don't explicitly + # collect envelopes won't fail from background span sends. + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + config = + [dsn: "http://public:secret@localhost:#{bypass.port}/1", finch_request_opts: [receive_timeout: 2000]] + |> Keyword.merge(extra_config) + + put_test_config(config) + %{bypass: bypass} + end + + @doc """ + Sets up a Bypass envelope collector that forwards all envelope bodies + to the test process as messages. Returns a reference for collecting results. + + Uses `Bypass.stub` (not `Bypass.expect`) to be resilient to stray requests + from background processes (e.g., OpenTelemetry span processor) that may + hit this Bypass due to concurrent persistent_term DSN writes in async tests. + + Use with `collect_envelopes/2` to retrieve the decoded envelopes. + """ + @spec setup_bypass_envelope_collector(Bypass.t()) :: reference() + def setup_bypass_envelope_collector(bypass) do + test_pid = self() + ref = make_ref() + + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test_pid, {:bypass_envelope, ref, body}) + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + ref + end + + @doc """ + Collects decoded envelopes sent to a Bypass collector. + + Returns a list of decoded envelope item lists. Each element is the result + of `decode_envelope!/1` for one HTTP request. + + ## Options + + * `:timeout` - timeout in ms to wait for each envelope (default: 1000) + + """ + @spec collect_envelopes(reference(), pos_integer(), keyword()) :: [[{map(), map()}]] + def collect_envelopes(ref, expected_count, opts \\ []) do + timeout = Keyword.get(opts, :timeout, 1000) + do_collect_envelopes(ref, expected_count, [], timeout) + end + + defp do_collect_envelopes(_ref, 0, acc, _timeout), do: Enum.reverse(acc) + + defp do_collect_envelopes(ref, remaining, acc, timeout) do + receive do + {:bypass_envelope, ^ref, body} -> + items = decode_envelope!(body) + do_collect_envelopes(ref, remaining - 1, [items | acc], timeout) + after + timeout -> + Enum.reverse(acc) + end + end + + @doc """ + Extracts event payloads from decoded envelope item lists. + """ + @spec extract_events([[{map(), map()}]]) :: [map()] + def extract_events(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "event"}, payload} <- items, + do: payload + end + + @doc """ + Extracts transaction payloads from decoded envelope item lists. + """ + @spec extract_transactions([[{map(), map()}]]) :: [map()] + def extract_transactions(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "transaction"}, payload} <- items, + do: payload + end + + @doc """ + Extracts log item payloads from decoded envelope item lists. + """ + @spec extract_log_items([[{map(), map()}]]) :: [map()] + def extract_log_items(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "log"}, payload} <- items, + do: payload + end + defp decode_envelope_items(items) do items |> Enum.chunk_every(2) diff --git a/test_integrations/phoenix_app/config/test.exs b/test_integrations/phoenix_app/config/test.exs index 9c7997b5..07b8b48e 100644 --- a/test_integrations/phoenix_app/config/test.exs +++ b/test_integrations/phoenix_app/config/test.exs @@ -32,11 +32,9 @@ config :phoenix_live_view, enable_expensive_runtime_checks: true config :sentry, - dsn: "https://public@sentry.example.com/1", environment_name: :dev, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], - test_mode: true, send_result: :sync, traces_sample_rate: 1.0, enable_logs: true, diff --git a/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs index f3e5b1c2..606be11f 100644 --- a/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs @@ -8,11 +8,10 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do alias Sentry.Integrations.Oban.ErrorReporter setup do - put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + %{bypass: bypass} = setup_bypass(traces_sample_rate: 1.0) + ref = setup_bypass_envelope_collector(bypass) - Sentry.Test.start_collecting_sentry_reports() - - :ok + %{bypass: bypass, ref: ref} end defmodule TestWorker do @@ -35,26 +34,27 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do def perform(_job), do: :ok end - test "captures Oban worker execution as transaction" do + test "captures Oban worker execution as transaction", %{ref: ref} do :ok = perform_job(TestWorker, %{test: "args"}) - transactions = Sentry.Test.pop_sentry_transactions() + envelopes = collect_envelopes(ref, 1) + transactions = extract_transactions(envelopes) assert length(transactions) == 1 - [transaction] = transactions + [tx] = transactions - assert transaction.transaction == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" - assert transaction.transaction_info == %{source: :custom} + assert tx["transaction"] == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" + assert tx["transaction_info"] == %{"source" => "custom"} - trace = transaction.contexts.trace - assert trace.origin == "opentelemetry_oban" - assert trace.op == "queue.process" - assert trace.description == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" - assert trace.data["oban.job.job_id"] - assert trace.data["messaging.destination.name"] == "default" - assert trace.data["oban.job.attempt"] == 1 + trace = tx["contexts"]["trace"] + assert trace["origin"] == "opentelemetry_oban" + assert trace["op"] == "queue.process" + assert trace["description"] == "Sentry.Integrations.Phoenix.ObanTest.TestWorker" + assert trace["data"]["oban.job.job_id"] + assert trace["data"]["messaging.destination.name"] == "default" + assert trace["data"]["oban.job.attempt"] == 1 - assert [] = transaction.spans + assert tx["spans"] == [] end describe "should_report_error_callback config" do @@ -69,7 +69,7 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do :ok end - test "skips error reporting when callback returns false" do + test "skips error reporting when callback returns false", %{ref: ref} do test_pid = self() ErrorReporter.attach( @@ -91,10 +91,11 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert %Oban.Job{} = received_job assert received_job.args == %{"should_fail" => true} - assert [] = Sentry.Test.pop_sentry_reports() + envelopes = collect_envelopes(ref, 10, timeout: 500) + assert extract_events(envelopes) == [] end - test "reports error when callback returns true" do + test "reports error when callback returns true", %{ref: ref} do test_pid = self() ErrorReporter.attach( @@ -113,14 +114,18 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert_receive {:callback_invoked, _worker, _job} - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "intentional failure for testing"} + envelopes = collect_envelopes(ref, 10, timeout: 500) + events = extract_events(envelopes) + assert [event] = events + + assert [%{"type" => "RuntimeError", "value" => "intentional failure for testing"} | _] = + event["exception"] - assert event.tags.oban_worker == + assert event["tags"]["oban_worker"] == "Sentry.Integrations.Phoenix.ObanTest.FailingWorker" end - test "callback receives worker module and full job struct" do + test "callback receives worker module and full job struct", %{ref: ref} do test_pid = self() ErrorReporter.attach( @@ -149,9 +154,12 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert job.max_attempts == 3 assert is_integer(job.attempt) assert is_integer(job.id) + + # Drain envelopes to avoid Bypass errors + collect_envelopes(ref, 10, timeout: 500) end - test "callback can make decisions based on attempt number" do + test "callback can make decisions based on attempt number", %{ref: ref} do test_pid = self() ErrorReporter.attach( @@ -174,10 +182,11 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert max_attempts == 3 assert should_report == false - assert [] = Sentry.Test.pop_sentry_reports() + envelopes = collect_envelopes(ref, 10, timeout: 500) + assert extract_events(envelopes) == [] end - test "handles callback errors gracefully and defaults to reporting" do + test "handles callback errors gracefully and defaults to reporting", %{ref: ref} do log = capture_log(fn -> ErrorReporter.attach( @@ -198,11 +207,15 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert log =~ "FailingWorker" assert log =~ "callback crashed!" - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "intentional failure for testing"} + envelopes = collect_envelopes(ref, 10, timeout: 500) + events = extract_events(envelopes) + assert [event] = events + + assert [%{"type" => "RuntimeError", "value" => "intentional failure for testing"} | _] = + event["exception"] end - test "reports error when no callback is configured" do + test "reports error when no callback is configured", %{ref: ref} do ErrorReporter.attach([]) {:ok, _job} = @@ -212,11 +225,15 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do Oban.drain_queue(queue: :default) - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.original_exception == %RuntimeError{message: "intentional failure for testing"} + envelopes = collect_envelopes(ref, 10, timeout: 500) + events = extract_events(envelopes) + assert [event] = events + + assert [%{"type" => "RuntimeError", "value" => "intentional failure for testing"} | _] = + event["exception"] end - test "callback can filter based on worker type" do + test "callback can filter based on worker type", %{ref: ref} do test_pid = self() ErrorReporter.attach( @@ -236,10 +253,11 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert_receive {:worker_check, FailingWorker, false} - assert [] = Sentry.Test.pop_sentry_reports() + envelopes = collect_envelopes(ref, 10, timeout: 500) + assert extract_events(envelopes) == [] end - test "callback receives nil and logs warning for non-existent worker module" do + test "callback receives nil and logs warning for non-existent worker module", %{ref: ref} do test_pid = self() log = @@ -280,8 +298,10 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do assert worker == nil assert received_job.worker == "NonExistent.Worker.Module" - assert [event] = Sentry.Test.pop_sentry_reports() - assert event.tags.oban_worker == "NonExistent.Worker.Module" + envelopes = collect_envelopes(ref, 1) + events = extract_events(envelopes) + assert [event] = events + assert event["tags"]["oban_worker"] == "NonExistent.Worker.Module" end end end diff --git a/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs index 3ed65913..9e310d8c 100644 --- a/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app/repo_test.exs @@ -6,32 +6,28 @@ defmodule PhoenixApp.RepoTest do import Sentry.TestHelpers setup do - put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) - - Sentry.Test.start_collecting_sentry_reports() + setup_bypass(traces_sample_rate: 1.0) end - test "instrumented top-level ecto transaction span" do - Repo.all(User) |> Enum.map(& &1.id) - - transactions = Sentry.Test.pop_sentry_transactions() + test "instrumented top-level ecto transaction span", %{bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) - assert length(transactions) == 1 + Repo.all(User) |> Enum.map(& &1.id) - assert [transaction] = transactions + assert [tx] = collect_envelopes(ref, 1) |> extract_transactions() - assert transaction.transaction_info == %{source: :custom} + assert tx["transaction_info"] == %{"source" => "custom"} - assert transaction.contexts.trace.op == "db" + assert tx["contexts"]["trace"]["op"] == "db" - assert transaction.contexts.trace.data["db.system"] == :sqlite - assert transaction.contexts.trace.data["db.type"] == :sql - assert transaction.contexts.trace.data["db.instance"] == "db/test.sqlite3" - assert transaction.contexts.trace.data["db.name"] == "db/test.sqlite3" + assert tx["contexts"]["trace"]["data"]["db.system"] == "sqlite" + assert tx["contexts"]["trace"]["data"]["db.type"] == "sql" + assert tx["contexts"]["trace"]["data"]["db.instance"] == "db/test.sqlite3" + assert tx["contexts"]["trace"]["data"]["db.name"] == "db/test.sqlite3" - assert String.starts_with?(transaction.contexts.trace.description, "SELECT") - assert String.starts_with?(transaction.contexts.trace.data["db.statement"], "SELECT") + assert String.starts_with?(tx["contexts"]["trace"]["description"], "SELECT") + assert String.starts_with?(tx["contexts"]["trace"]["data"]["db.statement"], "SELECT") - refute Map.has_key?(transaction.contexts.trace.data, "db.url") + refute Map.has_key?(tx["contexts"]["trace"]["data"], "db.url") end end diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs index 7f597c73..fa5d9044 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/exception_test.exs @@ -4,9 +4,13 @@ defmodule Sentry.Integrations.Phoenix.ExceptionTest do import Sentry.TestHelpers setup do - put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) + %{bypass: bypass} = setup_bypass(traces_sample_rate: 1.0) - Sentry.Test.start_collecting_sentry_reports() + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) + end) + + :ok end test "GET /exception sends exception to Sentry", %{conn: conn} do diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/logs_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/logs_test.exs index 72262df0..e0130163 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/logs_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/logs_test.exs @@ -11,94 +11,129 @@ defmodule Sentry.Integrations.Phoenix.LogsTest do Logger.configure(level: original_level) end) - put_test_config(dsn: "http://public:secret@localhost:8080/1", enable_logs: true) + %{bypass: bypass} = setup_bypass(enable_logs: true) - Sentry.Test.start_collecting_sentry_reports() - - _ = Sentry.Test.pop_sentry_logs() - - :ok + %{bypass: bypass} end describe "structured logging from HTTP requests" do - test "GET /logs captures logs with trace context", %{conn: conn} do + test "GET /logs captures logs with trace context", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + conn = get(conn, ~p"/logs") assert json_response(conn, 200)["message"] == "Logs demo completed - check your Sentry logs!" - logs = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes = collect_envelopes(ref, 10, timeout: 2000) + log_payloads = extract_log_items(envelopes) + logs = Enum.flat_map(log_payloads, fn payload -> payload["items"] end) app_logs = filter_app_logs(logs) assert length(app_logs) >= 4 for log <- app_logs do - assert is_binary(log.trace_id) - assert String.length(log.trace_id) == 32 + assert is_binary(log["trace_id"]) + assert String.length(log["trace_id"]) == 32 end - traced_logs = Enum.filter(app_logs, &(&1.span_id != nil)) + traced_logs = Enum.filter(app_logs, &(&1["span_id"] != nil)) assert length(traced_logs) >= 2 - log_bodies = Enum.map(app_logs, & &1.body) + log_bodies = Enum.map(app_logs, & &1["body"]) assert Enum.any?(log_bodies, &String.contains?(&1, "User session started")) assert Enum.any?(log_bodies, &String.contains?(&1, "Inside traced span")) assert Enum.any?(log_bodies, &String.contains?(&1, "Database query completed")) end - test "GET /logs app logs share trace_id within same request", %{conn: conn} do + test "GET /logs app logs share trace_id within same request", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs") - logs = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes = collect_envelopes(ref, 10, timeout: 2000) + log_payloads = extract_log_items(envelopes) + logs = Enum.flat_map(log_payloads, fn payload -> payload["items"] end) + app_logs = filter_app_logs(logs) assert length(app_logs) >= 2 - traced_logs = Enum.filter(app_logs, &(&1.span_id != nil)) - trace_ids = traced_logs |> Enum.map(& &1.trace_id) |> Enum.uniq() + traced_logs = Enum.filter(app_logs, &(&1["span_id"] != nil)) + trace_ids = traced_logs |> Enum.map(& &1["trace_id"]) |> Enum.uniq() assert length(trace_ids) == 1 end - test "GET /logs captures logs at different levels", %{conn: conn} do + test "GET /logs captures logs at different levels", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs") - logs = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes = collect_envelopes(ref, 10, timeout: 2000) + log_payloads = extract_log_items(envelopes) + logs = Enum.flat_map(log_payloads, fn payload -> payload["items"] end) + app_logs = filter_app_logs(logs) - levels = Enum.map(app_logs, & &1.level) |> Enum.uniq() + levels = Enum.map(app_logs, & &1["level"]) |> Enum.uniq() - assert :info in levels - assert :warn in levels - assert :error in levels + assert "info" in levels + assert "warn" in levels + assert "error" in levels end - test "GET /logs logs have proper span hierarchy", %{conn: conn} do + test "GET /logs logs have proper span hierarchy", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs") - logs = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes = collect_envelopes(ref, 10, timeout: 2000) + log_payloads = extract_log_items(envelopes) + logs = Enum.flat_map(log_payloads, fn payload -> payload["items"] end) + app_logs = filter_app_logs(logs) - traced_logs = Enum.filter(app_logs, &(&1.span_id != nil)) + traced_logs = Enum.filter(app_logs, &(&1["span_id"] != nil)) - span_ids = traced_logs |> Enum.map(& &1.span_id) |> Enum.uniq() + span_ids = traced_logs |> Enum.map(& &1["span_id"]) |> Enum.uniq() assert length(span_ids) >= 2 end - test "separate requests have different trace_ids", %{conn: conn} do + test "separate requests have different trace_ids", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs") - logs1 = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes1 = collect_envelopes(ref, 10, timeout: 2000) + log_payloads1 = extract_log_items(envelopes1) + logs1 = Enum.flat_map(log_payloads1, fn payload -> payload["items"] end) app_logs1 = filter_app_logs(logs1) + ref2 = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs") - logs2 = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes2 = collect_envelopes(ref2, 10, timeout: 2000) + log_payloads2 = extract_log_items(envelopes2) + logs2 = Enum.flat_map(log_payloads2, fn payload -> payload["items"] end) app_logs2 = filter_app_logs(logs2) assert length(app_logs1) >= 1 assert length(app_logs2) >= 1 - trace_id_1 = hd(app_logs1).trace_id - trace_id_2 = hd(app_logs2).trace_id + trace_id_1 = hd(app_logs1)["trace_id"] + trace_id_2 = hd(app_logs2)["trace_id"] assert trace_id_1 != trace_id_2 end @@ -106,45 +141,44 @@ defmodule Sentry.Integrations.Phoenix.LogsTest do describe "structured logging with complex metadata" do test "GET /logs-with-structs safely serializes struct attributes for JSON encoding", %{ - conn: conn + conn: conn, + bypass: bypass } do put_test_config(logs: [level: :info, excluded_domains: [:cowboy, :ranch], metadata: :all]) + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/logs-with-structs") - logs = Sentry.Test.pop_sentry_logs() + Sentry.TelemetryProcessor.flush() + + envelopes = collect_envelopes(ref, 10, timeout: 2000) + log_payloads = extract_log_items(envelopes) + logs = Enum.flat_map(log_payloads, fn payload -> payload["items"] end) struct_log = Enum.find(logs, fn log -> - String.contains?(log.body, "Log with struct metadata") + String.contains?(log["body"], "Log with struct metadata") end) assert struct_log != nil - log_map = Sentry.LogEvent.to_map(struct_log) - attrs = log_map.attributes + attrs = struct_log["attributes"] - assert %{type: "string", value: uri_value} = attrs["uri"] + assert %{"type" => "string", "value" => uri_value} = attrs["uri"] assert uri_value == inspect(URI.parse("https://example.com/path")) - assert %{type: "string", value: conn_value} = attrs["conn_info"] + assert %{"type" => "string", "value" => conn_value} = attrs["conn_info"] assert conn_value =~ "method" - assert %{type: "string", value: tags_value} = attrs["tags"] + assert %{"type" => "string", "value" => tags_value} = attrs["tags"] assert tags_value == "[:web, :test]" - - assert {:ok, json} = Sentry.JSON.encode(log_map, Sentry.Config.json_library()) - assert is_binary(json) - - assert {:ok, decoded} = Sentry.JSON.decode(json, Sentry.Config.json_library()) - assert decoded["attributes"]["uri"]["value"] == uri_value - assert decoded["attributes"]["tags"]["value"] == "[:web, :test]" end end defp filter_app_logs(logs) do Enum.filter(logs, fn log -> - body = log.body + body = log["body"] String.contains?(body, "User session started") or String.contains?(body, "Processing user request") or diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs index ef73daf4..23640c78 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/controllers/transaction_test.exs @@ -5,142 +5,150 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do import Sentry.TestHelpers setup do - put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) - - Sentry.Test.start_collecting_sentry_reports() + setup_bypass(traces_sample_rate: 1.0) end - test "GET /transaction", %{conn: conn} do - # TODO: Wrap this in a transaction that the web server usually - # would wrap it in. + test "GET /transaction", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/transaction") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 1) |> extract_transactions() assert length(transactions) == 1 - assert [transaction] = transactions + assert [tx] = transactions - assert transaction.transaction == "test_span" - assert transaction.transaction_info == %{source: :custom} + assert tx["transaction"] == "test_span" + assert tx["transaction_info"] == %{"source" => "custom"} - trace = transaction.contexts.trace - assert trace.origin == "phoenix_app" - assert trace.op == "test_span" - assert trace.data == %{} + trace = tx["contexts"]["trace"] + assert trace["origin"] == "phoenix_app" + assert trace["op"] == "test_span" + assert trace["data"] == %{} end - test "GET /users", %{conn: conn} do + test "GET /users", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/users") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 2) |> extract_transactions() assert length(transactions) == 2 - assert [mount_transaction, handle_params_transaction] = transactions + assert [mount_tx, handle_params_tx] = transactions - assert mount_transaction.transaction == "PhoenixAppWeb.UserLive.Index.mount" - assert mount_transaction.transaction_info == %{source: :custom} + assert mount_tx["transaction"] == "PhoenixAppWeb.UserLive.Index.mount" + assert mount_tx["transaction_info"] == %{"source" => "custom"} - trace = mount_transaction.contexts.trace - assert trace.origin == "opentelemetry_phoenix" - assert trace.op == "PhoenixAppWeb.UserLive.Index.mount" - assert trace.data == %{} + trace = mount_tx["contexts"]["trace"] + assert trace["origin"] == "opentelemetry_phoenix" + assert trace["op"] == "PhoenixAppWeb.UserLive.Index.mount" + assert trace["data"] == %{} - assert [span_ecto] = mount_transaction.spans + assert [span_ecto] = mount_tx["spans"] - assert span_ecto.op == "db" + assert span_ecto["op"] == "db" - assert span_ecto.description == + assert span_ecto["description"] == "SELECT u0.\"id\", u0.\"name\", u0.\"age\", u0.\"inserted_at\", u0.\"updated_at\" FROM \"users\" AS u0" - assert handle_params_transaction.transaction == + assert handle_params_tx["transaction"] == "PhoenixAppWeb.UserLive.Index.handle_params" - assert handle_params_transaction.transaction_info == %{source: :custom} + assert handle_params_tx["transaction_info"] == %{"source" => "custom"} - trace = handle_params_transaction.contexts.trace - assert trace.origin == "opentelemetry_phoenix" - assert trace.op == "PhoenixAppWeb.UserLive.Index.handle_params" - assert trace.data == %{} + trace = handle_params_tx["contexts"]["trace"] + assert trace["origin"] == "opentelemetry_phoenix" + assert trace["op"] == "PhoenixAppWeb.UserLive.Index.handle_params" + assert trace["data"] == %{} end - test "GET /nested-spans includes grand-child spans", %{conn: conn} do + test "GET /nested-spans includes grand-child spans", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/nested-spans") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 1) |> extract_transactions() assert length(transactions) == 1 - assert [transaction] = transactions + assert [tx] = transactions - assert transaction.transaction == "root_span" - assert transaction.transaction_info == %{source: :custom} + assert tx["transaction"] == "root_span" + assert tx["transaction_info"] == %{"source" => "custom"} - trace = transaction.contexts.trace - assert trace.origin == "phoenix_app" - assert trace.op == "root_span" + trace = tx["contexts"]["trace"] + assert trace["origin"] == "phoenix_app" + assert trace["op"] == "root_span" - assert length(transaction.spans) == 6 + assert length(tx["spans"]) == 6 - span_names = Enum.map(transaction.spans, & &1.description) + span_names = Enum.map(tx["spans"], & &1["description"]) - # Verify all expected spans are present assert "child_span_1" in span_names assert "child_span_2" in span_names assert "grandchild_span_1" in span_names assert "grandchild_span_2" in span_names assert "grandchild_span_3" in span_names - # Find the database span - db_spans = Enum.filter(transaction.spans, &(&1.op == "db")) + db_spans = Enum.filter(tx["spans"], &(&1["op"] == "db")) assert length(db_spans) == 1 [db_span] = db_spans - assert String.starts_with?(db_span.description, "SELECT") - assert db_span.data["db.system"] == :sqlite + assert String.starts_with?(db_span["description"], "SELECT") + assert db_span["data"]["db.system"] == "sqlite" - child_spans = Enum.filter(transaction.spans, &(&1.parent_span_id == transaction.span_id)) + root_span_id = tx["contexts"]["trace"]["span_id"] + child_spans = Enum.filter(tx["spans"], &(&1["parent_span_id"] == root_span_id)) assert length(child_spans) == 2 - child_span_ids = MapSet.new(child_spans, & &1.span_id) + child_span_ids = MapSet.new(child_spans, & &1["span_id"]) grandchild_spans = - Enum.filter(transaction.spans, fn span -> - span.parent_span_id != transaction.span_id and span.parent_span_id in child_span_ids + Enum.filter(tx["spans"], fn span -> + span["parent_span_id"] != root_span_id and span["parent_span_id"] in child_span_ids end) assert length(grandchild_spans) == 3 end test "LiveView mount and handle_params create disconnected transactions with child spans", %{ - conn: conn + conn: conn, + bypass: bypass } do + ref = setup_bypass_envelope_collector(bypass) + get(conn, ~p"/users") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 2) |> extract_transactions() assert length(transactions) == 2 - assert [mount_transaction, handle_params_transaction] = transactions + assert [mount_tx, handle_params_tx] = transactions - assert mount_transaction.transaction == "PhoenixAppWeb.UserLive.Index.mount" - assert length(mount_transaction.spans) == 1 + assert mount_tx["transaction"] == "PhoenixAppWeb.UserLive.Index.mount" + assert length(mount_tx["spans"]) == 1 - [mount_db_span] = mount_transaction.spans - assert mount_db_span.op == "db" - assert mount_db_span.parent_span_id == mount_transaction.span_id + [mount_db_span] = mount_tx["spans"] + assert mount_db_span["op"] == "db" + assert mount_db_span["parent_span_id"] == mount_tx["contexts"]["trace"]["span_id"] - assert handle_params_transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_params" - assert handle_params_transaction.span_id != mount_transaction.span_id + assert handle_params_tx["transaction"] == "PhoenixAppWeb.UserLive.Index.handle_params" - assert handle_params_transaction.contexts.trace.trace_id != - mount_transaction.contexts.trace.trace_id + assert handle_params_tx["contexts"]["trace"]["span_id"] != + mount_tx["contexts"]["trace"]["span_id"] - refute mount_transaction.contexts.trace.trace_id == - handle_params_transaction.contexts.trace.trace_id + assert handle_params_tx["contexts"]["trace"]["trace_id"] != + mount_tx["contexts"]["trace"]["trace_id"] end describe "distributed tracing with sentry-trace header" do - test "LiveView mount inherits trace context from sentry-trace header", %{conn: conn} do + test "LiveView mount inherits trace context from sentry-trace header", %{ + conn: conn, + bypass: bypass + } do + ref = setup_bypass_envelope_collector(bypass) + trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdef1234567890" @@ -150,31 +158,34 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do get(conn, ~p"/users") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 2) |> extract_transactions() - mount_transaction = + mount_tx = Enum.find(transactions, fn t -> - t.transaction == "PhoenixAppWeb.UserLive.Index.mount" + t["transaction"] == "PhoenixAppWeb.UserLive.Index.mount" end) - handle_params_transaction = + handle_params_tx = Enum.find(transactions, fn t -> - t.transaction == "PhoenixAppWeb.UserLive.Index.handle_params" + t["transaction"] == "PhoenixAppWeb.UserLive.Index.handle_params" end) - assert mount_transaction != nil - assert handle_params_transaction != nil + assert mount_tx != nil + assert handle_params_tx != nil - assert mount_transaction.contexts.trace.trace_id == trace_id - assert handle_params_transaction.contexts.trace.trace_id == trace_id + assert mount_tx["contexts"]["trace"]["trace_id"] == trace_id + assert handle_params_tx["contexts"]["trace"]["trace_id"] == trace_id - assert mount_transaction.contexts.trace.parent_span_id == parent_span_id - assert handle_params_transaction.contexts.trace.parent_span_id == parent_span_id + assert mount_tx["contexts"]["trace"]["parent_span_id"] == parent_span_id + assert handle_params_tx["contexts"]["trace"]["parent_span_id"] == parent_span_id end test "LiveView handle_event in WebSocket shares trace context with initial request", %{ - conn: conn + conn: conn, + bypass: bypass } do + ref = setup_bypass_envelope_collector(bypass) + trace_id = "fedcba0987654321fedcba0987654321" parent_span_id = "1234567890fedcba" @@ -186,21 +197,23 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do view |> element("#increment-btn") |> render_click() - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 5, timeout: 2000) |> extract_transactions() - handle_event_transaction = + handle_event_tx = Enum.find(transactions, fn t -> - String.contains?(t.transaction, "handle_event#increment") + String.contains?(t["transaction"], "handle_event#increment") end) - assert handle_event_transaction != nil, - "Expected handle_event transaction, got: #{inspect(Enum.map(transactions, & &1.transaction))}" + assert handle_event_tx != nil, + "Expected handle_event transaction, got: #{inspect(Enum.map(transactions, & &1["transaction"]))}" - assert handle_event_transaction.contexts.trace.trace_id == trace_id, - "Expected trace_id #{trace_id}, got #{handle_event_transaction.contexts.trace.trace_id}" + assert handle_event_tx["contexts"]["trace"]["trace_id"] == trace_id, + "Expected trace_id #{trace_id}, got #{handle_event_tx["contexts"]["trace"]["trace_id"]}" end - test "baggage header is preserved through LiveView lifecycle", %{conn: conn} do + test "baggage header is preserved through LiveView lifecycle", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + trace_id = "abababababababababababababababab" parent_span_id = "cdcdcdcdcdcdcdcd" baggage = "sentry-environment=production,sentry-release=1.0.0" @@ -212,15 +225,15 @@ defmodule Sentry.Integrations.Phoenix.TransactionTest do get(conn, ~p"/users") - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 2) |> extract_transactions() - mount_transaction = + mount_tx = Enum.find(transactions, fn t -> - t.transaction == "PhoenixAppWeb.UserLive.Index.mount" + t["transaction"] == "PhoenixAppWeb.UserLive.Index.mount" end) - assert mount_transaction != nil - assert mount_transaction.contexts.trace.trace_id == trace_id + assert mount_tx != nil + assert mount_tx["contexts"]["trace"]["trace_id"] == trace_id end end end diff --git a/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs b/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs index cacd9021..df608453 100644 --- a/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app_web/live/user_live_test.exs @@ -10,9 +10,7 @@ defmodule PhoenixAppWeb.UserLiveTest do @invalid_attrs %{name: nil, age: nil} setup do - put_test_config(dsn: "http://public:secret@localhost:8080/1", traces_sample_rate: 1.0) - - Sentry.Test.start_collecting_sentry_reports() + setup_bypass(traces_sample_rate: 1.0) end defp create_user(_) do @@ -30,7 +28,9 @@ defmodule PhoenixAppWeb.UserLiveTest do assert html =~ user.name end - test "saves new user", %{conn: conn} do + test "saves new user", %{conn: conn, bypass: bypass} do + ref = setup_bypass_envelope_collector(bypass) + {:ok, index_live, _html} = live(conn, ~p"/users") assert index_live |> element("a", "New User") |> render_click() =~ @@ -52,28 +52,29 @@ defmodule PhoenixAppWeb.UserLiveTest do assert html =~ "User created successfully" assert html =~ "some name" - transactions = Sentry.Test.pop_sentry_transactions() + transactions = collect_envelopes(ref, 10, timeout: 2000) |> extract_transactions() transaction_save = - Enum.find(transactions, fn transaction -> - transaction.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save" + Enum.find(transactions, fn tx -> + tx["transaction"] == "PhoenixAppWeb.UserLive.Index.handle_event#save" end) - assert transaction_save.transaction == "PhoenixAppWeb.UserLive.Index.handle_event#save" - assert transaction_save.transaction_info.source == :custom + assert transaction_save != nil + assert transaction_save["transaction"] == "PhoenixAppWeb.UserLive.Index.handle_event#save" + assert transaction_save["transaction_info"]["source"] == "custom" - assert transaction_save.contexts.trace.op == + assert transaction_save["contexts"]["trace"]["op"] == "PhoenixAppWeb.UserLive.Index.handle_event#save" - assert transaction_save.contexts.trace.origin == "opentelemetry_phoenix" + assert transaction_save["contexts"]["trace"]["origin"] == "opentelemetry_phoenix" - assert length(transaction_save.spans) == 1 - assert [span] = transaction_save.spans - assert span.op == "db" - assert span.description =~ "INSERT INTO \"users\"" - assert span.data["db.system"] == :sqlite - assert span.data["db.type"] == :sql - assert span.origin == "opentelemetry_ecto" + assert length(transaction_save["spans"]) == 1 + assert [span] = transaction_save["spans"] + assert span["op"] == "db" + assert span["description"] =~ "INSERT INTO \"users\"" + assert span["data"]["db.system"] == "sqlite" + assert span["data"]["db.type"] == "sql" + assert span["origin"] == "opentelemetry_ecto" end test "updates user in listing", %{conn: conn, user: user} do diff --git a/test_integrations/phoenix_app/test/support/test_helpers.ex b/test_integrations/phoenix_app/test/support/test_helpers.ex index ecb867c2..90814727 100644 --- a/test_integrations/phoenix_app/test/support/test_helpers.ex +++ b/test_integrations/phoenix_app/test/support/test_helpers.ex @@ -86,6 +86,81 @@ defmodule Sentry.TestHelpers do decode_envelope_items(rest) end + @spec setup_bypass(keyword()) :: %{bypass: Bypass.t()} + def setup_bypass(extra_config \\ []) do + bypass = Bypass.open() + + # Stub all envelope requests by default so tests that don't explicitly + # collect envelopes won't fail from background OTel span sends. + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + config = + [ + dsn: "http://public:secret@localhost:#{bypass.port}/1", + finch_request_opts: [receive_timeout: 2000] + ] + |> Keyword.merge(extra_config) + + put_test_config(config) + %{bypass: bypass} + end + + @spec setup_bypass_envelope_collector(Bypass.t()) :: reference() + def setup_bypass_envelope_collector(bypass) do + test_pid = self() + ref = make_ref() + + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + send(test_pid, {:bypass_envelope, ref, body}) + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) + end) + + ref + end + + @spec collect_envelopes(reference(), pos_integer(), keyword()) :: [[{map(), map()}]] + def collect_envelopes(ref, expected_count, opts \\ []) do + timeout = Keyword.get(opts, :timeout, 1000) + do_collect_envelopes(ref, expected_count, [], timeout) + end + + defp do_collect_envelopes(_ref, 0, acc, _timeout), do: Enum.reverse(acc) + + defp do_collect_envelopes(ref, remaining, acc, timeout) do + receive do + {:bypass_envelope, ^ref, body} -> + items = decode_envelope!(body) + do_collect_envelopes(ref, remaining - 1, [items | acc], timeout) + after + timeout -> + Enum.reverse(acc) + end + end + + @spec extract_events([[{map(), map()}]]) :: [map()] + def extract_events(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "event"}, payload} <- items, + do: payload + end + + @spec extract_transactions([[{map(), map()}]]) :: [map()] + def extract_transactions(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "transaction"}, payload} <- items, + do: payload + end + + @spec extract_log_items([[{map(), map()}]]) :: [map()] + def extract_log_items(envelope_items_list) do + for items <- envelope_items_list, + {%{"type" => "log"}, payload} <- items, + do: payload + end + defp decode_envelope_items(items) do items |> Enum.chunk_every(2) diff --git a/test_integrations/umbrella/config/config.exs b/test_integrations/umbrella/config/config.exs index 8c1d6cf0..d8c560fa 100644 --- a/test_integrations/umbrella/config/config.exs +++ b/test_integrations/umbrella/config/config.exs @@ -22,6 +22,5 @@ config :sentry, environment_name: Mix.env(), enable_source_code_context: true, root_source_code_paths: [File.cwd!()], - test_mode: true, send_result: :sync, in_app_otp_apps: [:public, :admin] From 860183e3a69ef3e04bd2c4b0a9d5873ac12de0b6 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 30 Mar 2026 13:51:53 +0000 Subject: [PATCH 2/3] wip(tests): consistent DNS and bypass handling --- test/mix/sentry.send_test_event_test.exs | 21 ++++----- test/plug_capture_test.exs | 26 ++++++----- test/sentry/client_report/sender_test.exs | 6 +-- test/sentry/client_test.exs | 19 ++++---- test/sentry/integrations/oban/cron_test.exs | 10 +---- .../integrations/oban/error_reporter_test.exs | 43 +++++-------------- .../sentry/integrations/quantum/cron_test.exs | 10 +---- test/sentry/integrations/telemetry_test.exs | 26 ++--------- test/sentry/logger_handler/logs_test.exs | 11 +---- test/sentry/logger_handler_test.exs | 13 ++---- test/sentry/telemetry/scheduler_test.exs | 6 +-- .../telemetry_processor_integration_test.exs | 3 +- test/sentry/transport_test.exs | 10 ++--- test/sentry_test.exs | 16 +++---- test/support/test_helpers.ex | 22 ++++++++-- 15 files changed, 81 insertions(+), 161 deletions(-) diff --git a/test/mix/sentry.send_test_event_test.exs b/test/mix/sentry.send_test_event_test.exs index 966cd753..83493ef2 100644 --- a/test/mix/sentry.send_test_event_test.exs +++ b/test/mix/sentry.send_test_event_test.exs @@ -4,6 +4,10 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do import ExUnit.CaptureIO import Sentry.TestHelpers + setup do + setup_bypass() + end + test "prints if :dsn is not set" do put_test_config(dsn: nil, finch_pool_opts: [], environment_name: "some_env") @@ -21,19 +25,14 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do assert output =~ ~s(Event not sent because the :dsn option is not set) end - test "sends event successfully when configured to" do - bypass = Bypass.open() - - Bypass.expect(bypass, fn conn -> + test "sends event successfully when configured to", %{bypass: bypass} do + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) assert body =~ "Testing sending Sentry event" - assert conn.request_path == "/api/1/envelope/" - assert conn.method == "POST" Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", environment_name: "test", finch_pool_opts: [] ) @@ -58,16 +57,12 @@ defmodule Mix.Tasks.Sentry.SendTestEventTest do end @tag :capture_log - test "handles error when Sentry server is failing" do - bypass = Bypass.open() - - Bypass.expect(bypass, fn conn -> + test "handles error when Sentry server is failing", %{bypass: bypass} do + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, _body, conn} = Plug.Conn.read_body(conn) Plug.Conn.resp(conn, 500, ~s<{"id": "340"}>) end) - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") - assert_raise Mix.Error, ~r/Error sending event/, fn -> capture_io(fn -> Mix.Tasks.Sentry.SendTestEvent.run([]) end) end diff --git a/test/plug_capture_test.exs b/test/plug_capture_test.exs index 59bcc5c2..ff52eab2 100644 --- a/test/plug_capture_test.exs +++ b/test/plug_capture_test.exs @@ -55,16 +55,14 @@ defmodule Sentry.PlugCaptureTest do end setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") - %{bypass: bypass} + setup_bypass() end describe "with a Plug application" do test "sends error to Sentry and uses Sentry.PlugContext to fill in context", %{ bypass: bypass } do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) @@ -83,7 +81,7 @@ defmodule Sentry.PlugCaptureTest do end test "sends throws to Sentry", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) _event = decode_event_from_envelope!(body) Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) @@ -93,7 +91,7 @@ defmodule Sentry.PlugCaptureTest do end test "sends exits to Sentry", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) _event = decode_event_from_envelope!(body) Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) @@ -110,7 +108,7 @@ defmodule Sentry.PlugCaptureTest do end test "can render feedback form", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) _event = decode_event_from_envelope!(body) Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) @@ -146,7 +144,7 @@ defmodule Sentry.PlugCaptureTest do end test "reports raised exceptions", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) @@ -166,7 +164,7 @@ defmodule Sentry.PlugCaptureTest do end test "reports exits", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) @@ -180,7 +178,7 @@ defmodule Sentry.PlugCaptureTest do end test "reports throws", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) @@ -202,7 +200,7 @@ defmodule Sentry.PlugCaptureTest do test_pid = self() ref = make_ref() - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) send(test_pid, {ref, body}) Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) @@ -226,7 +224,7 @@ defmodule Sentry.PlugCaptureTest do end test "can render feedback form in Phoenix ErrorView", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) _event = decode_event_from_envelope!(body) @@ -248,7 +246,7 @@ defmodule Sentry.PlugCaptureTest do end test "handles Erlang error in Plug.Conn.WrapperError", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) assert event["culprit"] == "Sentry.PlugCaptureTest.PhoenixController.assigns/2" @@ -270,7 +268,7 @@ defmodule Sentry.PlugCaptureTest do pid = start_supervised!(PhoenixEndpointWithScrubber) Process.link(pid) - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) event = decode_event_from_envelope!(body) diff --git a/test/sentry/client_report/sender_test.exs b/test/sentry/client_report/sender_test.exs index f7d8222e..9c3ba2cf 100644 --- a/test/sentry/client_report/sender_test.exs +++ b/test/sentry/client_report/sender_test.exs @@ -7,9 +7,7 @@ defmodule Sentry.ClientReportTest do alias Sentry.Event setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") - %{bypass: bypass} + setup_bypass() end @span_id Sentry.UUID.uuid4_hex() @@ -69,7 +67,7 @@ defmodule Sentry.ClientReportTest do send(Process.whereis(:test_client_report), :send_report) - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) assert [{%{"type" => "client_report", "length" => _}, client_report}] = diff --git a/test/sentry/client_test.exs b/test/sentry/client_test.exs index b336086b..4254ad9f 100644 --- a/test/sentry/client_test.exs +++ b/test/sentry/client_test.exs @@ -187,14 +187,12 @@ defmodule Sentry.ClientTest do describe "send_event/2" do setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") - %{bypass: bypass} + setup_bypass() end test "respects the :sample_rate option", %{bypass: bypass} do # Always sends with sample rate of 1. - Bypass.expect_once(bypass, fn conn -> + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) @@ -204,7 +202,7 @@ defmodule Sentry.ClientTest do assert :unsampled = Client.send_event(Event.create_event([]), sample_rate: 0.0) # Either sends or doesn't with :sample_rate of 0.5. - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) @@ -216,7 +214,7 @@ defmodule Sentry.ClientTest do end test "calls anonymous :before_send callback", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) assert [{%{"type" => "event"}, event}] = decode_envelope!(body) @@ -294,7 +292,7 @@ defmodule Sentry.ClientTest do test "calls anonymous :after_send_event callback synchronously", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) @@ -427,7 +425,7 @@ defmodule Sentry.ClientTest do ] for {event, dup_event} <- events do - Bypass.expect_once(bypass, fn conn -> + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) @@ -445,8 +443,7 @@ defmodule Sentry.ClientTest do describe "send_client_report/1" do test "succefully sends discarded events to Sentry" do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") + %{bypass: bypass} = setup_bypass() client_report = %Sentry.ClientReport{ @@ -456,7 +453,7 @@ defmodule Sentry.ClientTest do ] } - Bypass.expect_once(bypass, fn conn -> + Bypass.expect_once(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) assert [{_headers, client_report_body}] = decode_envelope!(body) diff --git a/test/sentry/integrations/oban/cron_test.exs b/test/sentry/integrations/oban/cron_test.exs index 566a1d97..d024e85d 100644 --- a/test/sentry/integrations/oban/cron_test.exs +++ b/test/sentry/integrations/oban/cron_test.exs @@ -12,15 +12,7 @@ defmodule Sentry.Integrations.Oban.CronTest do end setup do - bypass = Bypass.open() - - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - dedup_events: false, - environment_name: "test" - ) - - %{bypass: bypass} + setup_bypass(dedup_events: false, environment_name: "test") end for event_type <- [:start, :stop, :exception] do diff --git a/test/sentry/integrations/oban/error_reporter_test.exs b/test/sentry/integrations/oban/error_reporter_test.exs index af4d8d07..0ae817cb 100644 --- a/test/sentry/integrations/oban/error_reporter_test.exs +++ b/test/sentry/integrations/oban/error_reporter_test.exs @@ -21,7 +21,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "reports the correct error to Sentry", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, []) @@ -44,7 +44,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "unwraps Oban.PerformErrors and reports the wrapped error", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job( :error, @@ -73,7 +73,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "reports normalized non-exception errors to Sentry", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, :undef, []) @@ -100,7 +100,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "reports exits to Sentry", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:exit, :oops, []) @@ -120,7 +120,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "reports throws to Sentry", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:throw, :this_was_not_caught, []) @@ -172,7 +172,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do test "includes custom tags when oban_tags_to_sentry_tags function config option is set and returns non empty map", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: fn _job -> %{custom_tag: "custom_value"} end @@ -183,7 +183,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "handles oban_tags_to_sentry_tags errors gracefully", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: fn _job -> raise "tag transform error" end @@ -193,7 +193,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "handles invalid oban_tags_to_sentry_tags return values gracefully", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") test_cases = [ 1, @@ -218,7 +218,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do def transform(_job), do: %{custom_tag: "custom_value"} end - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], oban_tags_to_sentry_tags: {TestTagsTransform, :transform} @@ -314,7 +314,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do end test "should_report_error_callback reports when callback returns true", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") emit_telemetry_for_failed_job(:error, %RuntimeError{message: "oops"}, [], should_report_error_callback: fn _worker, _job -> true end @@ -328,7 +328,7 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do test "should_report_error_callback handles errors gracefully and defaults to reporting", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") log = capture_log(fn -> @@ -350,27 +350,6 @@ defmodule Sentry.Integrations.Oban.ErrorReporterTest do ## Helpers - # Sets up a Bypass collector that only forwards error event envelopes. - # This filters out stray transaction envelopes from background processes - # (e.g., OpenTelemetry span processor) that may hit this Bypass due to - # concurrent persistent_term DSN writes in async tests. - defp setup_bypass_event_collector(bypass) do - test_pid = self() - ref = make_ref() - - Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - - if body =~ ~r/"type":\s*"event"/ do - send(test_pid, {:bypass_envelope, ref, body}) - end - - Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) - end) - - ref - end - defp emit_telemetry_for_failed_job(kind, reason, stacktrace, config \\ []) do job = %{"id" => "123", "entity" => "user", "type" => "delete"} diff --git a/test/sentry/integrations/quantum/cron_test.exs b/test/sentry/integrations/quantum/cron_test.exs index d2300a50..80476e60 100644 --- a/test/sentry/integrations/quantum/cron_test.exs +++ b/test/sentry/integrations/quantum/cron_test.exs @@ -13,15 +13,7 @@ defmodule Sentry.Integrations.Quantum.CronTest do end setup do - bypass = Bypass.open() - - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - dedup_events: false, - environment_name: "test" - ) - - %{bypass: bypass} + setup_bypass(dedup_events: false, environment_name: "test") end for event_type <- [:start, :stop, :exception] do diff --git a/test/sentry/integrations/telemetry_test.exs b/test/sentry/integrations/telemetry_test.exs index 0abf43c9..d9f1860b 100644 --- a/test/sentry/integrations/telemetry_test.exs +++ b/test/sentry/integrations/telemetry_test.exs @@ -11,7 +11,7 @@ defmodule Sentry.Integrations.TelemetryTest do end test "reports errors", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") handle_failure_event(:error, %RuntimeError{message: "oops"}, []) @@ -28,7 +28,7 @@ defmodule Sentry.Integrations.TelemetryTest do end test "reports Erlang errors (normalized)", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") handle_failure_event(:error, {:badmap, :foo}, []) @@ -47,7 +47,7 @@ defmodule Sentry.Integrations.TelemetryTest do for kind <- [:throw, :exit] do test "reports #{kind}s", %{bypass: bypass} do - ref = setup_bypass_event_collector(bypass) + ref = setup_bypass_envelope_collector(bypass, type: "event") handle_failure_event(unquote(kind), :foo, []) @@ -68,26 +68,6 @@ defmodule Sentry.Integrations.TelemetryTest do end end - # Sets up a Bypass collector that only forwards error event envelopes. - # This filters out stray transaction envelopes from background processes - # that may hit this Bypass due to concurrent persistent_term DSN writes in async tests. - defp setup_bypass_event_collector(bypass) do - test_pid = self() - ref = make_ref() - - Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - - if body =~ ~r/"type":\s*"event"/ do - send(test_pid, {:bypass_envelope, ref, body}) - end - - Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) - end) - - ref - end - defp handle_failure_event(kind, reason, stacktrace) do Telemetry.handle_event( [:telemetry, :handler, :failure], diff --git a/test/sentry/logger_handler/logs_test.exs b/test/sentry/logger_handler/logs_test.exs index 34b4a779..44e88666 100644 --- a/test/sentry/logger_handler/logs_test.exs +++ b/test/sentry/logger_handler/logs_test.exs @@ -11,16 +11,7 @@ defmodule Sentry.LoggerHandler.LogsTest do @moduletag :capture_log setup do - bypass = Bypass.open() - - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - enable_logs: true, - logs: [level: :info] - ) - - # TelemetryProcessor is already started by Sentry.Case - %{bypass: bypass} + setup_bypass(enable_logs: true, logs: [level: :info]) end setup :add_logs_handler diff --git a/test/sentry/logger_handler_test.exs b/test/sentry/logger_handler_test.exs index 652794a4..85124523 100644 --- a/test/sentry/logger_handler_test.exs +++ b/test/sentry/logger_handler_test.exs @@ -687,17 +687,10 @@ defmodule Sentry.LoggerHandlerTest do end defp register_delay do - bypass = Bypass.open() + %{bypass: bypass} = + setup_bypass(dedup_events: false, finch_request_opts: [receive_timeout: 500]) - put_test_config( - dsn: "http://public:secret@localhost:#{bypass.port}/1", - dedup_events: false, - finch_request_opts: [receive_timeout: 500] - ) - - Bypass.expect(bypass, fn conn -> - assert conn.request_path == "/api/1/envelope/" - assert conn.method == "POST" + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Process.sleep(150) Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) diff --git a/test/sentry/telemetry/scheduler_test.exs b/test/sentry/telemetry/scheduler_test.exs index 6ea130d6..d2f15e73 100644 --- a/test/sentry/telemetry/scheduler_test.exs +++ b/test/sentry/telemetry/scheduler_test.exs @@ -378,9 +378,7 @@ defmodule Sentry.Telemetry.SchedulerTest do describe "transport send failure logging" do test "logs warning when direct transport send fails during flush" do - bypass = Bypass.open() - - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") + %{bypass: bypass} = setup_bypass() prev_retries = Application.get_env(:sentry, :request_retries) Application.put_env(:sentry, :request_retries, []) @@ -392,7 +390,7 @@ defmodule Sentry.Telemetry.SchedulerTest do end end) - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.resp(conn, 500, ~s<{"error": "internal"}>) end) diff --git a/test/sentry/telemetry_processor_integration_test.exs b/test/sentry/telemetry_processor_integration_test.exs index a0925c3c..e0a1cd7a 100644 --- a/test/sentry/telemetry_processor_integration_test.exs +++ b/test/sentry/telemetry_processor_integration_test.exs @@ -8,7 +8,7 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do alias Sentry.{LogEvent, Transaction} setup context do - bypass = Bypass.open() + %{bypass: bypass} = setup_bypass() test_pid = self() ref = make_ref() @@ -29,7 +29,6 @@ defmodule Sentry.TelemetryProcessorIntegrationTest do ) Process.put(:sentry_telemetry_processor, processor_name) - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") %{processor: processor_name, ref: ref, bypass: bypass} end diff --git a/test/sentry/transport_test.exs b/test/sentry/transport_test.exs index a3a68235..5bbff23d 100644 --- a/test/sentry/transport_test.exs +++ b/test/sentry/transport_test.exs @@ -8,8 +8,7 @@ defmodule Sentry.TransportTest do describe "encode_and_post_envelope/2" do setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1") + result = setup_bypass() # Ensure Hackney is started for tests that use HackneyClient # Since the default client is now FinchClient, Hackney won't be started automatically @@ -17,18 +16,15 @@ defmodule Sentry.TransportTest do {:ok, _} = Application.ensure_all_started(:hackney) end - %{bypass: bypass} + result end test "sends a POST request with the right headers and payload", %{bypass: bypass} do envelope = Envelope.from_event(Event.create_event(message: "Hello 1")) - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> assert {:ok, body, conn} = Plug.Conn.read_body(conn) - assert conn.method == "POST" - assert conn.request_path == "/api/1/envelope/" - assert ["sentry-elixir/" <> _] = Plug.Conn.get_req_header(conn, "user-agent") assert [sentry_auth_header] = Plug.Conn.get_req_header(conn, "x-sentry-auth") diff --git a/test/sentry_test.exs b/test/sentry_test.exs index 8d3def03..c1310062 100644 --- a/test/sentry_test.exs +++ b/test/sentry_test.exs @@ -12,17 +12,13 @@ defmodule SentryTest do end setup do - bypass = Bypass.open() - put_test_config(dsn: "http://public:secret@localhost:#{bypass.port}/1", dedup_events: false) - %{bypass: bypass} + setup_bypass(dedup_events: false) end test "excludes events properly", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) assert body =~ "RuntimeError" - assert conn.request_path == "/api/1/envelope/" - assert conn.method == "POST" Plug.Conn.resp(conn, 200, ~s<{"id": "340"}>) end) @@ -48,7 +44,9 @@ defmodule SentryTest do @tag :capture_log test "errors when taking too long to receive response", %{bypass: bypass} do - Bypass.expect(bypass, fn _conn -> Process.sleep(:infinity) end) + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn _conn -> + Process.sleep(:infinity) + end) put_test_config(finch_request_opts: [receive_timeout: 50]) @@ -62,7 +60,7 @@ defmodule SentryTest do end test "sets last_event_id_and_source when an event is sent", %{bypass: bypass} do - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.send_resp(conn, 200, ~s<{"id": "340"}>) end) @@ -85,7 +83,7 @@ defmodule SentryTest do put_test_config(dedup_events: true) message_to_report = "Hello #{System.unique_integer([:positive])}" - Bypass.expect(bypass, fn conn -> + Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> Plug.Conn.send_resp(conn, 200, ~s<{"id": "340"}>) end) diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 01987f9c..8c411ec2 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -161,7 +161,7 @@ defmodule Sentry.TestHelpers do end @doc """ - Sets up a Bypass envelope collector that forwards all envelope bodies + Sets up a Bypass envelope collector that forwards envelope bodies to the test process as messages. Returns a reference for collecting results. Uses `Bypass.stub` (not `Bypass.expect`) to be resilient to stray requests @@ -169,15 +169,29 @@ defmodule Sentry.TestHelpers do hit this Bypass due to concurrent persistent_term DSN writes in async tests. Use with `collect_envelopes/2` to retrieve the decoded envelopes. + + ## Options + + * `:type` - when set, only envelopes containing an item of this type + (e.g., `"event"`, `"transaction"`, `"log"`) are forwarded to the test + process. Envelopes not matching the type are silently dropped. This is + useful in async tests where stray envelopes of other types may arrive + from background processes. + """ - @spec setup_bypass_envelope_collector(Bypass.t()) :: reference() - def setup_bypass_envelope_collector(bypass) do + @spec setup_bypass_envelope_collector(Bypass.t(), keyword()) :: reference() + def setup_bypass_envelope_collector(bypass, opts \\ []) do test_pid = self() ref = make_ref() + type_filter = Keyword.get(opts, :type) Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) - send(test_pid, {:bypass_envelope, ref, body}) + + if is_nil(type_filter) or body =~ ~s("type":"#{type_filter}") do + send(test_pid, {:bypass_envelope, ref, body}) + end + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) end) From aecfb45d4f93ffc06035d8ef25efaa96be0464d7 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 30 Mar 2026 14:15:29 +0000 Subject: [PATCH 3/3] wip(tests): more stability fixes in integration tests --- .../phoenix_app/test/phoenix_app/oban_test.exs | 6 ++++-- .../phoenix_app/test/support/test_helpers.ex | 13 +++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs index 606be11f..a131ab4a 100644 --- a/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs +++ b/test_integrations/phoenix_app/test/phoenix_app/oban_test.exs @@ -58,15 +58,17 @@ defmodule Sentry.Integrations.Phoenix.ObanTest do end describe "should_report_error_callback config" do - setup do + setup %{bypass: bypass} do :telemetry.detach(ErrorReporter) + ref = setup_bypass_envelope_collector(bypass, type: "event") + on_exit(fn -> _ = :telemetry.detach(ErrorReporter) ErrorReporter.attach([]) end) - :ok + %{ref: ref} end test "skips error reporting when callback returns false", %{ref: ref} do diff --git a/test_integrations/phoenix_app/test/support/test_helpers.ex b/test_integrations/phoenix_app/test/support/test_helpers.ex index 90814727..527aa981 100644 --- a/test_integrations/phoenix_app/test/support/test_helpers.ex +++ b/test_integrations/phoenix_app/test/support/test_helpers.ex @@ -107,14 +107,19 @@ defmodule Sentry.TestHelpers do %{bypass: bypass} end - @spec setup_bypass_envelope_collector(Bypass.t()) :: reference() - def setup_bypass_envelope_collector(bypass) do + @spec setup_bypass_envelope_collector(Bypass.t(), keyword()) :: reference() + def setup_bypass_envelope_collector(bypass, opts \\ []) do test_pid = self() ref = make_ref() + type_filter = Keyword.get(opts, :type) - Bypass.expect(bypass, "POST", "/api/1/envelope/", fn conn -> + Bypass.stub(bypass, "POST", "/api/1/envelope/", fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) - send(test_pid, {:bypass_envelope, ref, body}) + + if is_nil(type_filter) or body =~ ~s("type":"#{type_filter}") do + send(test_pid, {:bypass_envelope, ref, body}) + end + Plug.Conn.resp(conn, 200, ~s<{"id": "#{Sentry.UUID.uuid4_hex()}"}>) end)