web: share the span helper and keep tracing in its test
search_span moves up to Web#span, a name the import can call by too;
setup_sentry_test's block switched tracing on in a base layer that teardown never threw,
so tests after it recorded transactions and WebTest's counts depended on the order they ran;
SentryTracing switches it on in the test's own layer, gone when that test is done.

Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change xoruwzqyqswoowyzzxxsvvxstykwtypo
commit 79cfcf671dc31b1f801f36ddd9d18b1b84dcc1a2
author Alpha Chen <alpha@kejadlen.dev>
date
parent ltpntrvy
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ed55876..81692e1 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -151,6 +151,15 @@ module ProTacts
     def store
       self.class.store
     end
+
+    # One step of a request, timed as a span on its Sentry transaction
+    # so a slow screen names its slow step. The op is the step's whole
+    # name; a span carries counts and never a value a person typed or a
+    # card holds (config.ru's send_default_pii).
+    #: [T] (String op) { (Sentry::Span?) -> T } -> T
+    def span(op, &)
+      Sentry.with_child_span(op:, description: op, &)
+    end
   end
 end
 
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index cb960d3..25560ce 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -12,9 +12,7 @@ module ProTacts
     # a second copy in the page's script
     # (docs/plans/2026-09-30-live-search.md).
     #
-    # Each step is a span on the request's Sentry transaction, so a slow
-    # search names its slow step. A span carries counts and never the
-    # query, which is a name typed into a field.
+    # Each step is a span (Web#span), and none carries the query.
     hash_branch("search") do |r|
       query = r.params["q"].to_s.strip
 
@@ -22,7 +20,7 @@ module ProTacts
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
           results = search_results(query)
-          search_span("render") { Admin::SearchPage.call(login: @login, query:, results:) }
+          span("search.render") { Admin::SearchPage.call(login: @login, query:, results:) }
         end
       end
 
@@ -30,7 +28,7 @@ module ProTacts
       r.get "results" do
         response["Content-Type"] = "text/html; charset=utf-8"
         results = search_results(query)
-        search_span("render") { results.call }
+        span("search.render") { results.call }
       end
     end
 
@@ -41,25 +39,20 @@ module ProTacts
     # word is typed.
     #: (String query) -> Admin::SearchResults
     def search_results(query)
-      recent = search_span("contacts_by_recency") { |span|
-        store.contacts_by_recency.tap { span&.set_data("rows", it.length) }
+      recent = span("search.contacts_by_recency") { |step|
+        store.contacts_by_recency.tap { step&.set_data("rows", it.length) }
       }
       if query.empty?
         return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
       end
 
-      groups = search_span("all_groups") { |span| store.all_groups.tap { span&.set_data("rows", it.length) } }
-      search_span("match") { |span|
+      groups = span("search.all_groups") { |step| store.all_groups.tap { step&.set_data("rows", it.length) } }
+      span("search.match") { |step|
         contacts = Admin::Search.contacts(recent, query:, groups:)
-        span&.set_data("words", query.split.length)
-        span&.set_data("contacts", contacts.length)
+        step&.set_data("words", query.split.length)
+        step&.set_data("contacts", contacts.length)
         Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
       }
     end
-
-    #: [T] (String step) { (Sentry::Span?) -> T } -> T
-    def search_span(step, &)
-      Sentry.with_child_span(op: "search.#{step}", description: step, &)
-    end
   end
 end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 1252d9a..bc43c40 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -14,7 +14,7 @@ require "pro_tacts/web"
 # date.
 class AdminContactsPagesTest < Minitest::Test
   include Rack::Test::Methods
-  include Sentry::TestHelper
+  include SentryTracing
   include ThrowawayContacts
 
   def app
@@ -401,7 +401,7 @@ class AdminContactsPagesTest < Minitest::Test
   # Each step of a search is a span on the request's transaction, and
   # none of them carries the query (Web's search branch).
   def test_search_times_its_steps_without_the_query
-    setup_sentry_test { it.traces_sample_rate = 1.0 }
+    setup_sentry_tracing
     with_contacts({"ada" => ADA}) do
       get "/search/results", q: "lovelace"
 
diff --git a/test/test_helper.rb b/test/test_helper.rb
index e92c2cf..e5888cf 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -73,6 +73,22 @@ module SentryMessages
   end
 end
 
+# Tracing on for one test, under config.ru's statuses, so a request's
+# transaction and its spans land in sentry_events. Set on the test
+# layer's configuration after setup rather than in setup_sentry_test's
+# block, which configures the hub's base layer too — a layer teardown
+# keeps, so every later test would record transactions.
+module SentryTracing
+  include Sentry::TestHelper
+  include SentryMessages
+
+  def setup_sentry_tracing
+    setup_sentry
+    Sentry.configuration.traces_sample_rate = 1.0
+    Sentry.configuration.trace_ignore_status_codes = ProTacts::Web::UNTRACED_STATUSES
+  end
+end
+
 # Hands the app a throwaway store holding just the given cards (id to
 # bytes), so a route can be exercised without touching the fixture
 # book, and yields it so a test can change a card mid-sequence. Only