[Proposal] SLG-0006: task-local logger

Hi all,

The revised proposal SLG-0006: task-local logger for swift-log is now up and In Review.

The review period will run until Jun 10th β€” please feel free to post your feedback as a reply to this thread.

Thanks!

2 Likes

@kukushechkin I think this points to the wrong proposal (i see SLG-0004)

1 Like

@anreitersimon Oh, thank you, it was indeed a wrong link, fixed it.

1 Like

I really like this direction!

My only wish would be if this could use typed throws instead of rethrows.

I saw the note that this mirrors TaskLocal, but could it not be safe to just to just us TaskLocal s untyped version and then force cast the error?

Te typesystem would already guarantee that this is safe, would it not?

1 Like

Thanks for working on this!

This came out far better than I was worried about -- I was really concerned about each and every log statement hitting the lookup; but with just surfacing the .current and withLogger pattern I'm less worried about it now. The lookup is explicit in code and we know when we're paying for it and it is not "magically" summoned, but summoned exactly where we see we are doing so...

For what its worth your point here exactly addressed my concerns:

The bootstrapped branch invokes LoggingSystem.factory on every access. Logger.current is not meant to be a hot path outside of a withLogger scope β€” callers should wrap their entry point in withLogger(_:_:) and use the closure's logger parameter or a local let binding for repeated logging.

Having that said, there's a bit of polishing left here probably?

The default logger fallback

I'm a bit confused with wording in proposal vs impl:

Returns the logger bound by the nearest enclosing withLogger scope. If none has been set up, returns a fallback logger: the globally bootstrapped handler if LoggingSystem.bootstrap has been called, otherwise a silent SwiftLogNoOpLogHandler . The fallback logger uses the empty label "" β€” an empty-label line in production output is the deliberate diagnostic signal that no withLogger scope was set up before that emission, rather than a named-but-misleading placeholder.

That's not how the impl is right now, or I'm blind? We just delegate current to the task local, and the task local is:

    static var taskLocalLogger: Logger = Logger(label: "")

which would just initialize the value on the read, because "no value" -> return default, which would be the Logger(label: "") but that would look for the bootstrapped handler, and would NOT be the Noop one.

Is the impl just behind here and the intended default value here was a Noop handler logger?

static var taskLocalLogger: Logger = 
  Logger(label: "", factory: { label in SwiftLogNoOpLogHandler(label) })

I think the default noop is the right tradeoff here, rather than doing the stderr by default.

The "no-op" one-time warning also needs to be clarified a bit:

The no-op branch returns a cached logger and emits a one-time warning on stderr the first time it is taken so a user who to wrap their entry point in withLogger (or forgot to call LoggingSystem.bootstrap) doesn't see logs silently disappear with no diagnostic.

Do you mean once per process? I assume so but might want to be clear about that. One might assume this would be "once per location of log statement" or something like that... (could be an opt in mode though via env var?).

Typed throws

Yeah, just do the throw error as! Failure trick; The reasons for the stdlib lagging on typed throws adoption shouldn't be holding swift-log back here.

Impl tip: Metadata overloads

withLogger(mergingMetadata:) and withLogger(metadata:) are good/valid, but I do wonder if you can't get away with one overload... today we have two overloads one with the merging and another with the metadata:, then of course *2 because async/sync, and again +2 because the nonisolated(nonsending)...

How about we make one overload with both parameters and just say that if you do "metadata: [...], mergingMetadata: [...]" we:

  • take the metadata instead of current.metadata
  • still just apply the merging onto it

This way we save on the overloads without that much additional complexity?


Overall I think this will be fine in the end... It's opt-in and explicit enough in sources that I don't have a heart attach about it. I also am happy with not doing some weird combination of the two passing pattern, which was proposed some years ago which would cause log: Logger = .current which would be very badβ„’ because every single method call would then hit the task local lookup, regardless if you're using it or not.

Overall pretty happy with this, I wouldn't be opposed to landing this once we flesh out the minor details above

1 Like

Detecting that current Logger is Noop to avoid metadata creation

