tracing: hand spans to Sentry and VictoriaTraces via OTel
OpenTelemetry makes the spans, and Sentry builds its transactions from that feed;
the status lands under a key its filter never reads, so Tracing.scrub drops the statuses it should not keep.
The bridge copies every attribute, query string too, where send_default_pii has no say,
so scrub cuts url.query; set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and VictoriaTraces gets the spans unscrubbed anyway.

Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change tusnlnuyyxwlwotyvusmplolwrwrqwuq
commit 814e73fe340d97c0d75673550d2e8d3ca886cbb9
author Alpha Chen <alpha@kejadlen.dev>
date
parent oxqovplv
diff --git a/Gemfile b/Gemfile
index b5a4010..1d5d8fb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,11 +4,15 @@ gem "base64"
 gem "irb"
 gem "logger"
 gem "nokogiri"
+gem "opentelemetry-exporter-otlp"
+gem "opentelemetry-instrumentation-rack"
+gem "opentelemetry-sdk"
 gem "phlex"
 gem "puma"
 gem "rackup"
 gem "rake"
 gem "roda"
+gem "sentry-opentelemetry"
 gem "sentry-ruby"
 gem "sequel"
 gem "sqlite3"
diff --git a/Gemfile.lock b/Gemfile.lock
index aec3c3e..02ff1e4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -12,6 +12,17 @@ GEM
     ffi (1.17.4-arm64-darwin)
     ffi (1.17.4-x86_64-linux-gnu)
     fileutils (1.8.0)
+    google-protobuf (4.36.2-aarch64-linux-gnu)
+      bigdecimal
+      rake (~> 13.3)
+    google-protobuf (4.36.2-arm64-darwin)
+      bigdecimal
+      rake (~> 13.3)
+    google-protobuf (4.36.2-x86_64-linux-gnu)
+      bigdecimal
+      rake (~> 13.3)
+    googleapis-common-protos-types (1.23.0)
+      google-protobuf (~> 4.26)
     hegeltest (0.1.1-aarch64-linux)
       ffi (~> 1.17, >= 1.17.4)
     hegeltest (0.1.1-arm64-darwin)
@@ -41,6 +52,45 @@ GEM
       racc (~> 1.4)
     nokogiri (1.19.4-x86_64-linux-gnu)
       racc (~> 1.4)
+    opentelemetry-api (1.11.1)
+      logger
+    opentelemetry-common (0.25.2)
+      opentelemetry-api (~> 1.0)
+    opentelemetry-exporter-otlp (0.37.0)
+      google-protobuf (>= 3.18)
+      googleapis-common-protos-types (~> 1.3)
+      opentelemetry-api (~> 1.1)
+      opentelemetry-common (~> 0.20)
+      opentelemetry-exporter-otlp-common (~> 0.2)
+      opentelemetry-sdk (~> 1.10)
+      opentelemetry-semantic_conventions
+    opentelemetry-exporter-otlp-common (0.3.0)
+      google-protobuf (>= 3.18)
+      googleapis-common-protos-types (~> 1.3)
+      opentelemetry-api (~> 1.1)
+      opentelemetry-common (~> 0.20)
+    opentelemetry-instrumentation-base (0.27.0)
+      opentelemetry-api (~> 1.7)
+      opentelemetry-common (~> 0.21)
+      opentelemetry-logs-api (~> 0.6)
+      opentelemetry-metrics-api (~> 0.9)
+      opentelemetry-registry (~> 0.1)
+    opentelemetry-instrumentation-rack (0.31.2)
+      opentelemetry-instrumentation-base (~> 0.25)
+    opentelemetry-logs-api (0.6.0)
+      opentelemetry-api (~> 1.0)
+    opentelemetry-metrics-api (0.9.0)
+      opentelemetry-api (~> 1.0)
+    opentelemetry-registry (0.6.3)
+      opentelemetry-api (~> 1.1)
+    opentelemetry-sdk (1.13.1)
+      logger
+      opentelemetry-api (~> 1.1)
+      opentelemetry-common (~> 0.20)
+      opentelemetry-registry (~> 0.2)
+      opentelemetry-semantic_conventions
+    opentelemetry-semantic_conventions (1.44.0)
+      opentelemetry-api (~> 1.0)
     parser (3.3.12.0)
       ast (~> 2.4.1)
       racc
