[Proposal] SLG-0003: Standardized Error Metadata via Logger Convenience

I’m happy to adjust the proposal, if we can agree on what changes to make. If I read all comments correctly, I think we agree to remove recordError, and change any Error to some Error.

However, the change to passing Error to LogHandler seems to be a discussion that some people are clearly for, and others have strong reservations for. I think there are good arguments on both sides (convenience/ability to customize, vs. non-leaky abstraction and prevent indirect dependencies between logging code to LogHandlers).

Excluding it from this proposal was suggested in Add option to pass Errors to log functions by samuelmurray · Pull Request #384 · apple/swift-log · GitHub , and I thought it was a very reasonable way to move forward constructively. Even if we were to pass the Error as-is, we would need to provide some fall-back implementation so as to not risk passed Errors being ignored/dropped completely. So, independently of if we do it in one go, or in two steps (or only implement this proposal and never end up doing the next step), we need the metadata serialization, or some equivalent backwards compatible default. Also, I think that if we did implement this proposal, and let some time pass, we might be more informed on the potential benefits of extending it.

The review period ended several days ago, and I’m not sure what the next step is. Should we do a new pass without recordError and any Errorsome Error? If so, what should I do concretely, make a new PR to swift-log where I modify the .md-file directly?

2 Likes

Agreed. I'll let @kukushechkin drive the next steps here, but my suggestion would be to make the two updates you proposed and extend the review period, clarifying that we're specifically evaluating this proposal as-is.

I think this proposal is a strict improvement with little downside, so it has a +1 from me.

The second proposal should come later separately, to avoid coupling multiple things that can be delivered incrementally.

1 Like

Since it doesn't seem we're aligned about some important bits of the proposal implementation I took some time to formulate the problems that make the "partial" implementation attempt here not acceptable. I'm in favor of the proposal, but we must land it in the correct way, without locking down evolution and causing performance issues.

The way we designed swift logging hinges on two aspects, Logger must :

  • a) not have performance penalties which log handlers cannot workaround; i.e. "logic" must live in LogHandler, never in Logger.
  • b) not be too-opinionated to the point that prevents log handlers from achieving their goals, which we might not even know about in detail e.g. sampling, throttling, forwarding errors to other 3rd party APIs etc.

Performance

The idea to serialize the error into strings "early" is flawed and cannot work performantly, id can only be seen as backwards compatibility until LogHandlers are updates to adopt the new LogHandler.log() method with the new :error: (some Error)?) parameter.

This is because while we may attempt to implement the serialization "lazy" in Logger layer:

    @inlinable
    public func traceAnyError(
        _ message: @autoclosure () -> Logger.Message,
        error: (any Error)? = nil,
        metadata: @autoclosure () -> Logger.Metadata? = nil,
        source: @autoclosure () -> String? = nil,
        file: String = #fileID,
        function: String = #function,
        line: UInt = #line
    ) {
        #if !MaxLogLevelDebug && !MaxLogLevelInfo && !MaxLogLevelNotice && !MaxLogLevelWarning && !MaxLogLevelError && !MaxLogLevelCritical && !MaxLogLevelNone
        self._log(
            level: .trace,
            message(),
            metadata: {
                var combinedMetadata = metadata() ?? [:]
                if let error = error {
                    combinedMetadata["error"] = "\(error)"
                    combinedMetadata["error.type"] = "\(String(reflecting: type(of: error)))"
                }
                return combinedMetadata
            }(),
            source: source(),
            file: file,
            function: function,
            line: line
        )
        #endif
    }

You might think this is fine because we're being lazy in metadata:, this is not entirely true however. We just lost the ability for the log handler to sample log statements. This can be a real technique employed especially at debug or trace levels, and this implementation approach prevents it entirely, because forwarding to LogHandler.log materializes the metadata:

    @inlinable
    package func _log(
        level: Logger.Level,
        _ message: @autoclosure () -> Logger.Message,
        metadata: @autoclosure () -> Logger.Metadata? = nil,
        source: @autoclosure () -> String? = nil,
        file: String = #fileID,
        function: String = #function,
        line: UInt = #line
    ) {
        if self.logLevel <= level {
            self.handler.log(
                level: level,
                message: message(),
                metadata: metadata(), <<<<<
                source: source() ?? Logger.currentModule(fileID: (file)),
                file: file,
                function: function,
                line: line
            )
        }
    }

