Thanks for raising that question @IanPartridge. I had a brief look into the 4 projects you mentioned. If I see this correctly, all packages allow the concrete logger type to be configured by the virtue of having either a
protocol Logger with multiple implementations or having a log registry.
For changing the loggers/log level these four libraries seem to be in two totally different camps:
- global logger: IBM-Swift's LoggerAPI and SwiftyBeaver seem to use a global log configuration.
- local logger: Vapor's Logging and Perfect's Log require you to manually pass around the
Logger existential to where you want to log through some mechanism (for example passing it as a parameter)
So I think we need to settle on which model we want.
Below I'll just brain dump my thoughts about those models:
the two models
I totally made up the terminology local logger and global logger, if there's some better and well-known word please let me know.
EDIT: Thanks to feedback of @Ponyboy47 I want to add that it's technically feasible to use a hybrid approach too.
I define something to be a global logger if the logging configuration is accessible globally in the program (usually through
static logging functions). The logging configuration is at least 1) the concrete logging mechanism (are we logging to file, stdout/err, database, whatever) 2) the log level.
Pros of the global logger:
- it's easy, just
Log.info("hello world") from anywhere in the program
- (not really a pro but still) the decision whether to log or not can be made without a virtual call (vtable / protocol witness table) which is great but then we need a lock to protect the state (unless the log-level is constant at runtime) which is much worse than a vtable call.
Cons of the global logger:
- changing the log level for only a certain subset of requests seems hard/impossible as the way to change the log level is to globally modify the logger
- having some payload (for example a request UUID) that gets always logged (and is distinct per request) is hard/impossible
- synchronisation (ie. locks) are needed whenever something is attempted to log, even if nothing is logged, we will need to acquire a lock for the state of the logger (at least to get concrete logger & the log level). Interestingly, neither IBM's LoggerAPI nor SwiftyBeaver seem to actually synchronise the memory access if I see this correctly. That means that you will trigger undefined behaviour if your program logs from more than one thread and you do any log configuration changes at runtime after spawning any thread which is doing any logging whatsoever. That means in reality both SwiftyBeaver and LoggerAPI do not allow to change any logging configuration at runtime. In LoggerAPI's case we have
public static var logger: Logger? which is unsynchronised and in SwiftyBeaver's case it's
public private(set) static var destinations = Set<BaseDestination>()
EDIT Thanks to feedback of @neallester I want to add that testing the logging system using a global logger is problematic, with parallel test runs pretty much impossible.
Local loggers store the logging configuration locally, for example through a parameter that is the
Pros of the local loggers:
- synchronisation is not an issue because the logger object itself holds the state
- trivial to implement things that have a different log-level per request
- very easy to implement some extra state that gets always logged (such as a request ID)
EDIT Thanks to feedback of @neallester I want to add that testing the logging system using local loggers is very straightforward, even with multiple tests run in parallel.
Cons of the local loggers:
- some very invasive mechanism to pass the actual logger is needed. Some well-known mechanisms are for example 1) passing the logger as an explicit function parameter to any function that wants to log or calls a function that wants to log 2) putting the logger in a 'context' and passing the context around explicitly 3) storing the logger in thread-locals. In Swift I can personally only see (1) and (2) really working (happy to expand on why) which means handing either the logger or some form of context which contains the logger through to every single place that wants to log
Ideally, we'd want logging to be completely free at runtime if there are no log messages issued. Short of having a constant logging configuration (ie. can't be changed at run time) completely free is pretty much impossible. Apple's
os_log has pretty static configuration and uses pretty amazing tricks to reduce the overhead to pretty much zero. But it seems that for the server-world we need to be more dynamic and need to offer more features so we just won't get run time free I believe.
So if not free, we need to figure out how much overhead is fine? A long time ago I asked on
swift-dev what people think about 'Zero-cost "Service Provider Interface"/Signature Packages' which is sort of relevant here. Today I think maybe some overhead is okay and I wanted to list out what overhead logging solutions that I have seen in Swift do occur at runtime so we can decide what's acceptable:
- synchronisation overhead (usually for global loggers only)
- reference counting overhead
- protocol existential dispatch overhead (through virtual calls)
And I think people usually want to degrees of freedom at runtime:
a) changing the logging mechanism (file, stdout/err, database)
b) changing the logging level at runtime
My personal opinion (today) is that probably incurring reference counting (2) and existential dispatch (3) overhead is fine but synchronisation (1) probably is not. The benefit is that we'll get full freedom at runtime.
Please let me know if I misread some of the code. This was quite literally a 10 min analysis of the four loggers that @IanPartridge mentioned and I thought I should write down my initial thoughts. Also I obviously haven't touched on the API itself, like how many log levels, naming etc. And I also realise that there's a lot of stuff in my post that might not be clear enough. Please ask if anything's unclear I'm very happy to expand on anything.