sentry: turn off send_default_pii, drop the scrubber
The scrubber watched the errors, but transactions carried cards;
now no request body leaves, so there is nothing left to guard.
A three-line lambda strips the tailnet names from every send,
and the regex and its test have reached their end.

Assisted-by: Claude Opus 5 via Claude Code
change nknlqvqnpvtrrvnsvymunowrtqztrupm
commit d878046678cdcc33344e713d68fecaeef0ad7f3b
author Alpha Chen <alpha@kejadlen.dev>
date
parent omzptsxw
diff --git a/AGENTS.md b/AGENTS.md
index 6b3d61b..7513449 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,9 +95,10 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   `log/unhandled` in the fixture layout. When implementing something a
   client asked for, look there first — and strip the identifying headers
   before promoting a capture into `test/fixtures`.
-- Anything added to a request body must be assumed to reach Sentry. Card
-  content is redacted by `ProTacts::SentryScrubber`; a new kind of sensitive
-  field would need its own rule there.
+- No request body reaches Sentry: `send_default_pii` is off in
+  `config.ru`, because card content never leaves the machine. The URL,
+  headers, exception messages, and `capture_message` text still do, so
+  keep card content out of all four.
 - Don't hide errors. A `rescue` that swallows an exception and returns a
   fallback value reads as defensive but is actually the opposite: it turns
   a bug or corrupt data into a silently wrong screen instead of a loud one.
diff --git a/README.md b/README.md
index 5ea1f28..3c768de 100644
--- a/README.md
+++ b/README.md
@@ -76,9 +76,8 @@ same layout as `test/fixtures/macos-exchange`. A client asking for
 something unimplemented therefore leaves behind enough to implement it, and
 the capture can be promoted to a fixture by copying it and stripping the
 identifying headers.
-Sentry gets the request body too, minus any card content, which
-`ProTacts::SentryScrubber` redacts on the way out — hrefs and tailnet IPs
-are not secrets, but the cards themselves never leave the machine.
+Sentry gets no request body at all, because `send_default_pii` is off, so
+the cards themselves never leave the machine.
 
 ## The minimal set macOS Contacts needs
 
diff --git a/config.ru b/config.ru
index 15c6682..44258b6 100644
--- a/config.ru
+++ b/config.ru
@@ -7,7 +7,6 @@ require "sentry-ruby"
 
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts/web"
-require "pro_tacts/sentry_scrubber"
 require "pro_tacts/store"
 
 config = ProTacts.config
@@ -26,21 +25,26 @@ Sentry.init do |sentry|
 
   sentry.breadcrumbs_logger = [:sentry_logger, :http_logger]
 
-  # On: request bodies are worth having on a 404, and nothing else this
-  # sends is sensitive. Hrefs carry opaque contact UIDs, not names, and the
-  # IPs are tailnet addresses.
-  sentry.send_default_pii = true
-
-  # The one thing that must not leave the machine is card content, which a
-  # write path would put directly in a PUT body. Full bodies are kept
-  # locally either way, see ProTacts::UnhandledRequests.
-  sentry.before_send = ProTacts::SentryScrubber
+  # Off, so no request body, query string, or cookie is sent: a write's
+  # body is a card, and card content never leaves the machine. Sentry
+  # says that something went wrong; the full exchange stays local
+  # (docs/plans/2026-09-13-dav-observability.md).
+  sentry.send_default_pii = false
+
+  # Headers are sent regardless, on transactions as well as errors, and
+  # the tailnet identity is not worth sending.
+  drop_identity = lambda do |event, _hint|
+    event.request&.headers&.reject! { |name, _| name.start_with?("Tailscale-User-") }
+    event
+  end
+  sentry.before_send = drop_identity
+  sentry.before_send_transaction = drop_identity
 
   # The debug log is a local-only record of full exchanges, bodies and
   # all. Sentry's sentry_logger hook would otherwise turn every one of