Additional question... The Logger.current is NON optional for convenience, this is good, always doing an ?.info(*) would be annoying however it does mean that we never know if a logger was actually set or if we'll noop...

You allude to this in

The fallback logger uses the empty label "" β€” an empty-label line in production output is the deliberate diagnostic signal that no withLogger scope was set up before that emission, rather than a named-but-misleading placeholder.

But that doesn't really feel sufficient... do we need to know that the current one "is definitely noop"?

Would it make sense to be able to ask if Logger.current.isNoop...? Because we don't have a way of doing if let logger = Logger.current but maybe you'd want to split out some multi-line heavier computation to prepare metadata, but there's no reason to do so if it is all going to be thrown at a Noop logger anyway.

Specific example would be something like:

if !Logger.current.isNoop {
  let complexMetadata = // many lines of preparing metadata I'm too lazy
  var log = Logger.current
  log.metadata = ...
  log.info(...)
}

Function for mergingMetadata?

Shouldn't we attempt to avoid materializing metadata when the logger will not end up using it?

Today the signature is:

public func withLogger<Result>(
    mergingMetadata metadata: Logger.Metadata,
// shouldn't this be:
//    mergingMetadata metadata: @autoclosure () -> Logger.Metadata?,
    _ operation: (Logger) throws -> Result
) rethrows -> Result

but that means we'd compute the logging metadata, even if we never use it:

