Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
418 changes: 356 additions & 62 deletions Documentation/Analytics.md

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions Targets/App/Sources/Main/FlinkyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
Self.configureSentry(options: options)
}

// Start app health observation for system-level metrics
// (thermal state, network reachability, app state transitions)
// Reference: https://github.com/getsentry/sentry-cocoa/issues/7000
AppHealthObserver.shared.startObserving()

do {
sharedModelContainer = try SharedModelContainerFactory.make(
isStoredInMemoryOnly: ProcessInfo.processInfo.isTestingEnabled
Expand Down Expand Up @@ -183,7 +188,7 @@
themeOptions.outlineStyle.cornerRadius = 5
themeOptions.outlineStyle.outlineWidth = 0.5
}
feedbackOptions.configureDarkTheme = { themeOptions in

Check warning on line 191 in Targets/App/Sources/Main/FlinkyApp.swift

View workflow job for this annotation

GitHub Actions / Test UI

setter for 'configureDarkTheme' is deprecated: Use dynamic UIColor instead of the dark theme.

Check warning on line 191 in Targets/App/Sources/Main/FlinkyApp.swift

View workflow job for this annotation

GitHub Actions / Test

setter for 'configureDarkTheme' is deprecated: Use dynamic UIColor instead of the dark theme.
// Background color to use for text inputs in the feedback form.
themeOptions.inputBackground = UIColor.systemGray6

Expand Down Expand Up @@ -224,23 +229,22 @@
breadcrumb.message = "User opened feedback form"
SentrySDK.addBreadcrumb(breadcrumb)

let event = Event(level: .info)
event.message = SentryMessage(formatted: "User opened feedback form")
SentrySDK.capture(event: event)
// Track feedback form opening using metrics - better for aggregate counts than individual events
SentryMetricsHelper.trackFeedbackFormOpened()
}
feedbackOptions.onFormClose = {
let breadcrumb = Breadcrumb(level: .info, category: "user_feedback")
breadcrumb.message = "User closed feedback form"
SentrySDK.addBreadcrumb(breadcrumb)

let event = Event(level: .info)
event.message = SentryMessage(formatted: "User closed feedback form")
SentrySDK.capture(event: event)
// Track feedback form closing using metrics - better for aggregate counts than individual events
SentryMetricsHelper.trackFeedbackFormClosed()
}
}

// Configure Other Options
options.experimental.enableUnhandledCPPExceptionsV2 = false
options.experimental.enableMetrics = true

// Configure Logs
options.enableLogs = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client

name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.1.0, source: https://github.com/getsentry/sentry-cocoa
name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.2.0, source: https://github.com/getsentry/sentry-cocoa

name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols

name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client

name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.1.0, source: https://github.com/getsentry/sentry-cocoa
name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.2.0, source: https://github.com/getsentry/sentry-cocoa

name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<key>File</key>
<string>Licenses/sentry-cocoa</string>
<key>Title</key>
<string>Sentry (9.1.0)</string>
<string>Sentry (9.2.0)</string>
<key>Type</key>
<string>PSChildPaneSpecifier</string>
</dict>
Expand Down
275 changes: 275 additions & 0 deletions Targets/App/Sources/Services/AppHealthObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import Foundation
import Network
import os.log
import Sentry
import UIKit