@@ -85,6 +135,9 @@ GEM
       prism (>= 1.2, < 2.0)
       rbs (>= 3, < 5)
     securerandom (0.4.1)
+    sentry-opentelemetry (6.7.0)
+      opentelemetry-sdk (~> 1.0)
+      sentry-ruby (~> 6.7.0)
     sentry-ruby (6.7.0)
       bigdecimal
       concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -132,6 +185,9 @@ DEPENDENCIES
   logger
   minitest
   nokogiri
+  opentelemetry-exporter-otlp
+  opentelemetry-instrumentation-rack
+  opentelemetry-sdk
   phlex
   puma
   rack-test
@@ -139,6 +195,7 @@ DEPENDENCIES
   rake
   roda
   ruby-lsp
+  sentry-opentelemetry
   sentry-ruby
   sequel
   sqlite3
@@ -156,6 +213,10 @@ CHECKSUMS
   ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
   ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
   fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
+  google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169
+  google-protobuf (4.36.2-arm64-darwin) sha256=f70d810e6bbab9d8ad9c1d5fd685a21de4c10ad3ad32efb9455e17e5b1286917
+  google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db
+  googleapis-common-protos-types (1.23.0) sha256=992e740a523794d9fc5f29a504465d8fc737aaa16c930fe7228e3346860faf0a
   hegeltest (0.1.1-aarch64-linux) sha256=9a52c25200b09930cac91a2056005cbdb778962aa07049f5e1e9dd7b74e88891
   hegeltest (0.1.1-arm64-darwin) sha256=885c9b16fb3aaee5f2c5c6ab3d2b2d990c536334f78f3c21ebd456defa4115e0
   hegeltest (0.1.1-x86_64-linux) sha256=1d16acb3841db1f1fc06ff00ce3af045c7861c92f7641f9f22c765e2c23d74ab
@@ -170,6 +231,17 @@ CHECKSUMS
   nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
   nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
   nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
+  opentelemetry-api (1.11.1) sha256=d07f9987ea6240b3fd5ed5dd7450f93c58e403e0553e804069c23ae18ba61ff7
+  opentelemetry-common (0.25.2) sha256=c035d44987789356fe8feeeb870708b44fe7564ffc032dec7200c39da92a911d
+  opentelemetry-exporter-otlp (0.37.0) sha256=edc8bda657dce5f4d527ac735f75d44043bb5924de6df0e3f01260108aa76136
+  opentelemetry-exporter-otlp-common (0.3.0) sha256=855d10d788a7272dfe5b14efa229507619abbef29085ef4a8acfd2c0d3af6171
+  opentelemetry-instrumentation-base (0.27.0) sha256=e55edb82cd0b018e82e746be61fa36cb05b7b68b2fa37f0e4a96b923ad3d7735
+  opentelemetry-instrumentation-rack (0.31.2) sha256=4a183066110455711a0c77754e3f46652f646221d7704d968a48d155b47f8309
+  opentelemetry-logs-api (0.6.0) sha256=b889ac61d90326bffadb83bf7278d246d1086e2349c9052aeb072ed375ce73d0
+  opentelemetry-metrics-api (0.9.0) sha256=e11a12055113e11421313f71c758e5e10e4662e9e5c3b91786218a692e81206d
+  opentelemetry-registry (0.6.3) sha256=568fa8eaea94394f29f4fce3f0339ab10c3bf6d6c8beed6b3cfee6fd37fcc608
+  opentelemetry-sdk (1.13.1) sha256=91c9cc315ac7ef30243549eeff8b27cd0d39bd02bdf9c9ae481ab96a1cec6754
+  opentelemetry-semantic_conventions (1.44.0) sha256=b09b8793422f13b01c08f7a6f29b5f511f47f37bcdb57c0513465a0464168513
   parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
   phlex (2.4.1) sha256=e596717fbfe38b5271840266758779ebe75092e02629f0c170287e6290a70b12
   pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