withLogger(mergingMetadata: ["expensive": computeExpensiveMetadata()] { log in 
  log.info(...) // but level was trace, so we never needed to render the expensive

i.e., do we need the same pattern here as we do with log()?

    public func log(
        level: Logger.Level,
        _ message: @autoclosure () -> Logger.Message,
        error: @autoclosure () -> (any Error)? = nil,
        metadata: @autoclosure () -> Logger.Metadata? = nil,

It might be good to explore a bit more how much we can delay and avoid rendering metadata if we are not going to use it :thinking:

1 Like

Right, the proposal (the one in a separate PR) was not updated with the static var taskLocalLogger: Logger = Logger(label: ""), now it is updated. The expected behavior is to have the default task-local value created via the regular initializer using either the bootstrapped factory, or falling back to the (again, consistent initializer behavior) Stream logger. No NoOpLogHandler is explicitly constructed.

As of typed throws β€” yeah, will apply throw error as! Failure. I've updated both the proposal PR and the implementation.

Impl tip: Metadata overloads

I decided to go with a separate withLogger(mergingMetadata:) overload. The thinking behind this is most probably this will be the mostly used overload and it would benefit from simplicity of the implementation. Combining overloads into withLogger(logLevel:handler:metadata: mergingMetadata:) with some runtime check of metadata and mergingMetadata mutual exclusivity makes it unnecessary complicated. One is "quickly add more metadata", another is "ok, let's make a different scoped logger".

Shouldn't we attempt to avoid materializing metadata when the logger will not end up using it?

Yeah, good idea, changed.

1 Like

Thanks for the updates, this looks good.

I'm a bit worried about the no-label default logger... I guess it's not "wrong" and not much harm in it though, so maybe it's fine.

I think empty is better than any specific static fallback label (like "task-local logger") and it is better than some process identifier β€” we already know what is the process from other sources.

Labels become a lesser feature when using task-local loggers β€” there is no label mutation API in Logger atm (worth being in the future directions?) and we cannot create a completely new one because there is no "task-local logger factory" (worth being in the future directions?). So everything using Logger.current is going to use the same label. Of course, nothing prevents the user from explicitly constructing a new logger with whatever factory, and binding it.

Friend, just use Point Free's excellent Dependencies package. It does exactly what you propose: GitHub - pointfreeco/swift-dependencies: A dependency management library inspired by SwiftUI's "environment." Β· GitHub

There is no reason to make this a language feature though.

Look you have many options, a global singleton logger, a static logger service, these two are identical in practice to task-local for those (likely) majority if apps who don't use concurrency (or barely do).

Saying there is no third option is just incorrect.

1 Like

@vitamin This is a valid approach, and applications are free to do it (or simply declare a task-local Logger explicitly). Where it gets tricky is aligning libraries with applications across the ecosystem. If a library wants to benefit from a common logger without adding it to its API surface (as outlined in the Motivation section), it would have to depend on that extra package, effectively imposing that choice on the application. But the dependency mechanism is an application-level decision β€” an app may prefer a different approach, which leads to fragmentation or extra solutions to bridge them.

The benefit of having this mechanism in swift-log is discoverability and better composability. Applications can keep using whatever dependency management they prefer and still use the .withLogger(myDeps.logger) {} API to pass loggers into libraries that neither want to adopt the same dependency mechanism, nor expose an explicit logger parameter in their API.

That said, this is a great point, I'll add it to the Alternatives Considered section!

1 Like
  1. A task-local logger in the stdlib is effectively ambient global state, and pushing it into the language/concurrency runtime is a heavy way to solve a library-coordination problem.
  2. swift-log already addresses your concern without language changes:

The split between API and backend. swift-log defines a small Logger API that libraries code against. Crucially, the actual log handling (where logs go, formatting, filtering) is provided by a separate LogHandler backend that the application chooses and bootstraps once at startup via LoggingSystem.bootstrap(_:). Libraries import only the API; they never pick a backend.

  • A library depends on swift-log and emits logs. It imposes only the minimal logging API on consumers, not a logging implementation or a DI framework.
  • The app decides the backend β€” swift-log-console, a JSON handler, an OSLog bridge, something custom β€” and bootstraps it globally. Every library's logs flow through that one choice.
  • So library A and library B both depending on swift-log don't fragment, because they share the same neutral API and the app unifies them at the backend. There's no "A picked Dependencies, B picked swift-log, now bridge them" problem.

On the task-local part specifically. swift-log also supports passing metadata and contextual loggers. The common pattern is to thread a Logger through explicitly, but you can also carry one in task-local storage yourself if you want something like request-scoped or view-scoped contextβ€”without the language providing it ambiently.

For example, check out Vapor (excellent Swift server-side network client). They do exactly this: a per-request @TaskLocal logger with accumulated metadata, propagated through the request's task tree, using the shared logger from the app.

Or if you wanna do it in your SwiftUI views, try this:

import Logging
import SwiftUI

public extension Logger {
    @TaskLocal public static var current = Logger(label: "app")
}

Now each view can just use .task modifier to:


struct ProfileView: View {
    let userID: String

    var body: some View {
        VStack {
            Text("Profile")
        }
        .task {
            // Establish a contextual logger for everything this view's task does.
            var logger = Logger.current
            logger[metadataKey: "screen"] = "profile"
            logger[metadataKey: "user-id"] = "\(userID)"

            await Logger.$current.withValue(logger) {
                await loadProfile()
            }
        }
    }

    func loadProfile() async {
        Logger.current.info("loading profile")   // carries screen + user-id
        await fetchAvatar()
    }

    func fetchAvatar() async {
        Logger.current.info("fetching avatar")   // same metadata, no threading
    }
}

... which is no more verbose than it would be under your proposal, (aside from the three-line extension declaration).

@vitamin thank you for the great example. In this particular case all is sound. Let's expand it with a 3rd party library:

import AvatarFetching  // this comes as a dependency in Package.swift, provides AvatarFetcher

...

func fetchAvatar() async {
    Logger.current.info("fetching avatar")
    await AvatarFetcher.fetch()  // no explicit logger in the API
}

What are the options for the AvatarFetching developer?

import Logging

public struct AvatarFetcher {
    public static fetch() async {
        // I want a logger here, where do I get it from?
        Logger.current.debug("Begin fetching...")
    }
}

AvatarFetching library already depends on the swift-log, but it cannot rely on that example application's Logger.current extension, because not every application depending on the AvatarFetching declares it. Neither it can declare it separately, because it will potentially conflict and is not really discoverable, becoming a part of the library API.

AvatarFetching can add logger to the fetch(logger: Logger) β€” this is a valid approach available right now. But not every library want to pollute the API or change the API is logger is not there. Libraries providing extensions to symbols they do not own, might not even be able to add explicit logger parameter anywhere. AvatarFetching can declare a new Logger instance, but this would not inherit any metadata from the call site.

For all these cases Logger.current declared in the swift-log provides an easy discoverable API shared between all kinds of packages already using swift-log.

You found the one gap in my example: a library that wants metadata-inheriting logs without an API change β€” especially one vending extensions on foreign types where it can't add a logger: parameter to. I grant you this.

But notice what you're now describing. It isn't "a task-local logger." It's a shared, writable, ambient context slot, owned by swift-log, that any library may read and any caller β€” including transitive dependencies nobody audited β€” may rebind. That's the global-state concern I opened with, not a rebuttal to it. A logger is benign; the pattern is "ambient mutable context flowing implicitly across library boundaries," which is exactly what task-locals were scoped to keep explicit and per-declaration.

And the ecosystem already worked through this. In the swift-log MetadataProvider RFC, the maintainers debated precisely where the shared slot should live. They were uneasy about putting swift-log in that slot, as they wanted to keep it dependency-free and as thin as possible.

Tomer Doron argued the logging API should stay narrowly focused and pushed for handler-level awareness instead; he noted swift-log is meant to be low-level and zero-dependency, used in plenty of contexts that have nothing to do with tracing, and suggested that if a context type must exist it belongs lower down β€” possibly the standard library β€” since it's useful well beyond logging. That's a maintainer arguing to keep the logger out of the ambient slot, and standardize a lower-level context primitive that can supply metadata as needed.

Which is what shipped: ServiceContext.

The app binds metadata at the boundary to the context, and swift-log folds it in through a bootstrapped MetadataProvider. The library's untouched logger.debug(...) comes out with the context attached β€” no extra API surface, no shared logger symbol, no signature change. The slot both sides reference already exists; it's ServiceContext.current, not a logger.

You're right that this doesn't escape app setup β€” every mechanism needs the app to configure the boundary, yours included, so "don't impose on the app" cuts both ways.

Your proposal's one genuine edge is that since your library already depends on swift-log, it would get the metadata context "for free." However it means that now, swift-log would have to depend on swift-service-context, which reverses the RFC decision to make swift-log dependency-free, and at the cost of loading ambient-context awareness onto the core logging API that the team deliberately kept out of there.

So I'm arguing to keep the universal logging package zero-dependency and tracing-agnostic.

The team chose "libraries wanting ambient context take one small extra import" (swift-service-context) over "every swift-log consumer inherits an ambient mutable metadata logger whether they do tracing or not."

Your Logger.current is a second, redundant slot on top of one already designed for exactly this.

EDIT: As to why global state is bad, let me ask you this: under your proposal can tests assume a clean logger from Logger.current? Or does it read from whatever binding is in scope, falling back to a default? In a test, "in scope" can be murky: did a previous test leave a binding? Did the test harness set one? Is the default the real default or something a dependency bootstrapped? These are all real scenarios that have impacted my iOS/iPadOS development life...

@vitamin this proposal does not suggest adding swift-service-context or any other dependency, the alternatives section explicitly outlines using ServiceContext to propagate arbitrary metadata is the wrong move. MetadataProvider approach is also different from the performance point of view β€” you pay runtime cost of merging metadata on every log emission, comparing to merging once on a new scoped logger construction and them paying only the task-local access (there are some benchmarks in the implementation PR).

Another important difference from getting just the metadata through a ServiceContext-aware MetadataProvider is .withLogger(logLevel:handler:metadata:) β€” it allows changing mutable parts of the logger in a scope. There are several use-cases for this, for example constructing an in-memory scoped logger and export logs from it to the remote backend only if, say, the operation in scope failed.

Testing with task-local logger is similar in spirit to testing API with the explicit logger: param, but with tests running in separate closures:

@Test func testAvatarFetch() {
    let testTogger = MyTestLogger()
    await withLogger(testTogger) async { _ in
        await AvatarFetcher.fetch()  // bound to testLogger constructed in this test
    }
}

@Test func testAvatarUpload() {
    let testTogger = MyTestLogger()
    await withLogger(testTogger) async { _ in
        await AvatarFetcher.upload()  // bound to testLogger constructed in this test
    }
}

A bit verbose, but does the job. I can imagine a macro wrapping a function in such a construct automatically, attaching function arguments to the metadata, but it is out of scope of this proposal.

Apologies, when I clicked your link it just took me to a short paragraph, I didn't see the much more detailed proposal in the repo tab there.

That relieves my concerns about ServiceContext. Per-module isolation does defeat the cross-cutting propagation that's its entire goal, and you're right there's no added dependency since MetadataProvider already inverts the context read.

But I would like you to address the following points.

Tests:

Testing with task-local logger is similar in spirit to testing API with the explicit logger: param, but with tests running in separate closures:

I meant integration/UI/end-to-end tests with a live config against a QA environment on a release build with third party dependencies. It's the last stop before millions of customers in prod. Having those tests be flaky or non-deterministic is my concern.

Reliance on Discipline with Shared State

The proposal states:

Second, the leak case is disciplinable. The only way library code propagates its own metadata downstream through Logger.current is by calling withLogger(mergingMetadata:) or withLogger(metadata:). The documented contract is that those overloads are application-side APIs: libraries read Logger.current and use per-statement metadata: or a local copy for their own context; they do not push. Per-module isolation would make the leak impossible by construction even when discipline fails but it would also fragment cross-cutting metadata propagation (request.id accumulated at the application layer would not appear on library log lines without an explicit bridge from one task-local to another) β€” which is the propagation we most want by default.

A few comments/questions:

  1. Disciplinable by whom? Genuine question. See my discussion on security below.
  2. The reason we have access control, sealed classes, etc. is because documented contracts aren't reliable. Mixing a reliance on good behavior with shared state sets off a lot of my red flags, frankly.
  3. We can agree that isolation makes the bad behavior impossible, but it should be the rule not the exception. A general principle of Swift is that if you're going to do unsafe things that violate isolation then it should never be implicit. I feel like your proposal wants to compromise on that for the sake of the convenience not to have to write extra lines into APIs and call sites. It sees to prioritize developer ergonomics and convenience over what I'd argue are more important priorities in the big picture.
  4. What is the safeguard against an upstream library rebinding the handler?
  5. What is there to prevent this kind of situation?
// some BlueToothLib somewhere...
func pollRSSI(_ device: Device) async {
    while connected {
        Logger.current.debug("RSSI \(device.rssi)") 
        try? await Task.sleep(for: .milliseconds(10))
    }
}

... where an appβ€”correctly using a binding overload, no discipline broken anywhere?β€”bound logLevel: .trace for a scope that happens to enclose this poll, maybe to debug something unrelated. What is to stop this suddenly firing hundreds of times a second through the app's real handler? What will happen to every downstream library's debug/trace logging?
6. Can a library accidentally bind a .critical level or a /dev/null handler and silence a callee?

Security

  1. Does this change widen the attack surface and possibility space for things like, log injection, log masking, DoL/DoS, disk space exhaustion via spamming, user info leakage, etc.? Recalling:
Focus
Available for: macOS Sequoia
Impact: An app may be able to access sensitive user data
Description: A logging issue was addressed with improved data redaction.
CVE-2026-20668: Kirin (@Pwnrin)
  1. While a CWE-117-safe handler helps deal with certain attacks, my structural question is, doesn't this ambient design mean every handler in the ecosystem must now be injection-safe? Can any handler perhaps receive attacker-tainted metadata injected by some upstream layer?

Thanks. This phase is when to think critically about these proposals, thanks for being responsive about it.

By the application either by scoping sensitive metadata or by cleaning downstream logger metadata.

No. On the contrary, libraries using task-local logger gives applications full control over logs in the entire process.

Hi all,

The review period for SLG-0006: task-local logger is now over. As there were no major objections raised, I'm marking the proposal as Ready for Implementation. :tada:

Thank you!

2 Likes

Congrats, thanks for responding to the comments. You did a good job satisfying all of mine.

I do have one question (this is not an objection to the proposal, just a use case question): is the TaskLocal logger considered nonisolated (i.e. could there be dataraces if multiple things try mutate its metadata dict or similar)? Or am I missing something that was addressed already by the proposal (or not understanding isolation)? Thanks