Which is forced to form the metadata:

// LogHandler
    func log(
        level: Logger.Level,
        message: Logger.Message,
        metadata: Logger.Metadata?, <<<<
        source: String,
        file: String,
        function: String,
        line: UInt
    )

IMHO this is unacceptable to degrade the performance of the NoOp handler like this (and it falls out of the design principles discussed below -- the Logger must not perform "logic").

This means it is impossible for a log handler to avoid rendering the strings for the error. Even if your target is emitting backpressure and you must drop trace logs for example, you're still causing the cost of the error rendering. The difference is significant, compare passing an error and not passing one (this is to a NoOpLogger!):

In comparison, the complete approach forwarding the error and only serializing it in the LogHandler:

// LogHandler
    func log(
        level: Logger.Level,
        message: Logger.Message,
        metadata: Logger.Metadata?,
        error: (some Error)?,
        source: String,
        file: String,
        function: String,
        line: UInt
    )
// Logging
    @inlinable
    package func _log(
        level: Logger.Level,
        _ message: @autoclosure () -> Logger.Message,
        metadata: @autoclosure () -> Logger.Metadata? = nil,
        source: @autoclosure () -> String? = nil,
        error: (some Error)?,
        file: String = #fileID,
        function: String = #function,
        line: UInt = #line
    ) {
        if self.logLevel <= level {
            self.handler.log(
                level: level,
                message: message(),
                metadata: metadata(),
                error: error,
                source: source() ?? Logger.currentModule(fileID: (file)),
                file: file,
                function: function,
                line: line
            )
        }
    }

If the log handler were to actually print (and therefore materialize the error -> strings), then it'll get the performance hit and get down to the 300k/s, that's fine. But it's up to the log handler to make that decision or not. Again, there may be factors swift-log does not, and cannot, know about, at play here so we must leave these decisions to LogHandler. None of those decisions are possible if we string-ify-too-early.

some Error vs any Error

From the previous benchmark we can observe the difference in behavior. The implicit existential opening, when we:

do { try ... } catch { logger.info(error: error) }

is actually quite costly, and gives the worst performance of all the cases in the "pass any/some Error all the way to LogHandler" benchmarks (last line):

Passing any Error generally has roughly the same performance because when it is thrown, it already is existential, so there's no "re-packing".

The best of course is the "some Error passing + an concrete (typed throw)" however I kinda think it is less common for typed throws to exist rather than the untyped throws -- that's just the reallity and direction of Swift as a whole. Typed throws are going to be more rare than untyped throws, so looking at these numbers I guess we may want to proceed with (any Error)? in all the parameters...

The with_nil benchmark isn't passing explicitly but just loggerPassAny.errorPassAnyError("Operation completed") by the way, so this 10M there confirms we've not regressed the "not passing an error" case for NoOpLogger.

Composition

swift-log must compose well with other libraries, and the fact is that Error is the part of the Swift's lingua franca, and APIs express error passing as throwing or passing errors.

Doing some weird trickery with stringifying early because "someone might maybe check the error" is unreasonable and a baffling design choice. Error is the currency type.

This matters for composition; it is entirely reasonable and correct for 3rd party logging libraries to expect to be passed an Error which they then serialize however they want. It's up to them to e.g. use "error.kind": ... or `"error.type": ..." -- again, this is entirely out of scope for swift-log and is a backend decision. LogHandlers and/or 3rd party libraries handle this decision, NOT swift-log. (with the exception of the StdOutLogHandler which we're shipping, yeah there we'll just pick something -- like the proposal proposes here).

Examples of APIs which accept Error and we want to forward to:

I don't see why swift-log should be in the business of making any of these libraries life's hader. It does not matter that all of them will just to-string the error, some of them may check NSError or something else, but either way, passing Error IS the right design for the Swift language and ecosystem.

Swift Log Design Principles

A general design principle in such libraries is to "lose" information as late as possible.
Another way to look at this is that LogHandler is the logic and Logger must not have logic in it because it is too early in the chain to make any decisions correctly.

