dav: warn Sentry on refusals, grouped by route
A DAV refusal warns Sentry, grouped by method, route, and code;
the card id folds into {id}, so repeats share one road.
The not_found handler's warning is dropped, and admin misses stay unsaid;
only an exchange the log has marked is the kind an alert is fed.

Assisted-by: Claude Opus 5 via Claude Code
change qkmqnqvwusmurstompplmqztmluqvltm
commit 40abd6dd6021fb9fab14befe451e40c560aff2fa
author Alpha Chen <alpha@kejadlen.dev>
date
parent nmtowrxu
diff --git a/AGENTS.md b/AGENTS.md
index ef210c6..6825c53 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,8 +49,9 @@ lib/pro_tacts/
 ├── vcard/parser.rb # One card's bytes into lines
 ├── config.rb       # Every environment read in the app
 ├── profile.rb      # carddav.mobileconfig generation
-└── exchange_log.rb # Failed DAV exchanges, whole, under an id Sentry
-                    # carries
+├── exchange_log.rb # Failed DAV exchanges, whole, under an id Sentry
+│                   # carries
+└── refusal_alerts.rb # DAV refusals as Sentry warnings, one per route
 lib/roda/plugins/dav_verbs.rb     # PROPFIND and REPORT routing verbs
 lib/sequel/extensions/sole.rb     # `first`, minus the ambiguity
 db/migrations/      # Sequel migrations, run on every store open
diff --git a/lib/pro_tacts/exchange_log.rb b/lib/pro_tacts/exchange_log.rb
index 2b44836..e9111aa 100644
--- a/lib/pro_tacts/exchange_log.rb
+++ b/lib/pro_tacts/exchange_log.rb
@@ -27,6 +27,10 @@ module ProTacts
     # breadcrumbs.
     PROGNAME = "ProTacts::ExchangeLog" #: String
 
+    # Where a DAV exchange's id rides in the env, which is how
+    # RefusalAlerts tells a DAV exchange from an admin one.
+    ENV_KEY = "pro_tacts.exchange" #: String
+
     # A photo PUT is a megabyte of base64 on its own.
     ROTATE_AT = 10 * 1024 * 1024 #: Integer
     ROTATIONS = 5 #: Integer
@@ -73,6 +77,7 @@ module ProTacts
       return @app.call(env) unless dav?(env)
 
       id = SecureRandom.hex(6)
+      env[ENV_KEY] = id
       Sentry.set_tags(exchange: id)
       # The hub's last event id moves only for an error event, so one
       # that moved while the app ran is a report about this exchange.
diff --git a/lib/pro_tacts/refusal_alerts.rb b/lib/pro_tacts/refusal_alerts.rb
new file mode 100644
index 0000000..33fb400
--- /dev/null
+++ b/lib/pro_tacts/refusal_alerts.rb
@@ -0,0 +1,50 @@
+require "rack"
+require "sentry-ruby"
+
+require "pro_tacts/exchange_log"
+
+module ProTacts
+  # Rack middleware that warns Sentry when a DAV exchange is refused
+  # (docs/plans/2026-09-13-dav-observability.md, "Refusals alert"). The
+  # warning says only which route refused what; the exchange log, under
+  # the id the warning is tagged with, says what was sent.
+  class RefusalAlerts
+    # @rbs @app: Rack::_App
+
+    # A report this server does not support (403), a resource that is not
+    # here (404), a sync token naming a state that is gone (410), and a
+    # write whose precondition failed (412), the one status that means a
+    # client and this server disagree about a card.
+    REFUSALS = [403, 404, 410, 412].freeze #: Array[Integer]
+
+    #: (Rack::_App app) -> void
+    def initialize(app)
+      @app = app
+    end
+
+    # Only an exchange ExchangeLog marked is a DAV one: the admin half
+    # reports exceptions and nothing else.
+    #: (Rack::env env) -> Rack::response
+    def call(env)
+      status, headers, body = @app.call(env)
+      alert(env, status) if env.key?(ExchangeLog::ENV_KEY) && REFUSALS.include?(status)
+      [status, headers, body]
+    end
+
+    private
+
+    # Fingerprinted on the route with the card id replaced, so Sentry
+    # groups a refusal that repeats across cards as one issue.
+    #: (Rack::env env, Integer status) -> void
+    def alert(env, status)
+      method = env.fetch("REQUEST_METHOD")
+      route = env["PATH_INFO"].to_s.sub(%r{/[^/]+\.vcf\z}, "/{id}.vcf")
+
+      Sentry.capture_message(
+        "#{method} #{route} answered #{status} #{Rack::Utils::HTTP_STATUS_CODES[status]}",
+        level: :warning,
+        fingerprint: [method, route, status.to_s],
+      )
+    end
+  end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index b49bfc1..a51dd5f 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -9,6 +9,7 @@ require "roda"
 
 require "pro_tacts/contact"
 require "pro_tacts/exchange_log"
+require "pro_tacts/refusal_alerts"
 require "pro_tacts/store"
 require "pro_tacts/tailscale_auth"
 require "roda/plugins/dav_verbs"
