feat: add logfire spans and structured error handling to notifications

- add NotificationResult dataclass with success, error, error_type fields
- categorize errors: dm_blocked, network, auth, unknown
- add logfire spans to _send_dm_to_did and send_copyright_notification
- log warnings with structured data when notifications fail
- return tuple of results so caller knows exactly what happened

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+176 -73
backend
src
backend
+8 -2
backend/src/backend/_internal/moderation.py
··· 90 90 .where(Track.id == track_id) 91 91 ) 92 92 if track and track.artist: 93 - notified = await notification_service.send_copyright_notification( 93 + ( 94 + artist_result, 95 + admin_result, 96 + ) = await notification_service.send_copyright_notification( 94 97 track_id=track_id, 95 98 track_title=track.title, 96 99 artist_did=track.artist_did, ··· 98 101 highest_score=scan.highest_score, 99 102 matches=scan.matches, 100 103 ) 101 - if notified: 104 + # mark as notified if at least one succeeded 105 + if (artist_result and artist_result.success) or ( 106 + admin_result and admin_result.success 107 + ): 102 108 scan.notified_at = datetime.now(UTC) 103 109 await db.commit() 104 110
+168 -71
backend/src/backend/_internal/notifications.py
··· 1 1 """notification service for relay events.""" 2 2 3 3 import logging 4 + from dataclasses import dataclass 4 5 6 + import logfire 5 7 from atproto import AsyncClient, models 6 8 7 9 from backend.config import settings 8 10 from backend.models import Track 9 11 10 12 logger = logging.getLogger(__name__) 13 + 14 + 15 + @dataclass 16 + class NotificationResult: 17 + """result of a notification attempt.""" 18 + 19 + success: bool 20 + recipient_did: str 21 + error: str | None = None 22 + error_type: str | None = None # "dm_blocked", "network", "auth", "unknown" 11 23 12 24 13 25 class NotificationService: ··· 68 80 self.dm_client = None 69 81 self.recipient_did = None 70 82 71 - async def _send_dm_to_did(self, recipient_did: str, message_text: str) -> bool: 83 + async def _send_dm_to_did( 84 + self, recipient_did: str, message_text: str 85 + ) -> NotificationResult: 72 86 """send a DM to a specific DID. 73 87 74 - returns True if sent successfully, False otherwise. 88 + returns NotificationResult with success status and error details. 75 89 """ 76 90 if not self.dm_client: 77 - logger.warning("dm client not authenticated, skipping notification") 78 - return False 91 + return NotificationResult( 92 + success=False, 93 + recipient_did=recipient_did, 94 + error="dm client not authenticated", 95 + error_type="auth", 96 + ) 79 97 80 - try: 81 - dm = self.dm_client.chat.bsky.convo 98 + with logfire.span( 99 + "send_dm", 100 + recipient_did=recipient_did, 101 + message_length=len(message_text), 102 + ) as span: 103 + try: 104 + dm = self.dm_client.chat.bsky.convo 82 105 83 - convo_response = await dm.get_convo_for_members( 84 - models.ChatBskyConvoGetConvoForMembers.Params(members=[recipient_did]) 85 - ) 106 + convo_response = await dm.get_convo_for_members( 107 + models.ChatBskyConvoGetConvoForMembers.Params( 108 + members=[recipient_did] 109 + ) 110 + ) 86 111 87 - if not convo_response.convo or not convo_response.convo.id: 88 - raise ValueError("failed to get conversation ID") 112 + if not convo_response.convo or not convo_response.convo.id: 113 + span.set_attribute("error_type", "no_convo") 114 + return NotificationResult( 115 + success=False, 116 + recipient_did=recipient_did, 117 + error="failed to get conversation ID - user may have DMs disabled", 118 + error_type="dm_blocked", 119 + ) 89 120 90 - await dm.send_message( 91 - models.ChatBskyConvoSendMessage.Data( 92 - convo_id=convo_response.convo.id, 93 - message=models.ChatBskyConvoDefs.MessageInput(text=message_text), 121 + await dm.send_message( 122 + models.ChatBskyConvoSendMessage.Data( 123 + convo_id=convo_response.convo.id, 124 + message=models.ChatBskyConvoDefs.MessageInput( 125 + text=message_text 126 + ), 127 + ) 94 128 ) 95 - ) 96 - return True 129 + 130 + span.set_attribute("success", True) 131 + return NotificationResult(success=True, recipient_did=recipient_did) 97 132 98 - except Exception: 99 - logger.exception(f"error sending DM to {recipient_did}") 100 - return False 133 + except Exception as e: 134 + error_str = str(e) 135 + error_type = "unknown" 136 + 137 + # try to categorize the error 138 + if "blocked" in error_str.lower() or "not allowed" in error_str.lower(): 139 + error_type = "dm_blocked" 140 + elif "timeout" in error_str.lower() or "connect" in error_str.lower(): 141 + error_type = "network" 142 + elif "auth" in error_str.lower() or "401" in error_str: 143 + error_type = "auth" 144 + 145 + span.set_attribute("error_type", error_type) 146 + span.set_attribute("error", error_str) 147 + logger.exception(f"error sending DM to {recipient_did}") 148 + 149 + return NotificationResult( 150 + success=False, 151 + recipient_did=recipient_did, 152 + error=error_str, 153 + error_type=error_type, 154 + ) 101 155 102 156 async def send_copyright_notification( 103 157 self, ··· 107 161 artist_handle: str, 108 162 highest_score: int, 109 163 matches: list[dict], 110 - ) -> bool: 164 + ) -> tuple[NotificationResult | None, NotificationResult | None]: 111 165 """send notification about a copyright flag to both artist and admin. 112 166 113 - returns True if at least one notification was sent successfully. 167 + returns (artist_result, admin_result) tuple with details of each attempt. 114 168 """ 115 - if not self.dm_client: 116 - logger.warning("dm client not authenticated, skipping notification") 117 - return False 169 + with logfire.span( 170 + "copyright_notification", 171 + track_id=track_id, 172 + track_title=track_title, 173 + artist_did=artist_did, 174 + artist_handle=artist_handle, 175 + highest_score=highest_score, 176 + match_count=len(matches), 177 + ) as span: 178 + if not self.dm_client: 179 + logfire.warn("dm client not authenticated, skipping notification") 180 + return None, None 181 + 182 + # format match info 183 + match_count = len(matches) 184 + primary_match = None 185 + if matches: 186 + m = matches[0] 187 + primary_match = ( 188 + f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}" 189 + ) 190 + 191 + # build track URL if available 192 + track_url = None 193 + frontend_url = settings.frontend.url 194 + if frontend_url and "localhost" not in frontend_url: 195 + track_url = f"{frontend_url}/track/{track_id}" 118 196 119 - # format match info 120 - match_count = len(matches) 121 - primary_match = None 122 - if matches: 123 - m = matches[0] 124 - primary_match = ( 125 - f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}" 197 + # message for the artist (uploader) 198 + artist_message = ( 199 + f"⚠️ copyright notice for your track on {settings.app.name}\n\n" 200 + f"track: '{track_title}'\n" 201 + f"match confidence: {highest_score}%\n" 202 + ) 203 + if primary_match: 204 + artist_message += f"potential match: {primary_match}\n" 205 + artist_message += ( 206 + "\nif you believe this is an error, please reply to this message. " 207 + "otherwise, the track may be removed after review." 126 208 ) 127 209 128 - # build track URL if available 129 - track_url = None 130 - frontend_url = settings.frontend.url 131 - if frontend_url and "localhost" not in frontend_url: 132 - track_url = f"{frontend_url}/track/{track_id}" 210 + # message for admin 211 + admin_message = ( 212 + f"🚨 copyright flag on {settings.app.name}\n\n" 213 + f"track: '{track_title}'\n" 214 + f"artist: @{artist_handle}\n" 215 + f"score: {highest_score}%\n" 216 + f"matches: {match_count}\n" 217 + ) 218 + if primary_match: 219 + admin_message += f"primary: {primary_match}\n" 220 + if track_url: 221 + admin_message += f"\n{track_url}" 133 222 134 - # message for the artist (uploader) 135 - artist_message = ( 136 - f"⚠️ copyright notice for your track on {settings.app.name}\n\n" 137 - f"track: '{track_title}'\n" 138 - f"match confidence: {highest_score}%\n" 139 - ) 140 - if primary_match: 141 - artist_message += f"potential match: {primary_match}\n" 142 - artist_message += ( 143 - "\nif you believe this is an error, please reply to this message. " 144 - "otherwise, the track may be removed after review." 145 - ) 223 + # send to artist 224 + artist_result = await self._send_dm_to_did(artist_did, artist_message) 225 + span.set_attribute("artist_success", artist_result.success) 226 + if not artist_result.success: 227 + span.set_attribute("artist_error_type", artist_result.error_type) 228 + logfire.warn( 229 + "failed to notify artist", 230 + artist_handle=artist_handle, 231 + error_type=artist_result.error_type, 232 + error=artist_result.error, 233 + ) 146 234 147 - # message for admin 148 - admin_message = ( 149 - f"🚨 copyright flag on {settings.app.name}\n\n" 150 - f"track: '{track_title}'\n" 151 - f"artist: @{artist_handle}\n" 152 - f"score: {highest_score}%\n" 153 - f"matches: {match_count}\n" 154 - ) 155 - if primary_match: 156 - admin_message += f"primary: {primary_match}\n" 157 - if track_url: 158 - admin_message += f"\n{track_url}" 235 + # send to admin 236 + admin_result = None 237 + if self.recipient_did: 238 + admin_result = await self._send_dm_to_did( 239 + self.recipient_did, admin_message 240 + ) 241 + span.set_attribute("admin_success", admin_result.success) 242 + if not admin_result.success: 243 + span.set_attribute("admin_error_type", admin_result.error_type) 244 + logfire.warn( 245 + "failed to notify admin", 246 + error_type=admin_result.error_type, 247 + error=admin_result.error, 248 + ) 159 249 160 - # send to both 161 - artist_sent = await self._send_dm_to_did(artist_did, artist_message) 162 - admin_sent = False 163 - if self.recipient_did: 164 - admin_sent = await self._send_dm_to_did(self.recipient_did, admin_message) 250 + # summary 251 + any_success = artist_result.success or ( 252 + admin_result and admin_result.success 253 + ) 254 + span.set_attribute("any_success", any_success) 165 255 166 - if artist_sent: 167 - logger.info(f"sent copyright notification to artist {artist_handle}") 168 - if admin_sent: 169 - logger.info(f"sent copyright notification to admin for track {track_id}") 256 + if artist_result.success: 257 + logfire.info( 258 + "sent copyright notification to artist", 259 + artist_handle=artist_handle, 260 + track_id=track_id, 261 + ) 262 + if admin_result and admin_result.success: 263 + logfire.info( 264 + "sent copyright notification to admin", 265 + track_id=track_id, 266 + ) 170 267 171 - return artist_sent or admin_sent 268 + return artist_result, admin_result 172 269 173 270 async def send_image_flag_notification( 174 271 self,