Repository: hynek/structlog

Issue:
FilteringBoundLogger Async methods - capture context outside of executor thread
Taking the new async methods on `FilteringBoundLogger` for a spin, I noticed context captured with `bind_contextvars()` is not logged.

Narrowed it down to this pattern:

```python
        return await asyncio.get_running_loop().run_in_executor(
            None,
            lambda: contextvars.copy_context().run(
                lambda: self._proxy_to_logger(name, event % args, **kw)
            ),
        )
```

When `contextvars.copy_context()` is called it is in in the executor thread, so different context.

To fix, just capture the context outside of the lambda:

```python
        ctx = contextvars.copy_context()
        return await asyncio.get_running_loop().run_in_executor(
            None,
            lambda: ctx.run(
                lambda: self._proxy_to_logger(name, event % args, **kw)
            ),
        )
```

I'd be happy to PR.

Cheers!

Comments:

Patch:
diff --git a/src/structlog/_log_levels.py b/src/structlog/_log_levels.py
index 997d948..501c44a 100644
--- a/src/structlog/_log_levels.py
+++ b/src/structlog/_log_levels.py
@@ -88,11 +88,10 @@ def exception(self: FilteringBoundLogger, event: str, **kw: Any) -> Any:
 async def aexception(self: FilteringBoundLogger, event: str, **kw: Any) -> Any:
     kw.setdefault("exc_info", True)
 
+    ctx = contextvars.copy_context()
     return await asyncio.get_running_loop().run_in_executor(
         None,
-        lambda: contextvars.copy_context().run(
-            lambda: self.error(event, **kw)
-        ),
+        lambda: ctx.run(lambda: self.error(event, **kw)),
     )
 
 
@@ -157,9 +156,10 @@ def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:
             return self._proxy_to_logger(name, event % args, **kw)
 
         async def ameth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
+            ctx = contextvars.copy_context()
             await asyncio.get_running_loop().run_in_executor(
                 None,
-                lambda: contextvars.copy_context().run(
+                lambda: ctx.run(
                     lambda: self._proxy_to_logger(name, event % args, **kw)
                 ),
             )
@@ -183,9 +183,10 @@ def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:
             return None
         name = _LEVEL_TO_NAME[level]
 
+        ctx = contextvars.copy_context()
         return await asyncio.get_running_loop().run_in_executor(
             None,
-            lambda: contextvars.copy_context().run(
+            lambda: ctx.run(
                 lambda: self._proxy_to_logger(name, event % args, **kw)
             ),
         )


Test patch:
diff --git a/tests/test_log_levels.py b/tests/test_log_levels.py
index 285dbfc..1a37273 100644
--- a/tests/test_log_levels.py
+++ b/tests/test_log_levels.py
@@ -10,6 +10,11 @@ import pytest
 
 from structlog import make_filtering_bound_logger
 from structlog._log_levels import _LEVEL_TO_NAME
+from structlog.contextvars import (
+    bind_contextvars,
+    clear_contextvars,
+    merge_contextvars,
+)
 from structlog.testing import CapturingLogger
 
 
@@ -88,7 +93,7 @@ class TestFilteringLogger:
 
         assert [] == cl.calls
 
-    def test_filter_bound_below_missing_event_string(self, bl, cl):
+    def test_filter_bound_below_missing_event_string(self, bl):
         """
         Missing event arg causes exception below min_level.
         """
@@ -99,7 +104,7 @@ class TestFilteringLogger:
         message = "missing 1 required positional argument: 'event'"
         assert message in exc_info.value.args[0]
 
-    def test_filter_bound_exact_missing_event_string(self, bl, cl):
+    def test_filter_bound_exact_missing_event_string(self, bl):
         """
         Missing event arg causes exception even at min_level.
         """
@@ -110,23 +115,23 @@ class TestFilteringLogger:
         message = "missing 1 required positional argument: 'event'"
         assert message in exc_info.value.args[0]
 
-    def test_exception(self, bl):
+    def test_exception(self, bl, cl):
         """
         exception ensures that exc_info is set to True, unless it's already
         set.
         """
         bl.exception("boom")
 
-        assert [("error", (), {"event": "boom", "exc_info": True})]
+        assert [("error", (), {"event": "boom", "exc_info": True})] == cl.calls
 
-    async def test_async_exception(self, bl):
+    async def test_async_exception(self, bl, cl):
         """
         exception ensures that exc_info is set to True, unless it's already
         set.
         """
         await bl.aexception("boom")
 
-        assert [("error", (), {"event": "boom", "exc_info": True})]
+        assert [("error", (), {"event": "boom", "exc_info": True})] == cl.calls
 
     def test_exception_passed(self, bl, cl):
         """
@@ -134,7 +139,7 @@ class TestFilteringLogger:
         """
         bl.exception("boom", exc_info=42)
 
-        assert [("error", (), {"event": "boom", "exc_info": 42})]
+        assert [("error", (), {"event": "boom", "exc_info": 42})] == cl.calls
 
     async def test_async_exception_passed(self, bl, cl):
         """
@@ -142,7 +147,7 @@ class TestFilteringLogger:
         """
         await bl.aexception("boom", exc_info=42)
 
-        assert [("error", (), {"event": "boom", "exc_info": 42})]
+        assert [("error", (), {"event": "boom", "exc_info": 42})] == cl.calls
 
     @pytest.mark.parametrize("level", tuple(_LEVEL_TO_NAME.keys()))
     def test_pickle(self, level):
@@ -168,3 +173,21 @@ class TestFilteringLogger:
         await bl.ainfo("hello %s -- %d!", "world", 42)
 
         assert [("info", (), {"event": "hello world -- 42!"})] == cl.calls
+
+    @pytest.mark.parametrize(
+        "meth,args",
+        [
+            ("aexception", ("ev",)),
+            ("ainfo", ("ev",)),
+            ("alog", (logging.INFO, "ev")),
+        ],
+    )
+    async def test_async_contextvars_merged(self, meth, args, cl):
+        clear_contextvars()
+        bl = make_filtering_bound_logger(logging.INFO)(
+            cl, [merge_contextvars], {}
+        )
+        bind_contextvars(context_included="yep")
+        await getattr(bl, meth)(*args)
+        assert len(cl.calls) == 1
+        assert "context_included" in cl.calls[0].kwargs