Skip to content

Commit 5eaadcc

Browse files
committed
Add async task name as option to callsite (#693)
1 parent 66e22d2 commit 5eaadcc

5 files changed

Lines changed: 69 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
2121
[#684](https://github.com/hynek/structlog/pull/684)
2222

2323

24+
### Added
25+
26+
- `structlog.processors.CallsiteParameter.TASK_NAME` now available as callsite parameter.
27+
[#693](https://github.com/hynek/structlog/issues/693)
28+
29+
2430
### Changed
2531

2632
- `structlog.stdlib.BoundLogger`'s binding-related methods now also return `Self`.

src/structlog/_utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99

1010
from __future__ import annotations
1111

12+
import asyncio
1213
import sys
1314

1415
from contextlib import suppress
15-
from typing import Any
16+
from typing import Any, Optional
1617

1718

1819
def get_processname() -> str:
@@ -28,3 +29,17 @@ def get_processname() -> str:
2829
processname = mp.current_process().name
2930

3031
return processname
32+
33+
34+
def get_taskname() -> Optional[str]: # noqa: UP007
35+
"""
36+
Get the current asynchronous task if applicable.
37+
38+
Returns:
39+
Optional[str]: asynchronous task name.
40+
"""
41+
task_name = None
42+
with suppress(Exception):
43+
task = asyncio.current_task()
44+
task_name = task.get_name() if task else None
45+
return task_name

src/structlog/processors.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
_format_stack,
3838
)
3939
from ._log_levels import NAME_TO_LEVEL, add_log_level
40-
from ._utils import get_processname
40+
from ._utils import get_processname, get_taskname
4141
from .tracebacks import ExceptionDictTransformer
4242
from .typing import (
4343
EventDict,
@@ -735,6 +735,8 @@ class CallsiteParameter(enum.Enum):
735735
PROCESS = "process"
736736
#: The name of the process the callsite was executed in.
737737
PROCESS_NAME = "process_name"
738+
#: The name of the asynchronous task the callsite was executed in.
739+
TASK_NAME = "task_name"
738740

739741

740742
def _get_callsite_pathname(module: str, frame: FrameType) -> Any:
@@ -773,6 +775,10 @@ def _get_callsite_process_name(module: str, frame: FrameType) -> Any:
773775
return get_processname()
774776

775777

778+
def _get_callsite_task_name(module: str, frame: FrameType) -> Any:
779+
return get_taskname()
780+
781+
776782
class CallsiteParameterAdder:
777783
"""
778784
Adds parameters of the callsite that an event dictionary originated from to
@@ -825,6 +831,7 @@ class CallsiteParameterAdder:
825831
CallsiteParameter.THREAD_NAME: _get_callsite_thread_name,
826832
CallsiteParameter.PROCESS: _get_callsite_process,
827833
CallsiteParameter.PROCESS_NAME: _get_callsite_process_name,
834+
CallsiteParameter.TASK_NAME: _get_callsite_task_name,
828835
}
829836
_record_attribute_map: ClassVar[dict[CallsiteParameter, str]] = {
830837
CallsiteParameter.PATHNAME: "pathname",
@@ -836,6 +843,7 @@ class CallsiteParameterAdder:
836843
CallsiteParameter.THREAD_NAME: "threadName",
837844
CallsiteParameter.PROCESS: "process",
838845
CallsiteParameter.PROCESS_NAME: "processName",
846+
CallsiteParameter.TASK_NAME: "taskName",
839847
}
840848

841849
_all_parameters: ClassVar[set[CallsiteParameter]] = set(CallsiteParameter)
@@ -881,9 +889,12 @@ def __call__(
881889
# then the callsite parameters of the record will not be correct.
882890
if record is not None and not from_structlog:
883891
for mapping in self._record_mappings:
884-
event_dict[mapping.event_dict_key] = record.__dict__[
892+
# Careful since log record attribute taskName is only
893+
# supported as of python 3.12
894+
# https://docs.python.org/3.12/library/logging.html#logrecord-attributes
895+
event_dict[mapping.event_dict_key] = record.__dict__.get(
885896
mapping.record_attribute
886-
]
897+
)
887898
else:
888899
frame, module = _find_first_app_frame_and_name(
889900
additional_ignores=self._additional_ignores

tests/processors/test_processors.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import structlog
2323

2424
from structlog import BoundLogger
25-
from structlog._utils import get_processname
25+
from structlog._utils import get_processname, get_taskname
2626
from structlog.processors import (
2727
CallsiteParameter,
2828
CallsiteParameterAdder,
@@ -281,6 +281,7 @@ class TestCallsiteParameterAdder:
281281
"thread_name",
282282
"process",
283283
"process_name",
284+
"task_name",
284285
}
285286

286287
_all_parameters = set(CallsiteParameter)
@@ -330,7 +331,7 @@ def __init__(self):
330331
logger_params = json.loads(string_io.getvalue())
331332

332333
# These are different when running under async
333-
for key in ["thread", "thread_name"]:
334+
for key in ["thread", "thread_name", "task_name"]:
334335
callsite_params.pop(key)
335336
logger_params.pop(key)
336337

@@ -607,6 +608,7 @@ def get_callsite_parameters(cls, offset: int = 1) -> dict[str, object]:
607608
"thread_name": threading.current_thread().name,
608609
"process": os.getpid(),
609610
"process_name": get_processname(),
611+
"task_name": get_taskname(),
610612
}
611613

612614

tests/test_utils.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
# 2.0, and the MIT License. See the LICENSE file in the root of this
44
# repository for complete details.
55

6+
import asyncio
67
import multiprocessing
78
import sys
89

910
import pytest
1011

11-
from structlog._utils import get_processname
12+
from structlog._utils import get_processname, get_taskname
1213

1314

1415
class TestGetProcessname:
@@ -69,3 +70,30 @@ def _current_process() -> None:
6970
)
7071

7172
assert get_processname() == "n/a"
73+
74+
75+
class TestGetTaskname:
76+
def test_event_loop_running(self) -> None:
77+
"""
78+
Test returned task name when executed within an event loop.
79+
"""
80+
81+
async def aroutine() -> None:
82+
assert get_taskname() == "AsyncTask"
83+
84+
async def run() -> None:
85+
task = asyncio.create_task(aroutine(), name="AsyncTask")
86+
await asyncio.gather(task)
87+
88+
asyncio.run(run())
89+
90+
def test_no_event_loop_running(self) -> None:
91+
"""
92+
Test returned task name when executed asynchronously without an event
93+
loop.
94+
"""
95+
96+
def routine() -> None:
97+
assert get_taskname() is None
98+
99+
routine()

0 commit comments

Comments
 (0)