/// Observes app health signals and reports them as Sentry metrics.
///
/// This class monitors:
/// - **Thermal state changes**: Track when the device heats up or cools down
/// - **Network reachability changes**: Track connectivity state transitions
/// - **App state transitions**: Track foreground/background transitions
///
/// These metrics are recommended by the Sentry SDK team for app health monitoring
/// that doesn't overlap with automatic tracing features.
///
/// Reference: https://github.com/getsentry/sentry-cocoa/issues/7000
final class AppHealthObserver {

// MARK: - Singleton

/// Shared instance of the app health observer.
static let shared = AppHealthObserver()

// MARK: - Properties

private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Flinky", category: "AppHealthObserver")

private var previousThermalState: ProcessInfo.ThermalState = ProcessInfo.processInfo.thermalState
private var previousAppState: UIApplication.State = .inactive
private var previousNetworkStatus: NWPath.Status?
private var previousNetworkInterface: String?

private let networkMonitor = NWPathMonitor()
private let networkQueue = DispatchQueue(label: "com.flinky.network-monitor")

private var isObserving = false

// MARK: - Initialization

private init() {}

// MARK: - Public Methods

/// Starts observing app health signals.
///
/// Call this method once during app initialization, typically in `FlinkyApp.init()`.
func startObserving() {
guard !isObserving else {
Self.logger.warning("AppHealthObserver is already observing")
return
}

isObserving = true
Self.logger.info("Starting app health observation")

setupThermalStateObserver()
setupNetworkReachabilityObserver()
setupAppStateObserver()
}

/// Stops observing app health signals.
///
/// Call this method during app cleanup if needed.
func stopObserving() {
guard isObserving else { return }

isObserving = false
Self.logger.info("Stopping app health observation")

// swiftlint:disable:next notification_center_detachment
NotificationCenter.default.removeObserver(self)
networkMonitor.cancel()
}

// MARK: - Thermal State Observation

private func setupThermalStateObserver() {
previousThermalState = ProcessInfo.processInfo.thermalState

NotificationCenter.default.addObserver(
self,
selector: #selector(handleThermalStateChange),
name: ProcessInfo.thermalStateDidChangeNotification,
object: nil
)

Self.logger.debug("Thermal state observer setup complete. Initial state: \(self.thermalStateString(self.previousThermalState))")
}

@objc private func handleThermalStateChange() {
let newState = ProcessInfo.processInfo.thermalState
let oldState = previousThermalState

guard newState != oldState else { return }

let fromString = thermalStateString(oldState)
let toString = thermalStateString(newState)
let isEscalation = newState.rawValue > oldState.rawValue

Self.logger.info("Thermal state changed: \(fromString) → \(toString) (escalation: \(isEscalation))")

// Track thermal state transition
SentryMetricsHelper.trackThermalStateTransition(
fromState: fromString,
toState: toString,
isEscalation: isEscalation
)

// Add breadcrumb for debugging context
let breadcrumb = Breadcrumb(level: isEscalation ? .warning : .info, category: "device_health")
breadcrumb.message = "Thermal state changed: \(fromString) → \(toString)"
breadcrumb.data = [
"from_state": fromString,
"to_state": toString,
"is_escalation": isEscalation
]
SentrySDK.addBreadcrumb(breadcrumb)

previousThermalState = newState
}

private func thermalStateString(_ state: ProcessInfo.ThermalState) -> String {
SentryMetricsHelper.thermalStateString(state)
}

// MARK: - Network Reachability Observation

private func setupNetworkReachabilityObserver() {
networkMonitor.pathUpdateHandler = { [weak self] path in
self?.handleNetworkPathUpdate(path)
}

networkMonitor.start(queue: networkQueue)

Self.logger.debug("Network reachability observer setup complete")
}

private func handleNetworkPathUpdate(_ path: NWPath) {
let status = path.status
let interfaceType = getInterfaceType(path)
let statusString = status == .satisfied ? "connected" : "disconnected"

// Only track changes, not initial state
guard previousNetworkStatus != nil else {
previousNetworkStatus = status
previousNetworkInterface = interfaceType
Self.logger.debug("Initial network state: \(statusString) via \(interfaceType)")
return
}

// Check if there's an actual change
let statusChanged = previousNetworkStatus != status
let interfaceChanged = previousNetworkInterface != interfaceType

guard statusChanged || interfaceChanged else { return }

Self.logger.info("Network changed: \(statusString) via \(interfaceType) (expensive: \(path.isExpensive), constrained: \(path.isConstrained))")

// Track network reachability change
SentryMetricsHelper.trackNetworkReachabilityChanged(
status: statusString,
interfaceType: interfaceType,
isExpensive: path.isExpensive,
isConstrained: path.isConstrained
)

// Add breadcrumb for debugging context
let breadcrumb = Breadcrumb(level: .info, category: "network")
breadcrumb.message = "Network changed: \(statusString) via \(interfaceType)"
breadcrumb.data = [
"status": statusString,
"interface": interfaceType,
"is_expensive": path.isExpensive,
"is_constrained": path.isConstrained
]
SentrySDK.addBreadcrumb(breadcrumb)

previousNetworkStatus = status
previousNetworkInterface = interfaceType
}

private func getInterfaceType(_ path: NWPath) -> String {
if path.usesInterfaceType(.wifi) {
return "wifi"
} else if path.usesInterfaceType(.cellular) {
return "cellular"
} else if path.usesInterfaceType(.wiredEthernet) {
return "wired"
} else if path.usesInterfaceType(.loopback) {
return "loopback"
} else {
return "other"
}
}

// MARK: - App State Observation

private func setupAppStateObserver() {
previousAppState = UIApplication.shared.applicationState

NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)

Self.logger.debug("App state observer setup complete. Initial state: \(self.appStateString(self.previousAppState))")
}