-  # its lines into a breadcrumb, and breadcrumbs reach Sentry without
-  # passing the scrubber above. Keyed on the progname DebugLogger#write
-  # passes with each line.
+  # its lines into a breadcrumb, and send_default_pii has no say over
+  # breadcrumbs. Keyed on the progname DebugLogger#write passes with
+  # each line.
   sentry.exclude_loggers = [ProTacts::DebugLogger::PROGNAME]
 
   sentry.traces_sample_rate = 1.0
diff --git a/lib/pro_tacts/debug_logger.rb b/lib/pro_tacts/debug_logger.rb
index 211ea64..faefbcd 100644
--- a/lib/pro_tacts/debug_logger.rb
+++ b/lib/pro_tacts/debug_logger.rb
@@ -19,7 +19,7 @@ module ProTacts
     # breadcrumb hook, which keys its exclude list on the progname
     # (see config.ru). Every line here is a local-only record of a
     # full exchange — bodies included — so none of it may ride to
-    # Sentry as a breadcrumb, where SentryScrubber would never see it.
+    # Sentry as a breadcrumb, which send_default_pii does not govern.
     PROGNAME = "ProTacts::DebugLogger" #: String
 
     # Builds the Logger the middleware writes to: appended and unbuffered
diff --git a/lib/pro_tacts/sentry_scrubber.rb b/lib/pro_tacts/sentry_scrubber.rb
deleted file mode 100644
index 053c8ad..0000000
--- a/lib/pro_tacts/sentry_scrubber.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-module ProTacts
-  # Redacts card content from request bodies on their way to Sentry.
-  #
-  # Everything else in a request is fine to send: hrefs carry opaque contact
-  # UIDs, and the IPs are tailnet addresses. Card content is the exception,
-  # and a write path would put it straight in a PUT body.
-  #
-  # Sentry truncates bodies at 16KB (RequestInterface::MAX_BODY_LIMIT), so a
-  # card can arrive with its BEGIN and no END. The trailing alternation
-  # handles that, and is load-bearing for the ordinary case too: (?~) is
-  # greedy up to the longest run with no complete END:VCARD in it, which
-  # runs into the marker and stops at END:VCAR. Requiring END:VCARD or the
-  # end of the body is what backtracks it onto the real boundary.
-  module SentryScrubber
-    VCARD = /BEGIN:VCARD(?~END:VCARD)(?:END:VCARD|\z)/mi
-    VCARD_CONTENT_TYPE = %r{\Atext/vcard}i
-    REDACTED = "[vcard redacted]".freeze #: String
-
-    # Sentry discards the event unless a Sentry::ErrorEvent comes back, so
-    # this returns the event it was handed either way.
-    #: (untyped event, ?untyped hint) -> untyped
-    def self.call(event, _hint = nil)
-      request = event.request if event.respond_to?(:request)
-      return event unless request
-
-      request.data =
-        if vcard_body?(request)
-          REDACTED
-        else
-          redact(request.data)
-        end
-
-      event
-    end
-
-    # A card PUT is a card whether or not it parses, so the content type is
-    # enough on its own.
-    #: (untyped request) -> bool
-    def self.vcard_body?(request)
-      headers = request.headers
-      return false unless headers.respond_to?(:[])
-
-      headers["Content-Type"].to_s.match?(VCARD_CONTENT_TYPE)
-    end
-
-    # Form bodies arrive as a params hash rather than a string.
-    #: (untyped data) -> untyped
-    def self.redact(data)
-      case data
-      when String then data.gsub(VCARD, REDACTED)
-      when Hash then data.transform_values { redact(it) }
-      when Array then data.map { redact(it) }
-      else data
-      end
-    end
-  end
-end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index b0d08ae..46f5743 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -940,7 +940,7 @@ module ProTacts
     # The arrival report: a BDAY the model does not take is unexpected
     # input, and storing it verbatim would be the last anyone heard of
     # it. The message carries no card content
-    # (ProTacts::SentryScrubber's line); the value stays on the
+    # (the line config.ru draws for Sentry); the value stays on the
     # machine, where the admin view shows it raw.
     #: () -> void
     def report_unrecognized_bday_line
