Igor Kulman

Logging error messages from assert and fatalerror

· Igor Kulman

I often use fatalerror(message:) in my code base to deal with invalid states when the application cannot continue. A typical example can be a method that requires to be called only after the user has logged in:

guard let loggedUser = dataStore.user else {
	fatalerror("Invalid use before signup is complete")
}

The problem is that the fatalerror message does not appear in the crash log. You can of course take a look at the whole stack trace to figure out where the fatalerror originated but seeing the message in the logs yout get from your uses immediately would be much better.

I use PLCrashReporter to store crash logs locally so users can export them from the application together with all the logs.

I tried logging the message every time before calling fatalerror

guard let loggedUser = dataStore.user else {
	Log.error?.message("Invalid use before signup is complete")
	fatalerror("Invalid use before signup is complete")
}

but this is really not ideal, it is just writing boilerplate code you can easily forget.

I have not found a way to directly log the fatalerror message, so I created my own fail method

func fail(_ logMessage: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
    let formattedMessage = formatLogMessage(logMessage, file: file, function: function, line: line)
    Log.error?.message(formattedMessage)
    fatalError(formattedMessage, file: file, line: line)
}

You can format the message you log any way you want, I just log the filename, function name and line number. Getting the filename from a StaticString is a bit tricky though

func formatLogMessage(_ logString: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) -> String {
    let filename = (file.withUTF8Buffer {
        String(decoding: $0, as: UTF8.self)
    } as NSString).lastPathComponent
    return "[\(filename):\(line) \(function)]: \(logString)"
}

Instead of calling fatalerror(message:) I now call fail(message:) instead in all the places it is needed and the message is always logged.

As a downside if you have debugger attached it stops in the actual fatalerror call not on the fail method call, so you need to move one method up in the stack trace to see the actual place your application failed.

For me it is worth it, I am much more interested in the error messages in the logs than this.

In my code base I also define a failDebug(message:) method with the same code just replacing fatalerror(message:) with assertionFailure(message:).

func failDebug(_ logMessage: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
    let formattedMessage = formatLogMessage(logMessage, file: file, function: function, line: line)
    Log.error?.message(formattedMessage)
    assertionFailure(formattedMessage, file: file, line: line)
}

You can also add a notImplemented method in a similar way

func notImplemented(file: StaticString = #file, function: StaticString = #function, line: UInt = #line) -> Never {
    fail("Method not implemented.", file: file, function: function, line: line)
}

or a custom assertDebug method with a condition

func assertDebug(_ condition: @autoclosure () -> Bool, _ logMessage: String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) {
    #if DEBUG
    if !condition() {
        failDebug(logMessage, file: file, function: function, line: line)
    }
    #endif
}

See also