From 0394863c7be91e21dbf47c04414f903c3b975768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Fri, 22 May 2026 15:24:04 -0700 Subject: [PATCH] Add detailed diagnostics reporting to GIF keyboard failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog-Added: Added detailed diagnostics reporting to GIF keyboard errors Signed-off-by: Daniel D’Aquino --- .../NIP98/NIP98AuthenticatedRequest.swift | 58 +++++- damus/Shared/Media/GIF/GIFPickerView.swift | 55 ++++-- .../Shared/Media/GIF/PurpleGIFAPIClient.swift | 172 ++++++++++++++++-- 3 files changed, 247 insertions(+), 38 deletions(-) diff --git a/damus/Core/NIPs/NIP98/NIP98AuthenticatedRequest.swift b/damus/Core/NIPs/NIP98/NIP98AuthenticatedRequest.swift index 916c25a0..6cd1ef2c 100644 --- a/damus/Core/NIPs/NIP98/NIP98AuthenticatedRequest.swift +++ b/damus/Core/NIPs/NIP98/NIP98AuthenticatedRequest.swift @@ -19,11 +19,18 @@ enum HTTPPayloadType: String { case binary = "application/octet-stream" } -func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - request.httpBody = payload - +/// Creates a NIP-98 authentication event for HTTP requests. +/// +/// This generates the Nostr event used for authenticating HTTP requests according to NIP-98. +/// The event includes the request URL, HTTP method, and optionally a payload hash. +/// +/// - Parameters: +/// - method: The HTTP method (GET, POST, PUT, DELETE) +/// - url: The full URL being requested +/// - payload: Optional request body data to hash and include in the auth event +/// - auth_keypair: The keypair to sign the auth event with +/// - Returns: The NIP-98 authentication event, or nil if event creation fails +func create_nip98_auth_event(method: HTTPMethod, url: URL, payload: Data?, auth_keypair: Keypair) -> NdbNote? { var tag_pairs = [ ["u", url.absoluteString], ["method", method.rawValue], @@ -35,14 +42,32 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat tag_pairs.append(["payload", payload_hash_hex]) } - let auth_note = NdbNote( + return NdbNote( content: "", keypair: auth_keypair, kind: 27235, tags: tag_pairs, createdAt: UInt32(Date().timeIntervalSince1970) ) +} +/// Makes an HTTP request authenticated with a pre-built NIP-98 event. +/// +/// This overload accepts a pre-built and signed NIP-98 authentication event. +/// +/// - Parameters: +/// - method: The HTTP method (GET, POST, PUT, DELETE) +/// - url: The full URL to request +/// - payload: Optional request body data +/// - payload_type: Optional Content-Type for the payload +/// - auth_note: The pre-built NIP-98 authentication event +/// - Returns: A tuple containing the response data and URLResponse +/// - Throws: Errors from URL loading or JSON encoding +func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_note: NdbNote) async throws -> (data: Data, response: URLResponse) { + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.httpBody = payload + let auth_note_json_data: Data = try encode_json_data(auth_note) let auth_note_base64: String = base64_encode(auth_note_json_data.bytes) @@ -52,3 +77,24 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat } return try await URLSession.shared.data(for: request) } + +/// Makes an HTTP request authenticated with NIP-98. +/// +/// This function creates a NIP-98 authentication event and includes it in the request's +/// Authorization header as a base64-encoded Nostr event. +/// +/// - Parameters: +/// - method: The HTTP method (GET, POST, PUT, DELETE) +/// - url: The full URL to request +/// - payload: Optional request body data +/// - payload_type: Optional Content-Type for the payload +/// - auth_keypair: The keypair to sign the auth event with +/// - Returns: A tuple containing the response data and URLResponse +/// - Throws: Errors from URL loading or JSON encoding +func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?, auth_keypair: Keypair) async throws -> (data: Data, response: URLResponse) { + guard let auth_note = create_nip98_auth_event(method: method, url: url, payload: payload, auth_keypair: auth_keypair) else { + throw URLError(.unknown) + } + + return try await make_nip98_authenticated_request(method: method, url: url, payload: payload, payload_type: payload_type, auth_note: auth_note) +} diff --git a/damus/Shared/Media/GIF/GIFPickerView.swift b/damus/Shared/Media/GIF/GIFPickerView.swift index d62c3386..ba94f90a 100644 --- a/damus/Shared/Media/GIF/GIFPickerView.swift +++ b/damus/Shared/Media/GIF/GIFPickerView.swift @@ -347,44 +347,71 @@ private extension PurpleGIFAPIError { /// Converts Purple GIF API failures into a reusable user-presentable error. func userPresentableError(action: String, currentQuery: String?) -> ErrorView.UserPresentableError { let queryContext = currentQuery.map { "query=\($0)" } ?? "featured" + + // Build comprehensive technical info including error context + let baseTechnicalInfo = "GIF picker error while \(action); context=\(queryContext)" + let fullTechnicalInfo: String + + if let errorContext = self.context { + fullTechnicalInfo = "\(baseTechnicalInfo); \(errorContext.debugDescription)" + } else { + fullTechnicalInfo = baseTechnicalInfo + } switch self { - case .unauthorized: - return .init( - user_visible_description: NSLocalizedString("You need an active Purple subscription to use GIF search.", comment: "Error shown when the user is not authorized to use the GIF picker."), - tip: NSLocalizedString("Make sure you're signed in with the right account and that your Purple subscription is active, then try again.", comment: "Advice shown when GIF picker access is denied."), - technical_info: "GIF picker unauthorized while \(action); context=\(queryContext)" - ) + case .unauthorized(let context): + // For 401 errors, show the server's error message with context + let serverMessage = context?.extractedMessage + + if let message = serverMessage, !message.isEmpty { + // Quote and provide context around the server message + let contextualizedMessage = String(format: NSLocalizedString("Access to the GIF service was denied by the server with the following message: \"%@\"", comment: "Error message format that quotes the server's error message"), message) + return .init( + user_visible_description: contextualizedMessage, + tip: NSLocalizedString("If the problem persists, copy the technical information and send it to support.", comment: "Advice shown when GIF picker access is denied with server message."), + technical_info: fullTechnicalInfo + ) + } else { + // Fallback to generic message if no server message available + return .init( + user_visible_description: NSLocalizedString("Access to the GIF service was denied.", comment: "Generic error shown when the user is not authorized to use the GIF picker and no server message is available."), + tip: NSLocalizedString("This could be due to an expired subscription, authentication issue, or network problem. Copy the technical information and send it to support for help.", comment: "Advice shown when GIF picker access is denied without specific details."), + technical_info: fullTechnicalInfo + ) + } case .invalidURL: return .init( user_visible_description: NSLocalizedString("The GIF service is misconfigured.", comment: "Error shown when the GIF picker generated an invalid URL."), tip: NSLocalizedString("Try again later. If this keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker URL is invalid."), - technical_info: "GIF picker invalid URL while \(action); context=\(queryContext)" + technical_info: fullTechnicalInfo ) case .invalidResponse: return .init( user_visible_description: NSLocalizedString("The GIF service returned an unexpected response.", comment: "Error shown when the GIF picker receives an invalid server response."), tip: NSLocalizedString("Try again in a moment. If it keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the GIF picker receives an invalid server response."), - technical_info: "GIF picker invalid response while \(action); context=\(queryContext)" + technical_info: fullTechnicalInfo ) - case .decodingError(let decodingError, let rawResponse): + case .decodingError(let decodingError, let rawResponse, _): + // Include decoding error details for legacy compatibility let responseText = rawResponse ?? "" + let detailedInfo = "\(baseTechnicalInfo); error=\(String(describing: decodingError)); response=\(responseText)" + let withContext = self.context.map { "; \($0.debugDescription)" } ?? "" return .init( user_visible_description: NSLocalizedString("We couldn't understand the GIF data from the server.", comment: "Error shown when GIF response parsing fails."), tip: NSLocalizedString("Try again in a moment. If the problem continues, copy the technical information and send it to support.", comment: "Advice shown when GIF response parsing fails."), - technical_info: "GIF picker decoding error while \(action); context=\(queryContext); error=\(String(describing: decodingError)); response=\(responseText)" + technical_info: detailedInfo + withContext ) - case .networkError(let networkError): + case .networkError(let networkError, _): return .init( user_visible_description: NSLocalizedString("We couldn't reach the GIF service.", comment: "Error shown when GIF loading fails because of a network issue."), tip: NSLocalizedString("Check your internet connection and try again.", comment: "Advice shown when GIF loading fails because of a network issue."), - technical_info: "GIF picker network error while \(action); context=\(queryContext); error=\(String(describing: networkError))" + technical_info: "\(baseTechnicalInfo); error=\(String(describing: networkError))" + (self.context.map { "; \($0.debugDescription)" } ?? "") ) - case .upstreamError(let statusCode, let message): + case .upstreamError(let statusCode, let message, _): return .init( user_visible_description: NSLocalizedString("The GIF service is temporarily unavailable.", comment: "Error shown when the upstream GIF service fails."), tip: NSLocalizedString("Try again in a moment. If the problem keeps happening, copy the technical information and send it to support.", comment: "Advice shown when the upstream GIF service fails."), - technical_info: "GIF picker upstream error while \(action); context=\(queryContext); status=\(statusCode); message=\(message ?? "none")" + technical_info: "\(baseTechnicalInfo); status=\(statusCode); message=\(message ?? "none")" + (self.context.map { "; \($0.debugDescription)" } ?? "") ) } } diff --git a/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift b/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift index 3e5ffe98..3e9e7ac6 100644 --- a/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift +++ b/damus/Shared/Media/GIF/PurpleGIFAPIClient.swift @@ -4,14 +4,42 @@ // import Foundation +import Sentry + +/// Detailed context for GIF API errors, used for diagnostics and error reporting. +struct GIFAPIErrorContext { + /// The full HTTP response body from the server + let serverResponse: String? + /// HTTP status code returned by the server + let statusCode: Int? + /// The URL that was requested + let requestURL: String? + /// The NIP-98 authentication event that was sent (without sensitive private key data) + let nip98Event: String? + /// The timestamp when the request was made + let timestamp: Date + /// Extracted error message from server response (from "error" or "message" JSON key) + let extractedMessage: String? + + /// Returns a formatted string suitable for logging and user-facing technical info + var debugDescription: String { + var parts: [String] = [] + parts.append("timestamp=\(timestamp.ISO8601Format())") + if let url = requestURL { parts.append("url=\(url)") } + if let status = statusCode { parts.append("status=\(status)") } + if let response = serverResponse { parts.append("server_response=\(response)") } + if let event = nip98Event { parts.append("nip98_event=\(event)") } + return parts.joined(separator: "; ") + } +} enum PurpleGIFAPIError: Error, LocalizedError { case invalidURL - case invalidResponse - case unauthorized - case upstreamError(statusCode: Int, message: String?) - case networkError(Error) - case decodingError(Error, rawResponse: String?) + case invalidResponse(context: GIFAPIErrorContext?) + case unauthorized(context: GIFAPIErrorContext?) + case upstreamError(statusCode: Int, message: String?, context: GIFAPIErrorContext?) + case networkError(Error, context: GIFAPIErrorContext?) + case decodingError(Error, rawResponse: String?, context: GIFAPIErrorContext?) var errorDescription: String? { switch self { @@ -21,14 +49,28 @@ enum PurpleGIFAPIError: Error, LocalizedError { return NSLocalizedString("Invalid response from GIF service", comment: "Error message for invalid Purple GIF response") case .unauthorized: return NSLocalizedString("Purple subscription required to use GIF search", comment: "Error message shown when the user is not authorized to use Purple GIF endpoints") - case .upstreamError(_, let message): + case .upstreamError(_, let message, _): return message ?? NSLocalizedString("GIF service is temporarily unavailable", comment: "Error message shown when the Purple GIF service fails") - case .networkError(let error): + case .networkError(let error, _): return error.localizedDescription case .decodingError: return NSLocalizedString("Failed to parse GIF data", comment: "Error message for GIF decoding failure") } } + + /// Returns the error context for diagnostic purposes + var context: GIFAPIErrorContext? { + switch self { + case .invalidURL: + return nil + case .invalidResponse(let context), + .unauthorized(let context), + .upstreamError(_, _, let context), + .networkError(_, let context), + .decodingError(_, _, let context): + return context + } + } } actor PurpleGIFAPIClient { @@ -71,44 +113,116 @@ actor PurpleGIFAPIClient { /// Performs an authenticated request against the Purple GIF proxy. private func performRequest(url: URL) async throws -> GIFSearchResponse { + let requestTimestamp = Date() + + // Create NIP-98 event once and reuse it for both the request and error context + guard let nip98Event = create_nip98_auth_event( + method: .get, + url: url, + payload: nil, + auth_keypair: purple.keypair + ) else { + throw PurpleGIFAPIError.invalidURL + } + + let nip98EventJSON: String? + if let data = try? encode_json_data(nip98Event) { + nip98EventJSON = String(data: data, encoding: .utf8) + } else { + nip98EventJSON = nil + } + do { + // Use the pre-built event to ensure the actual auth event sent matches what we log let (data, response) = try await make_nip98_authenticated_request( method: .get, url: url, payload: nil, payload_type: nil, - auth_keypair: purple.keypair + auth_note: nip98Event ) guard let httpResponse = response as? HTTPURLResponse else { - throw PurpleGIFAPIError.invalidResponse + let context = GIFAPIErrorContext( + serverResponse: String(data: data, encoding: .utf8), + statusCode: nil, + requestURL: url.absoluteString, + nip98Event: nip98EventJSON, + timestamp: requestTimestamp, + extractedMessage: extractErrorMessage(from: data) + ) + let error = PurpleGIFAPIError.invalidResponse(context: context) + reportErrorToSentry(error, context: context) + throw error } guard (200...299).contains(httpResponse.statusCode) else { - throw mapHTTPError(statusCode: httpResponse.statusCode, data: data) + throw mapHTTPError( + statusCode: httpResponse.statusCode, + data: data, + url: url, + nip98Event: nip98EventJSON, + timestamp: requestTimestamp + ) } do { return try decoder.decode(GIFSearchResponse.self, from: data) } catch let decodingError as DecodingError { let rawResponse = String(data: data, encoding: .utf8) - throw PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse) + let context = GIFAPIErrorContext( + serverResponse: rawResponse, + statusCode: httpResponse.statusCode, + requestURL: url.absoluteString, + nip98Event: nip98EventJSON, + timestamp: requestTimestamp, + extractedMessage: extractErrorMessage(from: data) + ) + let error = PurpleGIFAPIError.decodingError(decodingError, rawResponse: rawResponse, context: context) + reportErrorToSentry(error, context: context) + throw error } } catch let error as PurpleGIFAPIError { + // Error already has context and was already reported to Sentry throw error } catch { - throw PurpleGIFAPIError.networkError(error) + let context = GIFAPIErrorContext( + serverResponse: nil, + statusCode: nil, + requestURL: url.absoluteString, + nip98Event: nip98EventJSON, + timestamp: requestTimestamp, + extractedMessage: nil + ) + let gifError = PurpleGIFAPIError.networkError(error, context: context) + reportErrorToSentry(gifError, context: context) + throw gifError } } /// Maps HTTP failures from the Purple proxy into localized client errors. - private func mapHTTPError(statusCode: Int, data: Data) -> PurpleGIFAPIError { - if statusCode == 401 { - return .unauthorized - } - + private func mapHTTPError(statusCode: Int, data: Data, url: URL, nip98Event: String?, timestamp: Date) -> PurpleGIFAPIError { + let serverResponse = String(data: data, encoding: .utf8) let message = extractErrorMessage(from: data) - return .upstreamError(statusCode: statusCode, message: message) + + let context = GIFAPIErrorContext( + serverResponse: serverResponse, + statusCode: statusCode, + requestURL: url.absoluteString, + nip98Event: nip98Event, + timestamp: timestamp, + extractedMessage: message + ) + + let error: PurpleGIFAPIError + if statusCode == 401 { + error = .unauthorized(context: context) + } else { + error = .upstreamError(statusCode: statusCode, message: message, context: context) + } + + reportErrorToSentry(error, context: context) + return error } /// Extracts a human-readable error message from a JSON or text payload. @@ -132,4 +246,26 @@ actor PurpleGIFAPIClient { return text } + + /// Reports GIF API errors to Sentry with basic diagnostic context. + /// + /// Only includes non-sensitive information: HTTP status code, server error message, + /// and timestamp. No URLs, query parameters, or auth details are sent. + private func reportErrorToSentry(_ error: PurpleGIFAPIError, context: GIFAPIErrorContext?) { + DamusSentry.captureSentryError(error) { scope in + scope.setContext(value: [ + "error_type": String(describing: error), + "timestamp": context?.timestamp.ISO8601Format() ?? "unknown", + "status_code": context?.statusCode ?? "none", + "server_error_message": context?.extractedMessage ?? "none" + ], key: "gif_api_error") + + // Add tags for easier filtering in Sentry + if let statusCode = context?.statusCode { + scope.setTag(value: String(statusCode), key: "http_status") + } + + scope.setTag(value: "gif_api", key: "error_source") + } + } }