TF-4269 Integrate Sentry into iOS NSE and automate dSYM upload in CI
This commit is contained in:
@@ -58,3 +58,21 @@ class KeychainController: KeychainControllerDelegate {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
extension KeychainController {
|
||||
/// The key used in Dart to store the Sentry configuration JSON
|
||||
private var sentryConfigKey: String { "sentry_config_data" }
|
||||
|
||||
/// Retrieves and decodes the SentryConfig from Keychain
|
||||
func retrieveSentryConfig() -> SentryConfig? {
|
||||
do {
|
||||
guard let configData = try keychain.getData(sentryConfigKey) else {
|
||||
return nil
|
||||
}
|
||||
return try JSONDecoder().decode(SentryConfig.self, from: configData)
|
||||
} catch {
|
||||
TwakeLogger.shared.log(message: "SentryConfig could not be decoded from Keychain")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
struct KeychainSharingSession: Codable {
|
||||
let accountId: String
|
||||
@@ -61,4 +62,12 @@ extension KeychainSharingSession {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var sentryUser: User {
|
||||
let user = User()
|
||||
user.userId = self.accountId
|
||||
user.email = self.userName
|
||||
user.username = self.userName
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct SentryConfig: Codable {
|
||||
/// DSN (Data Source Name) endpoint for the Sentry project
|
||||
let dsn: String
|
||||
|
||||
/// Running environment (production/staging/dev)
|
||||
let environment: String
|
||||
|
||||
/// Current app release version
|
||||
let release: String
|
||||
|
||||
/// Distribution (e.g. Git SHA). Must match --dist used when uploading symbols to Sentry.
|
||||
/// Optional: only set when the main app passes it via --dart-define=SENTRY_DIST.
|
||||
let dist: String?
|
||||
|
||||
/// Performance monitoring: Set to 1.0 to capture 100% of transactions for tracing.
|
||||
/// High values in NSE might impact extension memory limit (24MB).
|
||||
let tracesSampleRate: Double
|
||||
|
||||
/// Optional profiling sample rate.
|
||||
let profilesSampleRate: Double
|
||||
|
||||
/// Release Health: The sampling rate for sessions (0.0 to 1.0).
|
||||
let sessionSampleRate: Double
|
||||
|
||||
/// Error tracking: The sampling rate for errors (0.0 to 1.0).
|
||||
/// If set to 0.1, only 10% of errors are sent.
|
||||
let onErrorSampleRate: Double
|
||||
|
||||
/// Enable logs to be sent to Sentry (or internal console logging).
|
||||
let enableLogs: Bool
|
||||
|
||||
/// Debug logs during development.
|
||||
let isDebug: Bool
|
||||
|
||||
/// Automatically attaches a screenshot when capturing an error.
|
||||
/// Ignored in NSE as there is no UI to screenshot.
|
||||
let attachScreenshot: Bool
|
||||
|
||||
/// Master switch to check if Sentry integration is allowed/available.
|
||||
let isAvailable: Bool
|
||||
|
||||
/// Performance: Tracks UI rendering performance.
|
||||
/// Ignored in NSE as there is no UI rendering.
|
||||
let enableFramesTracking: Bool
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import UserNotifications
|
||||
import Sentry
|
||||
import SwiftUI
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
@@ -13,6 +14,10 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
|
||||
SentryManager.shared.configure(with: keychainController)
|
||||
SentryManager.shared.clearUser()
|
||||
|
||||
handler = contentHandler
|
||||
modifiedContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||
|
||||
@@ -22,10 +27,12 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
if isAppActive == true {
|
||||
self.modifiedContent?.userInfo = request.content.userInfo.merging(["data": request.content.userInfo], uniquingKeysWith: {(_, new) in new})
|
||||
contentHandler(self.modifiedContent ?? request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let payloadData = request.content.userInfo as? [String: Any],
|
||||
!keychainController.retrieveSharingSessions().isEmpty else {
|
||||
SentryManager.shared.capture(message: "NSE: Payload invalid or No Session found in Keychain")
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable"))
|
||||
return self.notify()
|
||||
}
|
||||
@@ -42,6 +49,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
// Called just before the extension will be terminated by the system.
|
||||
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
|
||||
SentryManager.shared.capture(message: "NSE: Service Extension Time Expired (Timeout)", flushTimeout: 0.3)
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable"))
|
||||
self.notify()
|
||||
}
|
||||
@@ -55,14 +63,23 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
let mapStateChanges: [String: [TypeName: String]] = PayloadParser.shared.parsingPayloadNotification(payloadData: payloadData)
|
||||
|
||||
if (mapStateChanges.isEmpty) {
|
||||
SentryManager.shared.capture(message: "NSE: Payload parsing returned empty state changes")
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable"))
|
||||
return self.notify()
|
||||
} else {
|
||||
guard let currentAccountId = mapStateChanges.keys.first,
|
||||
let keychainSharingSession = keychainController.retrieveSharingSessionFromKeychain(accountId: currentAccountId),
|
||||
keychainSharingSession.tokenOIDC != nil || keychainSharingSession.basicAuth != nil,
|
||||
let listStateOfAccount = mapStateChanges[currentAccountId],
|
||||
keychainSharingSession.tokenOIDC != nil || keychainSharingSession.basicAuth != nil else {
|
||||
SentryManager.shared.capture(message: "NSE: Session missing or invalid credential for account: \(mapStateChanges.keys.first ?? "unknown")")
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable"))
|
||||
return self.notify()
|
||||
}
|
||||
|
||||
SentryManager.shared.setSentryUser(keychainSharingSession.sentryUser)
|
||||
|
||||
guard let listStateOfAccount = mapStateChanges[currentAccountId],
|
||||
let newEmailDeliveryState = listStateOfAccount[TypeName.emailDelivery] else {
|
||||
SentryManager.shared.capture(message: "NSE: Missing emailDelivery state in payload")
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newNotificationDefaultMessageKey, comment: "Localizable"))
|
||||
return self.notify()
|
||||
}
|
||||
@@ -106,7 +123,8 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
TwakeLogger.shared.log(message: "JmapClient.shared.getNewEmails: \(error)")
|
||||
TwakeLogger.shared.log(message: "Error processing emails: \(error)")
|
||||
SentryManager.shared.capture(error: error)
|
||||
self.showDefaultNotification(message: NSLocalizedString(self.newEmailDefaultMessageKey, comment: "Localizable"))
|
||||
return self.notify()
|
||||
}
|
||||
@@ -183,7 +201,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
content.userInfo = userInfo
|
||||
|
||||
// Create a notification trigger
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false)
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false)
|
||||
// Create a notification request
|
||||
let request = UNNotificationRequest(identifier: notificationId, content: content, trigger: trigger)
|
||||
|
||||
@@ -191,6 +209,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
TwakeLogger.shared.log(message: "Error scheduling notification: \(error.localizedDescription)")
|
||||
SentryManager.shared.capture(error: error)
|
||||
} else {
|
||||
TwakeLogger.shared.log(message: "Notification scheduled successfully")
|
||||
}
|
||||
@@ -212,6 +231,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
private func cleanUp() {
|
||||
SentryManager.shared.clearUser()
|
||||
handler = nil
|
||||
modifiedContent = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
class SentryManager {
|
||||
|
||||
/// Singleton instance for easy access
|
||||
static let shared = SentryManager()
|
||||
|
||||
/// Internal flag to prevent multiple initializations
|
||||
private var isInitialized: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Configures Sentry using the config stored in Keychain.
|
||||
func configure(with keychainController: KeychainController) {
|
||||
// Prevent re-initialization
|
||||
if isInitialized { return }
|
||||
|
||||
// Retrieve config and validate 'isAvailable' and DSN presence
|
||||
guard let config = keychainController.retrieveSentryConfig(),
|
||||
config.isAvailable,
|
||||
!config.dsn.isEmpty else {
|
||||
TwakeLogger.shared.log(message: "Sentry is disabled or config is missing")
|
||||
return
|
||||
}
|
||||
|
||||
// Start Sentry SDK with options mapped from the config
|
||||
SentrySDK.start { options in
|
||||
options.dsn = config.dsn
|
||||
options.environment = config.environment
|
||||
options.releaseName = config.release
|
||||
options.dist = config.dist
|
||||
options.debug = config.isDebug
|
||||
// Map enableLogs to diagnostic level if needed
|
||||
options.diagnosticLevel = config.isDebug ? .debug : .none
|
||||
// Maps 'onErrorSampleRate' (Dart) to 'sampleRate' (iOS).
|
||||
// tracesSampleRate, profilesSampleRate, sessionSampleRate are intentionally not applied:
|
||||
// NSE has no UI and its lifecycle is too short for performance/session tracking.
|
||||
options.sampleRate = NSNumber(value: config.onErrorSampleRate)
|
||||
// Disable App Hang tracking: NSE execution is short, this causes false positives.
|
||||
options.enableAppHangTracking = false
|
||||
// Disable Watchdog tracking: Prevent OOM reports specific to extensions.
|
||||
options.enableWatchdogTerminationTracking = false
|
||||
// Disable UI/Interaction tracing: NSE has no UI.
|
||||
options.enableUserInteractionTracing = false
|
||||
options.enableAutoPerformanceTracing = false
|
||||
options.enablePreWarmedAppStartTracing = false
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
TwakeLogger.shared.log(message: "Sentry has been successfully initialized.")
|
||||
}
|
||||
|
||||
/// Safely captures an error if Sentry is initialized.
|
||||
/// - Parameter flushTimeout: If provided, blocks until events are sent or the timeout elapses.
|
||||
/// Use in critical paths (e.g. serviceExtensionTimeWillExpire) where the process may be
|
||||
/// suspended before Sentry flushes its queue.
|
||||
func capture(error: Error, flushTimeout: TimeInterval? = nil) {
|
||||
guard isInitialized else { return }
|
||||
SentrySDK.capture(error: error)
|
||||
if let flushTimeout {
|
||||
SentrySDK.flush(timeout: flushTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely captures a message if Sentry is initialized.
|
||||
/// - Parameter flushTimeout: If provided, blocks until events are sent or the timeout elapses.
|
||||
/// Use in critical paths (e.g. serviceExtensionTimeWillExpire) where the process may be
|
||||
/// suspended before Sentry flushes its queue.
|
||||
func capture(message: String, flushTimeout: TimeInterval? = nil) {
|
||||
guard isInitialized else { return }
|
||||
SentrySDK.capture(message: message)
|
||||
if let flushTimeout {
|
||||
SentrySDK.flush(timeout: flushTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set user context cho Sentry
|
||||
func setSentryUser(_ user: User) {
|
||||
guard isInitialized else { return }
|
||||
SentrySDK.setUser(user)
|
||||
}
|
||||
|
||||
/// Clear user
|
||||
func clearUser() {
|
||||
guard isInitialized else { return }
|
||||
SentrySDK.setUser(nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user