Therefore losing all information immediately and taking the serialization hit in the Logger layer is not acceptable.

It's entirely up to log handlers to determine how to handle a log() call, they may employ sampling or be rate throttled by an API where they're emitting logs or whatnot. swift-log cannot know these things, and therefore must remain open to log handlers making these decisions.

Hardcoding serializing into specific keys means APIs may have to "re-shuffle" those values into new keys, or even construct some "FakeError" that pretends to be the original error. This also prevents future evolution, if Error ever were to gain new protocol requirements that we could rely on -- we did in fact talk about errors with backtraces after all at some point -- it is entirely reasonable for log handlers to be aware of such protocols.

I'm wholly opposed to saying "error.type" are "API", for reasons of fear that someone might inspect an error. So what? They already can smuggle anything in metadata because we explicitly provided this escape hatch ages ago: Metadata's CustomStringConvertible is exactly such escape hatch of "pass anything" that you're so worried about, so this doesn't change a thing. The "hole" exists, by design, and was not abused by any log handler in the pas.

--

Long story short, I hope this comprehensive writeup helped folks understand that just serializing the errors into some dictionary key at the Logger level is not acceptable and can only be an temporary step towards LogHandlers adopting a new log() which also accepts the passed Error.

Accepting "half" this idea and making strings "public API" is not sufficient and would cause problems for the ecosystem.

--

I'm supportive of the proposal, however it has to be "both" parts:

  • we add the error parameters on the Logger level
  • we add a log() which accepts a new Error parameter
    • we provide a compatibility shim if the new method was not implemented; To be discussed: if we serialize the error into metadata there or not, IMHO, given my current performance findings we do not -- and you only get the new error information once your LogHandler updates to it.

You can find all prototypes and benchmarks used in the writeup here: GitHub - ktoso/swift-log at experiments-error I did use claude to help slap together the boilerplate here, but I've verified the important parts of the code executed and how/when what happens to the error.

8 Likes

Regarding the performance and sampling point - it's a good one, but how is it unique to this error proposal? It seems to highlight the fact that Logger materializes all metadata before handing it over to LogHandler, which has always been the case, so it was never possible to do zero-cost sampling in LogHandler.

Considering this was always the case in Swift Log, why would we suddenly draw the line today and say that materializing error metadata is a step too far? When, based on my reading, it's exactly in line with how Logger and LogHandler split responsibilities today: Logger APIs help you produce metadata (making it lazy until it checks the log level, but not after), LogHandler consumes materialized metadata (but cannot un-pay the cost of materializing metadata).

I agree the performance cliff for metadata exists, but it has always existed, and this proposal is orthogonal to that question. Any expensive-to-materialize piece of metadata has the same exact problem, so to me it doesn't seem a good reason to block this proposal for it (unless we're saying we'll poke a hole in LogHandler for every expensive piece of metadata).

Error is a core part of the language so it is very natural to make an addition to support it better. I’ve also explained other reasons in depth above, like decisions where and how to record it.

I think we all agree we want to improve the support for Error, thus our engagament on this proposal.

I just reacted to using performance as the reason to dismiss one approach, when that performance problem appears to me completely orthogonal to this proposal.

Thanks for bringing up the performance aspect to this. I think it is important but I am not sure I am following your analysis fully. As @Honza_Dvorsky stated in his reply Logger is already materializing all metadata before handing it to the LogHandler since the LogHandler is not accepting an auto closure but a metadata dictionary [String: MetadataValue].

The current split is in responsibilities is:

  • Logger checks if a log statement should happen based on the log level considering both the new compile time levels and the log level of the logger. If the log level is appropriate the logger materializes both the message and the metadata
  • LogHandler is responsible for applying any dynamic log handling such as sampling or dynamic log levels. Additionally, log handlers are expected to unify the three different metadata sources (loggers, log statement and metadata provider)

From your benchmarks and the code you mentioned I am assuming we are seeing a slow down due to the merging of metadata and not due to materialization per-se. I agree though that the effect of this proposal is forcing the logger to merge the error information into the message metadata and most log handlers will do another set of merging. @ktoso is this the cause for the performance difference you are seeing?

