Posts

CrashReportExtension en iOS 27: diagnosticar un fallo desde otro proceso

Arturo Rivas Arias

Cuando una app termina inesperadamente, el código encargado de explicar qué ha pasado tiene un problema: puede estar ejecutándose dentro del mismo proceso que acaba de fallar. Si hay corrupción de memoria o recursos bloqueados, recopilar información deja de ser una operación sencilla. CrashReportExtension, disponible desde iOS 27, permite trasladar ese trabajo a una extensión que se ejecuta en otro proceso. Apple documenta este modelo en la presentación del framework.

La separación es la novedad más interesante. La app que ha fallado pasa a ser el objeto de análisis y la extensión se encarga de inspeccionarla. Esto permite organizar el diagnóstico con tipos de Swift y APIs de Foundation sin ejecutar toda esa lógica dentro de un manager en el proceso afectado. Aun así, conviene mantener la extensión sin mucha complejidad: recibir un fallo no debería implicar inicializar todas las librerías y servicios de la aplicación.

Xcode incorpora la plantilla Crash Report Extension para crear el target correspondiente. Su identificador debe derivar del de la app: por ejemplo, com.example.TrainingJournal.CrashReporter para una aplicación cuyo identificador sea com.example.TrainingJournal. La plantilla configura el punto de extensión com.apple.crash-reporter.extension. En iOS, necesitas la versión 27 o posterior; Apple excluye expresamente las apps Mac Catalyst y las apps de iOS ejecutadas en Mac con Apple silicon.

El punto de entrada es el protocolo CrashReporterExtension. El sistema invoca processCrashReport(process:) y entrega un CrashedProcess con acceso a información del fallo. El método es síncrono: su firma no incluye async. Por tanto, lanzar una Task y regresar inmediatamente no proporciona, por sí solo, una garantía de que el trabajo termine antes de que finalice la ejecución de la extensión.

La propiedad reason contiene un CrashReason: exception identifica el tipo de excepción Mach y codes aporta los códigos asociados. No estamos recibiendo el Error que lanzaríamos desde una función con throws. Son datos del fallo a un nivel inferior. Conviene conservar sus valores originales y realizar después cualquier clasificación, evitando perder detalles al reducir todos los casos a una etiqueta genérica como podría ser «error de memoria».

CrashedProcess también expone binaryImages, la lista de ejecutables y librerías cargados. Además, ofrece operaciones para buscar símbolos y convertir direcciones en información simbólica. Estas operaciones no equivalen a recibir automáticamente la traza completa del hilo que falló: la API documentada no proporciona una colección lista para usar con todas las llamadas de la pila.

Hay otro matiz importante: symbolicateAddress(_:) devuelve un array porque una dirección puede corresponder a varias llamadas tras expandir funciones que el compilador ha integrado en otras funciones. Ese resultado no representa toda la cadena. Y buscar por nombre una función conocida solo demuestra que podemos localizar su símbolo; no demuestra que participase en la cadena de fallo.

Para una primera integración, mi propuesta es guardar un pequeño informe y procesarlo cuando la app vuelva a abrirse. Imagina un diario de entrenamiento que termina inesperadamente mientras importa una sesión. La extensión puede persistir los datos básicos de la excepción; la app principal se ocupará después de autenticar la petición, enviarla al servidor y gestionar los reintentos. Es una decisión de arquitectura, no una obligación del framework: Apple permite persistir el informe o enviarlo a un servidor propio.

Para compartir ese archivo, hay que configurar el mismo App Group en ambos targets. Escribir en el directorio privado de la extensión no hace que el archivo aparezca en el contenedor de la aplicación. Este ejemplo utiliza group.com.example.TrainingJournal, que debes sustituir por un identificador registrado y habilitado en tu proyecto:

import CrashReportExtension
import Foundation
import OSLog

private struct TrainingIncident: Encodable {
    let schemaVersion: Int
    let recordedAt: Date
    let machException: Int32
    let exceptionCodes: [UInt64]
}

