Add detailed diagnostics reporting to GIF keyboard failures

Changelog-Added: Added detailed diagnostics reporting to GIF keyboard errors
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2026-05-25 13:04:53 -07:00
parent cbae70336d
commit 0394863c7b
3 changed files with 247 additions and 38 deletions
@@ -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)
}
+41 -14
View File
@@ -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 ?? "<unavailable>"
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)" } ?? "")
)
}
}
+154 -18
View File
@@ -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")
}
}
}