Files
letro-ios/ElementX/Sources/Other/Logging/MXLogger.swift
Doug edbef54243 Improved bug report error handling (#1018)
* Improve rageshake error handling.

* Add a max file size and exclude older files one the limit is hit.

* Zip log files as smaller chunks.
2023-06-06 11:24:01 +03:00

336 lines
13 KiB
Swift

//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
/// The `MXLogger` tool redirects NSLog output into a fixed pool of files.
/// Another log file is used every time `MXLogger redirectNSLog(toFiles: true)`
/// is called. The pool contains 3 files.
///
/// `MXLogger` can track and log uncaught exceptions or crashes.
class MXLogger {
/// stderr so it can be restored.
static var stderrSave: Int32 = 0
private enum Constants {
/// The filename used for the crash log.
static let crashLogFileName = "crash.log"
}
/// Redirect NSLog output to MXLogger files.
///
/// It is advised to condition this redirection in `#if (!isatty(STDERR_FILENO))` block to enable
/// it only when the device is not attached to the debugger.
///
/// - Parameters:
/// - redirectToFiles: `true` to enable the redirection.
/// - maxLogFileCount: number of files to keep (default is 10).
/// - logFileSizeLimit: size limit of log files in bytes. 0 means no limitation, the default value for other methods
static func configure(redirectToFiles: Bool,
maxLogFileCount: UInt,
logFileSizeLimit: UInt) {
if redirectToFiles {
var tempLog = ""
// Do a circular buffer based on X files
for index in (0...(maxLogFileCount - 2)).reversed() {
rotateLog(at: index, tempLog: &tempLog)
}
// Save stderr so it can be restored.
stderrSave = dup(STDERR_FILENO)
let nsLogURL = logURL(for: "console\(subLogName).log")
freopen((nsLogURL as NSURL).fileSystemRepresentation, "w+", stderr)
MXLog.info("redirectNSLogToFiles: true")
if !tempLog.isEmpty {
// We can now log into files
MXLog.info(tempLog)
}
removeExtraFiles(from: maxLogFileCount)
if logFileSizeLimit > 0 {
removeFiles(after: logFileSizeLimit)
}
} else if stderrSave > 0 {
// Flush before restoring stderr
fflush(stderr)
// Now restore stderr, so new output goes to console.
dup2(stderrSave, STDERR_FILENO)
close(stderrSave)
}
}
private static func rotateLog(at index: UInt, tempLog: inout String) {
let fileManager = FileManager.default
let currentURL: URL
let newURL: URL
if index == 0 {
currentURL = logURL(for: String("console\(subLogName).log"))
newURL = logURL(for: String("console\(subLogName).1.log"))
} else {
currentURL = logURL(for: String("console\(subLogName).\(index).log"))
newURL = logURL(for: String("console\(subLogName).\(index + 1).log"))
}
guard fileManager.fileExists(atPath: currentURL.path()) else { return }
if fileManager.fileExists(atPath: newURL.path()) {
// Temp log
tempLog.append("removeItemAt: \(newURL)\n")
do {
try fileManager.removeItem(at: newURL)
} catch {
tempLog.append("removeItemAt: \(newURL). Error: \(error)\n")
}
}
// Temp log
tempLog.append("moveItemAt: \(currentURL) to: \(newURL)\n")
do {
try fileManager.moveItem(at: currentURL, to: newURL)
} catch {
tempLog.append("moveItemAt: \(currentURL) to: \(newURL). Error: \(error)\n")
}
}
private static func logURL(for fileName: String) -> URL {
MXLogger.logsFolderURL.appending(path: fileName)
}
/// Delete all log files.
static func deleteLogFiles() {
let fileManager = FileManager.default
for logFileURL in logFiles {
try? fileManager.removeItem(at: logFileURL)
}
}
/// The list of all log file URLs, sorted chronologically.
static var logFiles: [URL] {
var logFiles = [(url: URL, modificationDate: Date)]()
let fileManager = FileManager.default
let enumerator = fileManager.enumerator(at: logsFolderURL, includingPropertiesForKeys: [.contentModificationDateKey])
// Find all *.log files and their modification dates.
while let logURL = enumerator?.nextObject() as? URL {
guard let resourceValues = try? logURL.resourceValues(forKeys: [.contentModificationDateKey]),
let modificationDate = resourceValues.contentModificationDate
else { continue }
if logURL.pathExtension == "log" {
logFiles.append((logURL, modificationDate))
}
}
let sortedFiles = logFiles.sorted { $0.modificationDate > $1.modificationDate }.map(\.url)
MXLog.info("logFiles: \(sortedFiles.map(\.lastPathComponent))")
return sortedFiles
}
// MARK: - Exceptions and crashes
/// Exceptions uncaught by try catch block are handled here
static func handleUncaughtException(_ exception: NSException) {
MXLogger.logCrashes(false)
// Extract running app information
let app = InfoPlistReader.main.bundleExecutable
let appId = InfoPlistReader.main.bundleIdentifier
let appVersion = "\(InfoPlistReader.main.bundleShortVersionString) (r\(InfoPlistReader.main.bundleVersion))"
// Build the crash log
let model = UIDevice.current.model
let version = UIDevice.current.systemVersion
let backtrace = exception.callStackSymbols
let description = String(format: "%.0f - %@\n%@\nApplication: %@ (%@)\nApplication version: %@\nBuild: %@\n%@ %@\n\nMain thread: %@\n%@\n",
Date.now.timeIntervalSince1970,
NSDate(),
exception.description,
app, appId,
appVersion,
buildVersion ?? "Unknown",
model, version,
Thread.isMainThread ? "true" : "false",
backtrace)
// Write to the crash log file
MXLogger.deleteCrashLog()
let crashLog = crashLogURL
try? description.write(to: crashLog, atomically: false, encoding: .utf8)
MXLog.error("handleUncaughtException", context: ["description": description])
}
// Signals emitted by the app are handled here
private static func handleSignal(_ signalValue: Int32) {
// Throw a custom Objective-C exception
// The Objective-C runtime will then be able to build a readable call stack in handleUncaughtException
withVaList([signalValue]) { NSException.raise(.init("Signal detected"), format: "Signal detected: %d", arguments: $0) }
}
/// Make `MXLogger` catch and log unmanaged exceptions or application crashes.
///
/// When such error happens, `MXLogger` stores the application stack trace into a file
/// just before the application leaves. The path of this file is provided by `MXLogger.crashLog`.
///
/// - Parameter enabled: `true` to enable the catch.
static func logCrashes(_ enabled: Bool) {
if enabled {
// Handle not managed exceptions by ourselves
NSSetUncaughtExceptionHandler { exception in
MXLogger.handleUncaughtException(exception)
}
// Register signal event (seg fault & cie)
signal(SIGABRT) { MXLogger.handleSignal($0) }
signal(SIGILL) { MXLogger.handleSignal($0) }
signal(SIGSEGV) { MXLogger.handleSignal($0) }
signal(SIGFPE) { MXLogger.handleSignal($0) }
signal(SIGBUS) { MXLogger.handleSignal($0) }
signal(SIGABRT) { MXLogger.handleSignal($0) }
} else {
// Disable crash handling
NSSetUncaughtExceptionHandler(nil)
signal(SIGABRT, SIG_DFL)
signal(SIGILL, SIG_DFL)
signal(SIGSEGV, SIG_DFL)
signal(SIGFPE, SIG_DFL)
signal(SIGBUS, SIG_DFL)
}
}
/// Set the app build version.
/// It will be reported in crash report.
static var buildVersion: String?
/// Set a sub name for namespacing log files.
///
/// A sub name must be set when running from an app extension because extensions can
/// run in parallel to the app.
/// It must be called before `redirectNSLog(toFiles)`.
///
/// - Parameter name: the subname for log files. Files will be named as `console-[subLogName].log`
/// Default is nil.
static func setSubLogName(_ name: String) {
if name.isEmpty {
subLogName = ""
} else {
subLogName = "-\(name)"
}
}
private static var subLogName = ""
/// The URL used for a crash log file.
static var crashLogURL: URL {
MXLogger.logsFolderURL.appending(path: Constants.crashLogFileName)
}
/// The URL of the file containing the last application crash if one exists or `nil` if there is none.
///
/// Only one crash log is stored at a time. The best moment for the app to handle it is the
/// at its next startup.
static var crashLog: URL? {
let crashLogURL = MXLogger.crashLogURL
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: crashLogURL.path()) else { return nil }
return crashLogURL
}
/// Delete the crash log file.
static func deleteCrashLog() {
let crashLog = MXLogger.crashLogURL
let fileManager = FileManager.default
if fileManager.fileExists(atPath: crashLog.path()) {
try? fileManager.removeItem(at: crashLog)
}
}
// MARK: - Private
/// The folder where logs are stored
private static var logsFolderURL: URL {
.appGroupContainerDirectory
}
/// If `self.redirectNSLog(toFiles:numberOfFiles:)` is called with a lower numberOfFiles we need to do some cleanup.
private static func removeExtraFiles(from count: UInt) {
let fileManager = FileManager.default
for index in count... {
let fileName = "console\(subLogName).\(index).log"
let logFile = logURL(for: fileName)
if fileManager.fileExists(atPath: logFile.path()) {
try? fileManager.removeItem(at: logFile)
MXLog.info("removeExtraFilesFromCount: \(count). removeItemAt: \(logFile)\n")
} else {
break
}
}
}
/// If `redirectNSLog(toFiles:sizeLimit:)` is called with a size limit, we may need to do some cleanup.
private static func removeFiles(after sizeLimit: UInt) {
var logSize: UInt = 0
var indexExceedingSizeLimit: Int?
let fileManager = FileManager.default
// Start from console.1.log. Do not consider console.log. It should be almost empty
for index in 1... {
let fileName = "console\(subLogName).\(index).log"
let logFile = logURL(for: fileName)
if fileManager.fileExists(atPath: logFile.path()) {
if let attributes = try? fileManager.attributesOfItem(atPath: logFile.path()), let fileSize = attributes[.size] as? UInt {
logSize += fileSize
}
if logSize >= sizeLimit {
indexExceedingSizeLimit = index
break
}
} else {
break
}
}
let logSizeString = logSize.formatted(.byteCount(style: .binary))
let sizeLimitString = sizeLimit.formatted(.byteCount(style: .binary))
if let indexExceedingSizeLimit {
MXLog.info("removeFilesAfterSizeLimit: Remove files from index \(indexExceedingSizeLimit) because logs are too large (\(logSizeString) for a limit of \(sizeLimitString)\n")
removeExtraFiles(from: UInt(indexExceedingSizeLimit))
} else {
MXLog.info("removeFilesAfterSizeLimit: No need: \(logSizeString) for a limit of \(sizeLimitString)\n")
}
}
}