@@ -58,13 +59,17 @@ module ProTacts
     # stack, not here, so requiring the app writes nothing.
     use ProTacts::ExchangeLog, path: ProTacts.config.exchange_log_path, everything: ProTacts.config.debug?
 
+    # Inside ExchangeLog, which marks the DAV exchanges this alerts on
+    # and has already tagged the scope the warning is sent from.
+    use ProTacts::RefusalAlerts
+
     plugin :all_verbs
     plugin :dav_verbs
     plugin :public, root: PUBLIC_ROOT.to_s
     plugin :hash_branches
 
+    # A DAV 404 warns through RefusalAlerts; an admin one reports nothing.
     plugin :not_found do
-      Sentry.capture_message("404 Not Found", level: :warning)
       "Not Found"
     end
 
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index a91f803..f1b8de7 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -1,10 +1,10 @@
 # Sentry is reached wherever a handler has something to report: the
-# 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. ExchangeLog tags the request's scope and reads
-# whether anything was reported under it.
+# exception-capturing middleware web.rb mounts, the refusal warnings
+# 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.
 module Sentry
-  def self.capture_message: (String message, ?level: Symbol) -> untyped
+  def self.capture_message: (String message, ?level: Symbol, ?fingerprint: Array[String]) -> untyped
 
   def self.capture_exception: (Exception exception, ?level: Symbol) -> untyped
 
diff --git a/test/pro_tacts/test_refusal_alerts.rb b/test/pro_tacts/test_refusal_alerts.rb
new file mode 100644
index 0000000..1667c59
--- /dev/null
+++ b/test/pro_tacts/test_refusal_alerts.rb
@@ -0,0 +1,76 @@
+require_relative "../test_helper"
+require "rack/test"
+
+require "pro_tacts/refusal_alerts"
+
+class RefusalAlertsTest < Minitest::Test
+  include Sentry::TestHelper
+  include SentryMessages
+  include Rack::Test::Methods
+
+  def setup
+    setup_sentry
+    @status = 200
+  end
+
+  def teardown
+    teardown_sentry_test
+  end
+
+  def app
+    ProTacts::RefusalAlerts.new(->(_env) { [@status, {}, [""]] })
+  end
+
+  def dav(path, method: "PUT")
+    request path, method:, ProTacts::ExchangeLog::ENV_KEY => "abc123"
+  end
+
+  def test_each_refusal_warns
+    [403, 404, 410, 412].each do |status|
+      @status = status
+
+      dav "/dav/addressbook/"
+    end
+
+    assert_equal 4, sentry_events.length
+    assert(sentry_events.all? { it.level == :warning })
+  end
+
+  def test_other_statuses_are_quiet
+    [200, 201, 207, 400, 401, 500].each do |status|
+      @status = status
+
+      dav "/dav/addressbook/"
+    end
+
+    assert_empty sentry_events
+  end
+
+  def test_a_refusal_repeated_across_cards_is_one_group
+    @status = 412
+
+    dav "/dav/addressbook/aiden.vcf"
+    dav "/dav/addressbook/bea.vcf"
+
+    assert_equal [%w[PUT /dav/addressbook/{id}.vcf 412]] * 2, sentry_events.map(&:fingerprint)
+    assert_equal "PUT /dav/addressbook/{id}.vcf answered 412 Precondition Failed", sentry_messages.first
+  end
+
+  def test_the_method_and_status_split_groups
+    @status = 412
+    dav "/dav/addressbook/aiden.vcf", method: "PUT"
+    dav "/dav/addressbook/aiden.vcf", method: "DELETE"
+    @status = 404
+    dav "/dav/addressbook/aiden.vcf", method: "DELETE"
+
+    assert_equal 3, sentry_events.map(&:fingerprint).uniq.length
+  end
+
+  def test_an_exchange_the_log_did_not_mark_is_quiet
+    @status = 404
+
+    get "/contacts/nope"
+
+    assert_empty sentry_events
+  end
+end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 8cec221..8bcd2c4 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -134,7 +134,7 @@ class WebTest < Minitest::Test
 
   # Guards the wiring rather than the middleware: mounted inside the
   # scope Sentry opens for the request, and pointed at the configured
-  # log. The 404's own report is what carries the tag here.
+  # log. The 404's refusal warning is what carries the tag here.
   def test_a_failed_exchange_is_logged_under_the_id_sentry_carries
     get "/dav/addressbook/no-such-contact.vcf"
 
@@ -147,6 +147,22 @@ class WebTest < Minitest::Test
     assert_includes logged, "#{id} << 404 Not Found"
   end
 
+  def test_a_refused_write_warns
+    with_contacts({}) do
+      put_request "new", card("new", "New"), "CONTENT_TYPE" => "text/plain"
+
+      assert_equal 412, last_response.status
+      assert_equal ["PUT /dav/addressbook/{id}.vcf answered 412 Precondition Failed"], sentry_messages
+    end
+  end
+
+  def test_an_admin_404_reports_nothing
+    get "/contacts/no-such-contact"
+
+    assert_equal 404, last_response.status
+    assert_empty sentry_events
+  end
+
   ## Refusing a request that names nobody
 
   def test_a_request_without_a_name_is_refused