@main
struct TrainingCrashReporter: CrashReporterExtension {
    func processCrashReport(process: CrashedProcess) {
        let logger = Logger(
            subsystem: "com.example.TrainingJournal.CrashReporter",
            category: "Persistence"
        )

        do {
            let manager = FileManager.default
            guard let container = manager.containerURL(
                forSecurityApplicationGroupIdentifier:
                    "group.com.example.TrainingJournal"
            ) else {
                logger.error("El contenedor compartido no está disponible")
                return
            }

            let directory = container.appendingPathComponent(
                "PendingIncidents", isDirectory: true
            )
            try manager.createDirectory(
                at: directory,
                withIntermediateDirectories: true
            )

            let incident = TrainingIncident(
                schemaVersion: 1,
                recordedAt: Date(),
                machException: process.reason.exception,
                exceptionCodes: process.reason.codes
            )
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            let data = try encoder.encode(incident)
            let destination = directory.appendingPathComponent(
                "\(UUID().uuidString).json"
            )
            try data.write(to: destination, options: .atomic)
        } catch {
            logger.error("No se pudo guardar el informe del fallo")
        }
    }
}

El ejemplo guarda un registro mínimo, no un informe equivalente al del sistema. recordedAt indica cuándo se construye el registro, y el UUID evita reutilizar siempre el mismo nombre de archivo. La escritura atómica reduce el riesgo de que la aplicación lea un JSON escrito parcialmente, pero no garantiza que cualquier escritura tenga éxito. La app deberá leer esa carpeta compartida, limitar la acumulación de informes y eliminar cada archivo únicamente cuando haya completado su procesamiento.

Para saber si el usuario estaba importando una sesión o calculando estadísticas, diseñaría un registro de operaciones durante la ejecución normal. Bastaría con un identificador de sesión y una fase, sin guardar datos personales innecesarios. Ese contexto debe permitir detectar registros antiguos: encontrar «importación iniciada» en un archivo no prueba que la importación causase el fallo. La extensión tampoco recibe automáticamente los modelos de SwiftUI ni el estado de los objetos de la app.

Si necesitas investigar con más detalle el fallo, corpsePort permite a la extensión consultar el estado que el sistema conserva del proceso terminado. Ese acceso se realiza mediante un puerto Mach, una referencia gestionada por el sistema operativo que permite inspeccionar el proceso sin modificarlo. Con APIs de bajo nivel puedes, por ejemplo, enumerar sus hilos mediante task_threads o leer su memoria con mach_vm_read. Para analizar esa información posteriormente en un servidor, la extensión debe extraer y guardar los datos que necesite. Guardar únicamente el identificador numérico del puerto en un JSON no sirve: ese número solo tiene significado dentro del proceso de la extensión y no permite al servidor acceder al proceso que falló.

Los informes del sistema siguen siendo esenciales. Para obtener nombres de funciones, archivos y líneas en la simbolicación habitual, conserva los dSYM correspondientes a cada compilación, como explica Apple en su guía de simbolicación. La existencia de un método que resuelve direcciones dentro de la extensión no es motivo para dejar de archivar esos símbolos.

Tampoco desaparece el papel de MetricKit, que sirve para obtener métricas de rendimiento y diagnósticos capturados por el sistema. Son necesidades diferentes: observar el comportamiento de la aplicación y ejecutar un análisis propio sobre un proceso que ha fallado. Antes de incorporar otra extensión, conviene identificar qué información concreta falta en el diagnóstico actual y cómo se utilizará para corregir errores.

Para validar la integración, separaría la codificación y la persistencia del punto de entrada, comprobaría sus errores de forma independiente y provocaría un fallo controlado únicamente en una compilación de desarrollo. Después verificaría el archivo desde la app relanzada y contrastaría sus datos con el informe del sistema. El valor de CrashReportExtension está en permitir ese diagnóstico específico desde un proceso separado, manteniendo explícitas tanto la información recogida como las responsabilidades de cada componente.