@@ -191,6 +263,7 @@ CHECKSUMS
   roda (3.107.0) sha256=bf40c7a0f1fcd037ec10589a1dc98fc52ced1aece803670194ec7af0f0c77c06
   ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d
   securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+  sentry-opentelemetry (6.7.0) sha256=dfe6543e6f3243cee35feb1e47e77a8b58d15e0edfbd422738d2e5ab0c0c29da
   sentry-ruby (6.7.0) sha256=b4b915d79a0395ad02106d4012aa1914641456e042ce36393f15bd1630d8db42
   sequel (5.107.0) sha256=019629313205295b007f6df2c5eab19ddc40710d549fd445ac300f2853a370db
   sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c
diff --git a/config.ru b/config.ru
index 6a57ea8..275ade7 100644
--- a/config.ru
+++ b/config.ru
@@ -8,6 +8,7 @@ require "sentry-ruby"
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts/web"
 require "pro_tacts/store"
+require "pro_tacts/tracing"
 
 config = ProTacts.config
 
@@ -39,7 +40,9 @@ Sentry.init do |sentry|
     event
   end
   sentry.before_send = drop_identity
-  sentry.before_send_transaction = drop_identity
+  sentry.before_send_transaction = lambda do |event, hint|
+    ProTacts::Tracing.scrub(drop_identity.call(event, hint))
+  end
 
   # The exchange log is a local-only record of whole exchanges, bodies
   # and all. Sentry's sentry_logger hook would otherwise turn it into
@@ -48,9 +51,11 @@ Sentry.init do |sentry|
   sentry.exclude_loggers = [ProTacts::ExchangeLog::PROGNAME]
 
   sentry.traces_sample_rate = 1.0
-  sentry.trace_ignore_status_codes = ProTacts::Web::UNTRACED_STATUSES
+  sentry.instrumenter = :otel
 end
+ProTacts::Tracing.configure(config.traces_endpoint)
 
 ProTacts::Web.plugin :common_logger, $stderr
 
+use(*ProTacts::Tracing.middleware)
 run ProTacts::Web.freeze.app
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index e49f41b..6d42c13 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -23,6 +23,14 @@ module ProTacts
       @env.fetch("SENTRY_DSN", nil)
     end
 
+    # Where traces are sent over OTLP/HTTP, as the full URL — for
+    # VictoriaTraces, http://<host>:10428/insert/opentelemetry/v1/traces.
+    # Unset, traces reach Sentry alone. See ProTacts::Tracing.
+    #: () -> String?
+    def traces_endpoint
+      @env.fetch("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", nil)
+    end
+
     # Whether the exchange log keeps every DAV exchange rather than the
     # ones that went wrong, so a write that succeeded can be read back —
     # which is how a client's handling of a property gets probed at all
diff --git a/lib/pro_tacts/tracing.rb b/lib/pro_tacts/tracing.rb
new file mode 100644
index 0000000..1174c18
--- /dev/null
+++ b/lib/pro_tacts/tracing.rb
@@ -0,0 +1,65 @@
+require "opentelemetry/sdk"
+require "opentelemetry/exporter/otlp"
+require "opentelemetry/instrumentation/rack"
+require "sentry-ruby"
+require "sentry-opentelemetry"
+
+module ProTacts
+  # Tracing runs on OpenTelemetry, which hands every span to Sentry and,
+  # when an endpoint is configured, to an OTLP collector such as
+  # VictoriaTraces. Sentry's own instrumentation is off (config.ru's
+  # instrumenter), so its transactions are built from these spans.
+  module Tracing
+    # A proxy until configure installs the SDK, which it then delegates to,
+    # so the app can hold it from require time.
+    TRACER = OpenTelemetry.tracer_provider.tracer("pro_tacts") #: OpenTelemetry::Trace::Tracer
+
+    # The statuses whose transactions Sentry drops: sentry-ruby's default
+    # (TRACE_IGNORE_STATUS_CODES_DEFAULT) less 303, which answers every
+    # admin write, so an upload or a Save is traced. Applied by scrub,
+    # because Sentry's trace_ignore_status_codes never sees the status of
+    # a span it did not start.
+    UNTRACED_STATUSES = [(301..302), (305..399), (401..404)].freeze #: Array[Range[Integer]]
+
+    #: (String? endpoint) -> void
+    def self.configure(endpoint)
+      OpenTelemetry::SDK.configure do |otel|
+        otel.service_name = "pro-tacts"
+        # Named by route rather than the method alone, which would make
+        # every request one Sentry transaction. PATH_INFO, not the request
+        # URI the instrumentation offers, so the name carries no query.
+        otel.use "OpenTelemetry::Instrumentation::Rack",
+          url_quantization: ->(_uri, env) { "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" }
+        otel.add_span_processor(Sentry::OpenTelemetry::SpanProcessor.instance)
+        if endpoint
+          exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint:)
+          otel.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
+        end
+      end
+      OpenTelemetry.propagation = Sentry::OpenTelemetry::Propagator.new
+    end
+
+    # The request span's middleware, for the outside of the app's stack.
+    #: () -> Array[untyped]
+    def self.middleware
+      OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args
+    end
+
+    # For before_send_transaction. Sentry copies every attribute of the
+    # request span into the transaction's otel context, which
+    # send_default_pii does not reach, so the query string goes here.
+    #: (Sentry::TransactionEvent event) -> Sentry::TransactionEvent?
+    def self.scrub(event)
+      otel = event.contexts[:otel]
+      attributes = otel&.[](:attributes)
+      return event unless otel && attributes
+
+      status = attributes["http.response.status_code"]
+      return if status.is_a?(Integer) && UNTRACED_STATUSES.any? { it.cover?(status) }
+
+      # A finished span's attributes are frozen.
+      otel[:attributes] = attributes.except("url.query")
+      event
+    end
+  end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 81692e1..4f14c01 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -12,6 +12,7 @@ require "pro_tacts/exchange_log"
 require "pro_tacts/refusal_alerts"
 require "pro_tacts/store"
 require "pro_tacts/proxy_auth"