@objc private func handleAppDidBecomeActive() {
trackAppStateTransition(to: .active)
}

@objc private func handleAppWillResignActive() {
trackAppStateTransition(to: .inactive)
}

@objc private func handleAppDidEnterBackground() {
trackAppStateTransition(to: .background)
}

@objc private func handleAppWillEnterForeground() {
// This is called before didBecomeActive, we'll track the full transition there
Self.logger.debug("App will enter foreground")
}

private func trackAppStateTransition(to newState: UIApplication.State) {
let oldState = previousAppState

guard newState != oldState else { return }

let fromString = appStateString(oldState)
let toString = appStateString(newState)

Self.logger.info("App state changed: \(fromString) → \(toString)")

// Track app state transition
SentryMetricsHelper.trackAppStateTransition(toState: toString, fromState: fromString)

previousAppState = newState
}

private func appStateString(_ state: UIApplication.State) -> String {
SentryMetricsHelper.appStateString(state)
}

// MARK: - Cleanup

deinit {
stopObserving()
}
}
Comment on lines +1 to +275
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new AppHealthObserver service lacks test coverage. Since the repository has comprehensive testing for other services (e.g., QRCodeCacheTests, DataSeedingServiceTests), consider adding tests to verify:

  1. Singleton initialization
  2. Start/stop observing lifecycle
  3. Proper tracking of state transitions (avoiding duplicate metrics)
  4. Network path interface type detection
  5. Memory warning notification handling

This is particularly important for a service that automatically observes system-level notifications and could potentially send duplicate metrics.

Copilot uses AI. Check for mistakes.
20 changes: 4 additions & 16 deletions Targets/App/Sources/Services/DataSeedingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,14 @@ final class DataSeedingService {
seedingBreadcrumb.timestamp = Date()
SentrySDK.addBreadcrumb(seedingBreadcrumb)

// Capture seeding event for analytics
SentrySDK.capture(message: "Database seeding started") { scope in
scope.setLevel(.info)
scope.setTag(value: "database_seeding", key: "operation")
scope.setContext(
value: [
"action": "initial_seed",
"timestamp": ISO8601DateFormatter().string(from: Date())
], key: "seeding")
}
// Track seeding start using metrics - better for aggregate counts than individual events
SentryMetricsHelper.trackDatabaseSeedingStarted()

seedInitialData(modelContext: modelContext)
markDatabaseAsSeeded(modelContext: modelContext)

// Capture successful seeding completion
SentrySDK.capture(message: "Database seeding completed successfully") { scope in
scope.setLevel(.info)
scope.setTag(value: "database_seeding", key: "operation")
scope.setTag(value: "success", key: "status")
}
// Track seeding completion using metrics - better for aggregate counts than individual events
SentryMetricsHelper.trackDatabaseSeedingCompleted()
}

// MARK: - Private Implementation
Expand Down
Loading
Loading