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>
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