loguru icon indicating copy to clipboard operation
loguru copied to clipboard

feature request: add capability to attach logger context to exceptions

Open breathe opened this issue 6 months ago • 6 comments

Suppose a program is organized with a top-level Exception handler that logs the error (somewhere). It would be nice if the exception raised could include loguru contextual information associated with the site where the exception was raised -- or if that's not feasible, then would like to explicitly attach context to the exception at some some mid-level site in the call-stack between the leaf of the computation where the exception was raised and the root where its logged.

Was trying to write a function like this -- but couldn't find a way to retrieve the logger context from the logger instance ...

def attach_loguru_context(exc):
    context = dict(logger._core.context)
    exc._loguru_context = context
    return exc

breathe avatar May 09 '25 18:05 breathe

Looks like I might be able to do this

def attach_loguru_context(exc):
    from loguru._logger import context
    context = dict(context.get())
    exc._loguru_context = context
    return exc

breathe avatar May 09 '25 18:05 breathe

Hum, to be honest I'm not entirely convinced that linking Loguru's context to an arbitrary exception is very relevant. However, perhaps I am missing an example that would help me understand your use case better. Usually, the logger should only be responsible for capturing and reporting the error raised downstream.

That being said, you would be free to implement such a pattern if Loguru exposed the context. Until now, it has been hidden by design. Perhaps this will change soon for other reasons, but I haven't decided yet.

Delgan avatar Jul 27 '25 18:07 Delgan

That being said, you'd be free to implement such a pattern if Loguru exposed the context. Until now, it has been intentionally hidden. That may change soon for other reasons, but I haven’t decided yet.

I believe I’m reaching into implementation details to access Loguru’s context — I’d love to have an officially supported way to do this. The code I currently use is shown below.

On the use case

In our codebase, we generally avoid using exceptions for control flow. Most exceptions are treated as non-recoverable errors and are allowed to propagate to the top of the program (or the top of the connection context, in server code). From there, they're reported centrally and either terminate the program or the specific connection.

As I’m sure we agree, an exception with a stack trace is a highly effective way to capture and report bugs, since it provides rich debugging information about what went wrong and where.

However, some important information from the call stack is still lost — specifically the loggerr contextual context wherever an exception escapes a with logger.contextualize() block. If (as it quite common) an error is only reported from some high-level component of the call stack, the logger context present at the point of failure is no longer available. That means the error log won't reflect the contextual information that was active when the exception was raised -- even though that information is particularly useful for interpreting and diagnosing the error.

We go to great lengths in our codebase to preserve and propagate async/logging context accurately. Loguru’s log contextualization is a key part of our ability to trace, verify, and reason about async task flows. One of our goals is to make log searchability as straightforward as possible: simply wrapping code in logger.contextualize(...) ensures that all logs emitted from within that block carry the appropriate metadata. This makes it easy to extract logs relevant to a specific context.

Except in the case of error logs — unless we use tooling like the kind proposed in this PR. If the error handler lives outside the scope of the a logger.contextualize() block, then the log it emits won’t include the context that was active when the error occurred.

To make this concrete, imagine a codebase where each analysis step runs within a logger.contextualize(step=i) block. If step 4 raises an exception, and that exception is caught and logged outside the contextualize block, then the log message won’t include the step=4 context. Here’s a simplified version of the pattern:

def do_step(i):
    if i == 44:
        raise Exception("Some bug happened")

try:
    for device in devices:
        for i in steps:
            with logger.contextualize(device=device, step=i):
                do_step(i)
except:
    # Catch and log the error, but don’t crash
    logger.opt(exception=True).error("Failure occurred")

In our app, Loguru logs are formatted structurally in production (and in a pretty human-readable format when running locally). Contextual information is printed with each log message. Here's what I want to achieve:

2025-07-27 19:51:47.978 UTC | INFO     | breathe:MainProcess:AnyIO worker thread | /Users/ncohen/software/zephr/zephyrus/foo:221: do_step | zephyrus.foo
Failure occurred
... stack trace ...
{
  "device": "some_device",
  "step": 44
}

The goal is for the "Failure occurred" log message to reflect the async context that was active at the point where the exception was raised — so it’s included in any search for logs with device == "some_device".


Hum, to be honest I'm not entirely convinced that linking Loguru's context to an arbitrary exception is very relevant. However, perhaps I’m missing a more compelling example. Usually, the logger is responsible for capturing and reporting the error from downstream, not upstream.

That being said, you would be free to implement such a pattern if Loguru exposed the context. Until now, it has been hidden by design. Perhaps this will change soon for other reasons, but I haven't decided yet.


Here's what I’ve been using

The code I use doesn’t capture the async context where the exception was raised — but instead captures the context active at the point where the exception raises from an appropriately decorated function. If I structure my code to place @ensure_exceptions_contain_loguru_context on the right boundaries, I can still achieve what I want more or less:

def retrieve_loguru_context():
    from loguru._logger import context as loguru_context
    return dict(loguru_context.get())