@@ -1222,7 +1222,7 @@ module ProTacts
     # does not explain, and calling either the edit would be a guess.
     # The card is stored as it arrived and the line is news, the same
     # bargain report_unrecognized_bday_line makes, and carries no card
-    # content for the same reason (ProTacts::SentryScrubber).
+    # content for the same reason (config.ru).
     #: (Integer count) -> void
     def report_ambiguous_inherited_lines(count)
       return if count.zero?
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 2d65247..58febed 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -47,8 +47,8 @@ module ProTacts
       end
     end
 
-    # RewindableInput allows us to read the request body for Sentry logging
-    # and then rewind it so the application can still access it.
+    # RewindableInput lets the capture middlewares below read the request
+    # body and rewind it for the application.
     use Rack::RewindableInput::Middleware
     use Sentry::Rack::CaptureExceptions
 
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index a03628c..7669912 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -510,7 +510,7 @@ module ProTacts
     # reads every contact on every page load — and one bad card must
     # not alert once per page view. An arrival is the event worth a
     # message, and a PUT is the only arrival there is. The message
-    # carries no card content (ProTacts::SentryScrubber's line); the
+    # carries no card content (the line config.ru draws for Sentry); the
     # admin view shows the card raw.
     #: (VCard vcard) -> void
     def report_broken_assumptions(vcard)
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
index d810cbb..0fc9066 100644
--- a/lib/sequel/extensions/sole.rb
+++ b/lib/sequel/extensions/sole.rb
@@ -34,7 +34,7 @@ module Sequel
 
       # The table rather than the SQL: a filter can carry card content,
       # and an exception message is one of the things that reaches
-      # Sentry. See ProTacts::SentryScrubber.
+      # Sentry.
       raise TooManyRows, "more than one row in #{first_source}" if rows.length > 1
       raise Sequel::NoMatchingRow.new(self) if rows.empty?
 
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 919beef..2dba630 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -2,10 +2,6 @@
 # exception-capturing middleware web.rb mounts, the 404 and arrival
 # messages web.rb and store.rb send, and the photo decode refusal
 # contact.rb captures.
-#
-# Events are left untyped. SentryScrubber walks whatever before_send
-# hands it, feature-detecting as it goes, and typing that shape would
-# claim more about the sentry-ruby internals than the scrubber assumes.
 module Sentry
   def self.capture_message: (String message, ?level: Symbol) -> untyped
 
