Files
InvestmentTrackerApp/PortfolioJournal/Services/DiagnosticsCollector.swift
T
alexandrev-tibco 2380d37a9e sustituir Crashlytics por un recolector MetricKit que no sale del dispositivo
FirebaseCrashlytics se llevaba los informes de fallo a Google. Los crashes
ya llegan a Xcode Organizer por la vía de Apple, así que el sustituto no
tiene que enviar nada: DiagnosticsCollector se suscribe a MXMetricManager,
guarda los payloads de crash, cuelgue y escritura en disco como JSON en
Application Support/Diagnostics y ahí se quedan.

- Sin código de red. Retención de 5 informes, los ficheros se marcan como
  excluidos de backup (son ayuda de depuración, no datos del usuario).
- Arranque: AppDelegate llama a DiagnosticsCollector.shared.start() en
  lugar de FirebaseApp.configure() + MobileAds.shared.start().
- Ajustes → Acerca de: "Compartir diagnósticos", visible solo cuando hay
  algo recogido. Compartir es la única forma de que un informe salga del
  iPhone, y la decide el usuario. Cadenas nuevas en los 7 idiomas.

Un recolector que guarda y no enseña sería código muerto, y uno que sube
sería el problema de antes con otro nombre; por eso guarda en local y deja
el envío como acción explícita.

Closes #51

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:45 +02:00

122 lines
4.6 KiB
Swift

import Foundation
import MetricKit
import os
/// On-device replacement for Crashlytics.
///
/// `MXMetricManager` hands the app a daily payload with crash, hang and
/// disk-write diagnostics that Apple already collected for it. This collector
/// writes those payloads to the app container as JSON and **never sends them
/// anywhere** no network code, no third-party SDK, no identifiers. Crash
/// reports still reach Xcode Organizer through Apple's own pipeline, which is
/// the channel that was actually useful about Crashlytics.
///
/// The user can share the stored reports from Settings; that is the only way
/// one ever leaves the device, and it is an explicit action.
final class DiagnosticsCollector: NSObject {
static let shared = DiagnosticsCollector()
/// Reports older than this are pruned on every new payload.
private static let retentionCount = 5
private let logger = Logger(subsystem: AppConstants.bundleIdentifier, category: "diagnostics")
private let fileManager = FileManager.default
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private override init() {
super.init()
}
/// Subscribes to MetricKit. Safe to call more than once.
func start() {
MXMetricManager.shared.add(self)
}
// MARK: - Storage
/// `Application Support/Diagnostics`, created on demand.
private var directory: URL? {
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let url = base.appendingPathComponent("Diagnostics", isDirectory: true)
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
return url
}
/// Stored reports, newest first. Used by Settings to offer sharing them.
func storedReports() -> [URL] {
guard let directory else { return [] }
let contents = (try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey]
)) ?? []
return contents
.filter { $0.pathExtension == "json" }
.sorted { lhs, rhs in
let l = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let r = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return l > r
}
}
func deleteStoredReports() {
for url in storedReports() {
try? fileManager.removeItem(at: url)
}
}
private func write(_ data: Data, prefix: String) {
guard let directory else { return }
let name = "\(prefix)-\(dateFormatter.string(from: Date())).json"
let url = directory.appendingPathComponent(name)
do {
try data.write(to: url, options: .atomic)
// Diagnostics are debugging aids, not user data: keep them out of
// iCloud/iTunes backups.
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
var mutableURL = url
try? mutableURL.setResourceValues(resourceValues)
logger.info("Stored diagnostic report \(name, privacy: .public)")
} catch {
logger.error("Could not store diagnostic report: \(error.localizedDescription, privacy: .public)")
}
prune()
}
private func prune() {
let reports = storedReports()
guard reports.count > Self.retentionCount else { return }
for url in reports.dropFirst(Self.retentionCount) {
try? fileManager.removeItem(at: url)
}
}
}
// MARK: - MXMetricManagerSubscriber
extension DiagnosticsCollector: MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
write(payload.jsonRepresentation(), prefix: "metrics")
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
let crashes = payload.crashDiagnostics?.count ?? 0
let hangs = payload.hangDiagnostics?.count ?? 0
logger.info("Diagnostic payload: \(crashes, privacy: .public) crash(es), \(hangs, privacy: .public) hang(s)")
write(payload.jsonRepresentation(), prefix: "diagnostics")
}
}
}