+require "pro_tacts/tracing"
 require "roda/plugins/dav_verbs"
 
 module ProTacts
@@ -30,11 +31,6 @@ module ProTacts
       __dir__ #: String
     ).parent.parent / "public" #: Pathname
 
-    # The statuses whose transactions Sentry drops, for config.ru:
-    # sentry-ruby's default (TRACE_IGNORE_STATUS_CODES_DEFAULT) less 303,
-    # which answers every admin write, so an upload or a Save is traced.
-    UNTRACED_STATUSES = [(301..302), (305..399), (401..404)].freeze #: Array[Range[Integer]]
-
     # The store this app serves from. config.ru builds it and hands it in;
     # nothing here reaches for a global to find one, which is what lets a
     # test point the app at a throwaway database. Kept in Roda's own opts
@@ -152,13 +148,14 @@ module ProTacts
       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
+    # One step of a request, timed as a span inside its request's span
+    # so a slow screen names its slow step. The name 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, &)
+    # card holds, since its attributes reach Sentry whatever
+    # send_default_pii says.
+    #: [T] (String name) { (OpenTelemetry::Trace::Span) -> T } -> T
+    def span(name, &)
+      Tracing::TRACER.in_span(name) { |step, _context| yield step }
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 8620f29..1d3ed16 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -118,7 +118,7 @@ module ProTacts
         # the walk stages as the import's original and strikes lines
         # out of (Import::Monica).
         bytes = span("import.monica") { Import::Monica.vcf(bytes) } if Import::Monica.export?(bytes)
-        cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step&.set_data("cards", it.length) } }
+        cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step.set_attribute("cards", it.length) } }
       rescue Import::Vcf::Invalid => error
         return import_screen(notice: error.message)
       end
@@ -250,7 +250,7 @@ module ProTacts
       } if contact
 
       arriving = import_contact(card, index)
-      book = span("import.contacts") { |step| store.contacts.tap { step&.set_data("rows", it.length) } }
+      book = span("import.contacts") { |step| store.contacts.tap { step.set_attribute("rows", it.length) } }
       matches = span("import.match") { Import::Match.candidates(arriving, book) }
       target = into.nil? || into.empty? ? nil : store.contact(into)
       # The row opened with no choice made is the first choice