diff --git a/test/pro_tacts/test_sentry_scrubber.rb b/test/pro_tacts/test_sentry_scrubber.rb
deleted file mode 100644
index 5c53688..0000000
--- a/test/pro_tacts/test_sentry_scrubber.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-require_relative "../test_helper"
-require "sentry-ruby"
-
-require "pro_tacts/sentry_scrubber"
-
-class SentryScrubberTest < Minitest::Test
-  CARD = <<~VCARD.chomp
-    BEGIN:VCARD
-    VERSION:3.0
-    UID:AB12C345
-    FN:Real Person
-    TEL:+1-555-1234
-    END:VCARD
-  VCARD
-
-  # Stands in for a Sentry event: the scrubber only reaches for #request,
-  # and the request only needs a body and headers.
-  Event = Struct.new(:request)
-  Request = Struct.new(:data, :headers)
-
-  def scrub(data, headers = {})
-    event = Event.new(Request.new(data, headers))
-    ProTacts::SentryScrubber.call(event, nil)
-    event.request.data
-  end
-
-  def test_a_card_is_redacted
-    refute_includes scrub(CARD), "Real Person"
-  end
-
-  def test_a_card_is_replaced_rather_than_emptied
-    assert_includes scrub(CARD), "vcard redacted"
-  end
-
-  # The redaction has to stop at END:VCARD, not run to the end of the body,
-  # or a card anywhere in a request would take the rest of it along.
-  def test_a_card_embedded_in_xml_is_redacted_without_eating_what_follows
-    scrubbed = scrub("<address-data>#{CARD}</address-data><href>/keep/me</href>")
-
-    refute_includes scrubbed, "Real Person"
-    assert_includes scrubbed, "<address-data>"
-    assert_includes scrubbed, "</address-data>"
-    assert_includes scrubbed, "<href>/keep/me</href>"
-  end
-
-  # (?~) is greedy to the longest run holding no complete END:VCARD, which
-  # runs into the marker and stops at END:VCAR. Without something forcing it
-  # back onto the boundary a stray "D" survives, so assert the exact result
-  # rather than just the absence of card content.
-  def test_no_fragment_of_the_end_marker_survives
-    assert_equal "<d>#{ProTacts::SentryScrubber::REDACTED}</d>TAIL", scrub("<d>#{CARD}</d>TAIL")
-  end
-
-  def test_content_between_two_cards_survives
-    scrubbed = scrub("#{CARD}\nMIDDLE\n#{CARD.sub('Real Person', 'Other Person')}\nTRAILING")
-
-    refute_includes scrubbed, "Real Person"
-    refute_includes scrubbed, "Other Person"
-    assert_includes scrubbed, "MIDDLE"
-    assert_includes scrubbed, "TRAILING"
-  end
-
-  # Only the unterminated card runs to the end; the complete one before it
-  # still stops at its own END:VCARD.
-  def test_a_complete_card_before_a_truncated_one_stops_at_its_end
-    scrubbed = scrub("#{CARD}\nMIDDLE\nBEGIN:VCARD\nFN:Trunc")
-
-    refute_includes scrubbed, "Real Person"
-    refute_includes scrubbed, "Trunc"
-    assert_includes scrubbed, "MIDDLE"
-  end
-
-  def test_several_cards_are_all_redacted
-    scrubbed = scrub("#{CARD}\n#{CARD.sub('Real Person', 'Other Person')}")
-
-    refute_includes scrubbed, "Real Person"
-    refute_includes scrubbed, "Other Person"
-  end
-
-  # Sentry truncates at 16KB, so a card can arrive without its END line.
-  def test_a_truncated_card_is_redacted
-    refute_includes scrub("BEGIN:VCARD\nVERSION:3.0\nFN:Real Pers"), "Real Pers"
-  end
-
-  def test_lowercase_markers_are_redacted
-    refute_includes scrub(CARD.downcase), "real person"
-  end
-
-  # The href in a multiget names a contact by UID, which is not a secret and
-  # is the useful part of a 404 report.
-  def test_hrefs_are_left_alone
-    body = "<href>/dav/addressbook/AB12C345-6789.vcf</href>"
-
-    assert_equal body, scrub(body)
-  end
-
-  def test_a_vcard_content_type_redacts_the_whole_body
-    scrubbed = scrub("garbled but still a card", "Content-Type" => "text/vcard; charset=utf-8")
-
-    assert_equal ProTacts::SentryScrubber::REDACTED, scrubbed
-  end
-
-  def test_form_bodies_are_walked
-    scrubbed = scrub({ "card" => CARD, "id" => "AB12C345" })
-
-    refute_includes scrubbed.fetch("card"), "Real Person"
-    assert_equal "AB12C345", scrubbed.fetch("id")
-  end
-
-  def test_a_nil_body_is_left_alone
-    assert_nil scrub(nil)
-  end
-
-  def test_an_event_without_a_request_is_returned_unchanged
-    event = Event.new(nil)
-
-    assert_same event, ProTacts::SentryScrubber.call(event, nil)
-  end
-
-  # Guards the field names against a sentry-ruby upgrade: this is the real
-  # interface Sentry builds from a Rack env, not a stand-in.
-  def test_it_scrubs_a_real_sentry_request_interface
-    env = Rack::MockRequest.env_for("/dav/addressbook/", method: "PUT", input: CARD)
-    env["CONTENT_TYPE"] = "text/xml"
-    request = Sentry::RequestInterface.new(env: env, send_default_pii: true, rack_env_whitelist: [])
-
-    assert_includes request.data, "Real Person", "precondition: Sentry captured the body"
-
-    ProTacts::SentryScrubber.call(Event.new(request), nil)
-
-    refute_includes request.data, "Real Person"
-  end
-end