def ensure_exceptions_contain_loguru_context(func):
    """
    Decorator that wraps a function, and if it raises an exception,
    attaches the current Loguru context to it before re-raising.
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except StopIteration:
            raise  # Let StopIteration pass through unchanged
        except Exception as e:
            if not hasattr(e, "_loguru_context"):
                e._loguru_context = retrieve_loguru_context()  # type: ignore
            raise

    return wrapper

But personally, I think it would make sense for Loguru to track the logger.contextualize() stack frames that an exception raises through and expose a way to retrieve the context that was active from within the deepest logger.contextualize() block that the exception escaped from. Ideally, this wouldn’t involve mutating the exception (as I do above), but instead might use a weakref-based mapping from exceptions to the captured logging context info -- this would allow any code with access to the exception to ask Loguru for the async/logging context active when it was raised and should ensure that the loguru contextual context memory is also all cleaned up when the exception lifetime ends

breathe avatar Jul 27 '25 20:07 breathe

Hi @breathe. Thanks for the detailed explanations and for providing concrete use cases.

I now have a clearer appreciation of your suggestion. After thinking it through, I have to say I tend to agree with you. It does seem like a very reasonable request. The question boils down to: how to preserve the logging context at the time the exception occurs? I would have usually said this kind of issue is best handled by a specialized tool like Sentry, but there's definitely a case to be made for integrating it directly into Loguru.

Actually, this question comes up recurrently in different frameworks and languages. A few examples:

Now, technically, this isn't exactly straightforward. When the exception is caught at the top level, the context manager has already exited, so the ContextVar instance has been cleaned up. As you said, contextualize() needs to store the context at the time of the error and somehow restore it later.

I haven't fully made up my mind yet, but I might have an idea. I think it could make sense to add this capability to logger.catch(). I could maybe arrange for the message emitted by logger.catch() to preserve the context from where the exception it captured was raised. It would be less flexible than exposing the context directly, but it would stay within the boundaries of logging and avoid introducing a new API, which I really like.

I'll need to think about it a bit more and compare it with how this is handled in other languages and frameworks, but it's definitely a feature I'll seriously consider.

Delgan avatar Aug 06 '25 21:08 Delgan

Awesome I appreciate the detailed thoughts. Would be happy to help any way I can!

contextualize() needs to store the context at the time of the error and somehow restore it later.

Just spit-balling on implementation here -- is it not possible to do this inside of contextualize()? Seems like that would be the minimal and most transparent api... contextualize should be able to observe when an exception is raised 'through it' shouldn't it?

The way that I'm thinking about this is as sort of three scenarios (two of which nothing needs to happen):

scenario 0: an exception is raised within a contextualize() block and caught and reported inside that block. This is the simplest/easiest case -- and loguru currently does the right thing here as the logger context still contains all the information in this case

scenario 1: an exception 'escapes the first' contextualize() block. This is exiting the deepest / highest-information context and represents the point in program execution at which the context needs to be captured (somehow)

scenario 2: an exception escapes a contextualize() block higher in the call-stack after already escaping a deeper one. We should do nothing -- the context captured the first time we escaped during scenario 1 is the deepest/fullest/best context for this exception

Looking at the implemention of contextualize(). I was imagining the implementation would look something like this ...

        with __self._core.lock:
            new_context = {**context.get(), **kwargs}
            token = context.set(new_context)

        try:
            yield
        except Exception as e:
            ensure_context_is_associated_with_exception(e)
            raise
        except* Exception as e:
            ensure_context_is_associated_with_exception(e)
            raise
        finally:
            with __self._core.lock:
                context.reset(token)

ensure_context_is_associated_with_exception would then be a bit hard to write / design the api for ... I was thinking it could capture the context into a weakmap keyed by the exception instance -- and some additional api to retrieve the captured context from that map when providing an exception instance.

caveat 1: I'm not 100% sure the identity of exception's ... I think it would be easy for there to be loss of context in re-raise scenarios with many possible implementations of ensure_context_is_associated_with_exception ... I'm not sure there a completely lossless way to solve unless there's someway in python to say 'give me something tied to the lifetime and identity of the deepest originating exception associated with this exception object' -- which would then make it relatively straightforward (maybe)?

caveat 2: I put the except* case in the above there but I'm not totally sure that would make sense ... I use a structured concurrency library called anyio in my current dayjob -- to be honest I'm still figuring a bit what makes sense in terms of how to reason about async except and except*. I don't think I quite understand the api design needs fully -- but I've started trying to 'be aware' of ExceptionGroup more -- I wrote in the except* clause as a thought exercise in case separate handling of ExceptionGroup's might be warranted

breathe avatar Aug 10 '25 02:08 breathe

Just spit-balling on implementation here -- is it not possible to do this inside of contextualize()? Seems like that would be the minimal and most transparent api... contextualize should be able to observe when an exception is raised 'through it' shouldn't it?

Yes, sorry, poor wording on my part. I do expect this feature to be implemented inside the contextualize() context manager, I don't think there is any other way around.

Thanks for sharing your thoughts on the technical aspects of this feature. I like the idea of using a weak reference, but the issue is that Python's built-in exceptions are not weak-referencable... Not sure yet how to handle that problem. 😕

Then, of course as you stated, there are different edge cases to tackle (nesting, re-raised exceptions, chained exceptions with different contexts), but surely we can settle on something that would be both useful and coherent for the most common cases.

Delgan avatar Aug 30 '25 13:08 Delgan