@@ -618,9 +618,9 @@ module ProTacts
       return nil if original.nil? || revised.nil?
 
       span("import.staged") { |step|
-        step&.set_data("bytes", original.bytesize + revised.bytesize)
+        step.set_attribute("bytes", original.bytesize + revised.bytesize)
         cards = [Import::Vcf.cards(original), Import::Vcf.cards(revised)] #: [Array[VCard], Array[VCard]]
-        step&.set_data("cards", cards[0].length)
+        step.set_attribute("cards", cards[0].length)
         cards
       }
     end
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 25560ce..42f8ca4 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -40,17 +40,17 @@ module ProTacts
     #: (String query) -> Admin::SearchResults
     def search_results(query)
       recent = span("search.contacts_by_recency") { |step|
-        store.contacts_by_recency.tap { step&.set_data("rows", it.length) }
+        store.contacts_by_recency.tap { step.set_attribute("rows", it.length) }
       }
       if query.empty?
         return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
       end
 
-      groups = span("search.all_groups") { |step| store.all_groups.tap { step&.set_data("rows", it.length) } }
+      groups = span("search.all_groups") { |step| store.all_groups.tap { step.set_attribute("rows", it.length) } }
       span("search.match") { |step|
         contacts = Admin::Search.contacts(recent, query:, groups:)
-        step&.set_data("words", query.split.length)
-        step&.set_data("contacts", contacts.length)
+        step.set_attribute("words", query.split.length)
+        step.set_attribute("contacts", contacts.length)
         Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
       }
     end
diff --git a/sig/gems/opentelemetry.rbs b/sig/gems/opentelemetry.rbs
new file mode 100644
index 0000000..0888d9e
--- /dev/null
+++ b/sig/gems/opentelemetry.rbs
@@ -0,0 +1,60 @@
+# OpenTelemetry is reached from ProTacts::Tracing, which installs the SDK
+# and builds the request span's middleware, and from Web#span, which
+# times a step of a request inside that span.
+module OpenTelemetry
+  def self.tracer_provider: () -> Trace::TracerProvider
+
+  def self.propagation=: (untyped propagator) -> void
+
+  module Trace
+    class TracerProvider
+      def tracer: (String name) -> Tracer
+    end
+
+    class Tracer
+      def in_span: [T] (String name) { (Span span, untyped context) -> T } -> T
+    end
+
+    class Span
+      def set_attribute: (String key, Integer value) -> self
+    end
+  end
+
+  module SDK
+    def self.configure: () { (Configurator) -> void } -> void
+
+    class Configurator
+      def service_name=: (String name) -> void
+
+      def use: (String instrumentation_name, ?Hash[Symbol, untyped] config) -> void
+
+      def add_span_processor: (untyped processor) -> void
+    end
+
+    module Trace
+      module Export
+        class BatchSpanProcessor
+          def initialize: (Exporter::OTLP::Exporter exporter) -> void
+        end
+      end
+    end
+  end
+
+  module Exporter
+    module OTLP
+      class Exporter
+        def initialize: (endpoint: String) -> void
+      end
+    end
+  end
+
+  module Instrumentation
+    module Rack
+      class Instrumentation
+        def self.instance: () -> Instrumentation
+
+        def middleware_args: () -> Array[untyped]
+      end
+    end
+  end
+end
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 2747a73..2f94f01 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -3,13 +3,19 @@
 # RefusalAlerts sends, the arrival messages dav.rb and store.rb send,
 # and the photo decode refusal contact.rb captures. ExchangeLog tags the
 # request's scope and reads whether anything was reported under it, and
-# the search times its steps as spans.
+# Tracing hands Sentry its spans and scrubs the transactions they become.
 module Sentry
-  # Yields nil outside a transaction, as under a nil DSN.
-  def self.with_child_span: [T] (op: String, description: String) { (Span?) -> T } -> T
+  class TransactionEvent
+    def contexts: () -> Hash[Symbol, Hash[Symbol, untyped]]
+  end
+
+  module OpenTelemetry
+    class SpanProcessor
+      def self.instance: () -> SpanProcessor
+    end
 
-  class Span
-    def set_data: (String key, Integer value) -> void
+    class Propagator
+    end
   end
 
   def self.capture_message: (String message, ?level: Symbol, ?fingerprint: Array[String], ?tags: Hash[Symbol, String]) -> untyped
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index bc43c40..2485914 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -18,7 +18,7 @@ class AdminContactsPagesTest < Minitest::Test
   include ThrowawayContacts
 
   def app
-    ProTacts::Web
+    traced(ProTacts::Web)
   end
 
   def setup
