Right, let me just propose some actual code and a way with which we could proceed with something that people could use today.
As @tomerd summarised, we have two options: explicit or implicit logger/context passing (which I initially called local and global loggers, but explicit v. implicit is better terminology). Whilst explicit context passing would solve all the logging context issues it's probably too invasive for the real world without language support. We also don't want to delay having a shared logging solution so I don't think it's useful to wait for either language or Dispatch support.
That does - as stated before - mean we can't have the correct logging context available in all places and we will need to live with that. We can however declare logging context to be a responsibility of the concrete logger implementation and have it do best effort. That would mean that Vapor and Kitura might use logging systems that work completely differently but crucially share an API. For synchronous systems, it could just use a thread-local. For asynchronous systems the context needs to come from elsewhere either passed around or hung off say the 'request' type. In both cases this will mean that the logging context isn't necessarily reliably available everywhere. Especially not in asynchronous systems that call out to external libraries which are not prepared to get a logger passed explicitly.
So let's start with some code:
/// The most important type, the one the users will actually interact with and for example write:
///
/// let myModuleLogger = LoggerFactory.make(label: "example.myproject.FooModule")
/// class MyClass {
/// func something() {
/// myModuleLogger.warn("something isn't a great function name")
/// }
/// }
public protocol Logger {
public init(identifier: String)
/// not called directly, only by the helper methods like `info(...)`
// EDIT2: Should change to `message: @autoclosure () -> String`
public func _log(level: LogLevel, message: String, file: String, function: String, line: UInt)
/// This adds diagnostic context to a place the concrete logger considers appropriate. Some loggers
/// might not support this feature at all.
public subscript(diagnosticKey diagnosticKey: String) -> String? { get set }
}
extension Logger {
// EDIT2: Should change to `message: @autoclosure () -> String`
public func info(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
self._log(level: .info, message: message, file: file, function: function, line: line)
}
// EDIT2: Should change to `message: @autoclosure () -> String`
public func warn(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
self._log(level: .warn, message: message, file: file, function: function, line: line)
}
// EDIT2: Should change to `message: @autoclosure () -> String`
public func error(_ message: String, file: String = #file, function: String = #function, line: UInt = #line) {
self._log(level: .error, message: message, file: file, function: function, line: line)
}
// lots of to bikeshed more _log methods
}
public enum LogLevel {
case info
case warn
case error
}
/// The second most important type, this is where users will get a logger from.
public enum LoggerFactory {
// this is used to create a logger for a certain unit which might be a module, file, class/struct, function, whatever works for the concrete application. Systems that pass the logger explicitly would not use this function.
public static func make(identifier: String) -> Logger {
return LoggerImplementation.loggerType.init()
}
}
/// Ships with the logging module, really boring just prints something using the `print` function
public struct StdoutLogger: Logger {
[...]
}
/// This is the place to select the concrete logger implementation globally. The selection of the concrete logger type
/// that is used globally should only be done by the application itself (and not by libraries).
///
/// LoggerImplementation.loggerType = MySuperFancyLogger.self
public enum LoggerImplementation {
private static var _loggerType: Logger.Type = StdoutLogger.self
public static var loggerType: Logger.Type {
get {
return self.lock.withLock {
return self._loggerType
}
}
set {
return self.lock.withLock {
self._loggerType = newValue
}
}
}
That way whenever someone needs a logger, they can just LoggerFactory.make(identifier: ...)
and have a logger which offers pretty much the convenience of a truly global and static Log.info("something")
. But asynchronous systems like Vapor could carry around a Logger
instance in their HTTPRequest
.
So in an asynchronous system like Vapor you might write
myRequest.logger.info("something")
and Vapor libraries might never actually use the LoggerFactory
as they always get the logger explicitly through the request.
To add that logging context, Vapor internally could run similar to this
currentRequest.logger[diagnosticKey: "request_id"] = UUID().description
By the virtue of hanging the logger of the HTTP request, Vapor could explicitly pass the logger around in its own libraries and to all external libraries that support it. Sure, any library that doesn't accept a logger will not have any logging context associated.
Synchronous systems could make their concrete logger implementation store the diagnostic context in a thread-local instead of the logger instance itself.
I should mention some tradeoffs I made here:
- only one
String
typed message, no fanciness like os_log
. Why? Because a) we can always extend that later b) this will not be the fastest implementation of a logger anyway we'll need to take locks, go through existentials etc to log c) this seems to mostly be what people use today
- no configuration of log-levels etc. This is all the responsibility of the concrete logger implementations
- a lot is down to the concrete implementation of a logger
- EDIT: I should point out that I didn't make the
message
@autoclosure
right now and there's good arguments it should be but that'd lead to an allocation as the concrete logger can't be inlined because it's not known at compile time... EDIT2: @Joe_Groff said that if an API relies on an efficient implementation @autoclosure
this could be prioritised; therefore I think we should use it here.
- it's the hybrid model described above
Pros:
- we can start with this today
- ease of use as the logger is reachable from anywhere
- can kind of support explicit logger passing where available (if one controls the whole project)
Cons:
- no reliable propagation of log contexts
- everything is
String
s
- any
Logger
implementation will likely take locks (even if nothing turns out to be logged) and do syscalls (only if something's logged)
- using a synchronous system's concrete logger (which might store the logging context in a thread-local) with an asynchronous system will lead to weird stuff and the logging context is propagated to random requests
To be clear: This is by no means complete or what I consider to be a perfect API or anything like that. I just wanted to propose some concrete code. To get slightly less theoretical I would ask anybody to come forward with features they consider to be absolutely necessary which cannot be supported by this API. I hope that through iteration we arrive at something that anybody can live with and we can start implementing that. How does that sound?