TF-4269 Integrate Sentry into iOS NSE and automate dSYM upload in CI

This commit is contained in:
dab246
2026-04-21 11:51:36 +07:00
parent 4505c50302
commit bab9d4f24c
17 changed files with 412 additions and 18 deletions
@@ -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
}
}
+47
View File
@@ -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
}
+24 -4
View File
@@ -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)
}
}