@@ -410,7 +410,7 @@ class AdminContactsPagesTest < Minitest::Test
       transaction = transactions.first
       spans = transaction.spans.select { it[:op].start_with?("search.") }
       assert_equal %w[search.contacts_by_recency search.all_groups search.match search.render], spans.map { it[:op] }
-      assert_equal({"words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
+      assert_equal({"otel.kind" => :internal, "words" => 1, "contacts" => 1}, spans.find { it[:op] == "search.match" }[:data])
       refute_includes JSON.generate(transaction.to_h), "lovelace"
     end
   ensure
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 742bf6c..c8c6f46 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -49,7 +49,7 @@ class AdminImportPagesTest < Minitest::Test
   CARD
 
   def app
-    ProTacts::Web
+    traced(ProTacts::Web)
   end
 
   def setup
diff --git a/test/pro_tacts/test_tracing.rb b/test/pro_tacts/test_tracing.rb
new file mode 100644
index 0000000..ed20669
--- /dev/null
+++ b/test/pro_tacts/test_tracing.rb
@@ -0,0 +1,38 @@
+require_relative "../test_helper"
+
+require "rack/test"
+
+require "pro_tacts/web"
+
+# Which requests Sentry keeps a transaction for, now that Tracing's scrub
+# rather than trace_ignore_status_codes decides it.
+class TracingTest < Minitest::Test
+  include Rack::Test::Methods
+  include SentryTracing
+
+  def app
+    traced(ProTacts::Web)
+  end
+
+  def setup
+    setup_sentry_tracing
+    header "Remote-User", "test@example.com"
+  end
+
+  def teardown
+    teardown_sentry_test
+  end
+
+  def test_a_page_is_traced_by_its_route
+    get "/"
+
+    assert_equal ["GET /"], sentry_events.grep(Sentry::TransactionEvent).map(&:transaction)
+  end
+
+  def test_a_not_found_is_not_traced
+    get "/no-such-page"
+
+    assert_equal 404, last_response.status
+    assert_empty sentry_events.grep(Sentry::TransactionEvent)
+  end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index e5888cf..c9f07a8 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -46,7 +46,16 @@ require "sentry/test_helper"
 # re-opens the SDK when it finds it closed. Before minitest/autorun
 # regardless, per the rule the header states, so direct single-file
 # runs — where this file is the entry — keep the close after the tests.
-Sentry.init { |sentry| sentry.dsn = nil }
+# The instrumenter is config.ru's, so Sentry builds transactions from the
+# spans Tracing hands it, and Tracing is configured as config.ru does,
+# less the OTLP export.
+SENTRY_INIT = ->(sentry) {
+  sentry.dsn = nil
+  sentry.instrumenter = :otel
+}
+Sentry.init(&SENTRY_INIT)
+OpenTelemetry.logger = Logger.new($stderr, level: :warn)
+ProTacts::Tracing.configure(nil)
 
 require "minitest/autorun"
 
@@ -64,7 +73,7 @@ ProTacts::Web.store = FixtureData.install(data_dir)
 # store its birthday model what it cannot recompose.
 module SentryMessages
   def setup_sentry
-    Sentry.init { |sentry| sentry.dsn = nil } unless Sentry.initialized?
+    Sentry.init(&SENTRY_INIT) unless Sentry.initialized?
     setup_sentry_test
   end
 
@@ -73,7 +82,7 @@ module SentryMessages
   end
 end
 
-# Tracing on for one test, under config.ru's statuses, so a request's
+# Tracing on for one test, scrubbed as config.ru scrubs, 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
@@ -85,7 +94,15 @@ module SentryTracing
   def setup_sentry_tracing
     setup_sentry
     Sentry.configuration.traces_sample_rate = 1.0
-    Sentry.configuration.trace_ignore_status_codes = ProTacts::Web::UNTRACED_STATUSES
+    Sentry.configuration.before_send_transaction = ->(event, _hint) { ProTacts::Tracing.scrub(event) }
+  end
+
+  # The app inside the request span's middleware, as config.ru mounts it.
+  def traced(app)
+    Rack::Builder.app do
+      use(*ProTacts::Tracing.middleware)
+      run app
+    end
   end
 end