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