Well kinda, the cost isn't "the merge", merging empty dictionaries isn't the cost -- the cost is the materialization forced by the merge.

However since Error is so important in the language, it warrants the special treatment -- and for all the other reasons my write up explained as well. Making a bunch of strings "API" is not a good design and I stand by that, I've not seen any real arguments against doing the right thing, carrying errors, so far.

1 Like

After further offline discussions, it seems most folks are okay with the fact that LogHandler implementations will end up dynamically casting into various error types to extract information and the implicit coupling it'll create, so I withdraw my objections, to allow us to move forward.

Hopefully we can collectively agree to push back against any libraries and log handlers creating any explicit coupling (e.g. libraries recommending specific log handlers in their README), to preserve the great benefit of Swift Log that every library works with every log handler - it'd be a shame to lose that.

Thanks for indulging me :slightly_smiling_face:

3 Likes

Thanks Honza and everyone.

Yeah, definitely we want all handlers and libraries to work with each other so that's just some discipline to keep in mind. I'm not concerned about this becoming a real problem but yeah, we can definitely nudge about it in readme's etc. :+1:

1 Like

Thank you all for the discussion!

@samuelmurray, thank you for pushing this topic. As the step forward, please, make a PR to swift-log with a new revision of the proposal, extending it with Error support in the LogHandler and a default implementation providing the metadata serialization for LogHandler not (yet) supporting Error. Please, also, remove .recordError from the proposal scope, add it to the future directions and implement when swift-log has a better approach to scoped loggers.

After we have the revised proposal, we extend the duration of review.

4 Likes

Thinking about this more, we might not even need a new way to smuggle the error to the log handler and we could avoid having to deprecate/replace the main log method on LogHandler:

As was already touched on in this thread, we already have a way to pass in an arbitrary value: through the .stringConvertible case in a metadata value. That way, the only new API we'd need to add would be:

  1. The proposed methods on Logger
  2. A constant for the "error" metadata key

That way, the log handlers that haven't been updated (or don't need special handling) will see it in metadata. The log handlers that do want to handle is specially extract it from metadata, from the well-known metadata key and dynamically cast just like proposed.

The upside? We don't need to touch LogHandler at all, yet log handler implementations can still extract everything they need from the error.

Otherwise we might need to rev LogHandler multiple times in a short period of time, see SLG-0004: Metadata privacy levels proposal by kukushechkin · Pull Request #416 · apple/swift-log · GitHub for anther one.

Edit: actually that'll only work for error types that are CustomStringConvertible, so might not be a full replacement.

I think “abusing” the string based metadata value for this isn’t right. While I am sympathetic to not wanting to update the log handler requirement passing through the error is giving log handlers a lot more flexibility than expecting them to reconstruct information from a string.

Having said that I do think we need to come up with a better way to evolve the LogHandler interface. It might be time to create a new struct type e.g. LogRecord that contains all the current information passed as individual parameters and we can evolve over time.

1 Like

I agree with the LogRecord.

Just to clarify my earlier point, I wasn't proposing log handlers would reconstruct errors from a string. It'd be the .stringConvertible enum case, which contains the original value.

LogRecord just on the "inside" side between Logger and LogHandler sounds good, makes evolution a bit easier in the future.

6 Likes

I will try to take some time this weekend to update the proposal. Did we settle on keeping “any Error” as per the performance benchmark, or are we still in favour of switching to “some Error”?

I think the any is fine for errors specifically. This is because errors are generally passed as existentials anyway so if we do some we incur the opening penalty. Review manager @kukushechkin can confirm though

1 Like

FYI: The PR to adjust the proposal is now opened: Update proposal SLG-0003 after public review by samuelmurray · Pull Request #419 · apple/swift-log · GitHub

2 Likes

@samuelmurray thank you for updating the proposal. any Error is fine, do-catch produces any Error. Let's give it another week of formal review until the 9th of March. Let's keep the PR open in case there any comments.

2 Likes

During the first review, there was some concern about the fallback/default handling. Something I just thought about is that we could put this behind a trait, DefaultErrorSerialization. This would let users opt out of this behaviour, e.g. in performance sensitive applications where logs are sampled and unnecessary serializations of errors should be avoided.