diff --git a/CHANGELOG.md b/CHANGELOG.md index 5952e405..bcb011ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,12 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ [#684](https://github.com/hynek/structlog/pull/684) +### Added + +- `structlog.processors.CallsiteParameter.TASK_NAME` now available as callsite parameter. + [#693](https://github.com/hynek/structlog/issues/693) + + ### Changed - `structlog.stdlib.BoundLogger`'s binding-related methods now also return `Self`. diff --git a/src/structlog/_utils.py b/src/structlog/_utils.py index 9a41c257..4f917514 100644 --- a/src/structlog/_utils.py +++ b/src/structlog/_utils.py @@ -9,10 +9,11 @@ from __future__ import annotations +import asyncio import sys from contextlib import suppress -from typing import Any +from typing import Any, Optional def get_processname() -> str: @@ -28,3 +29,17 @@ def get_processname() -> str: processname = mp.current_process().name return processname + + +def get_taskname() -> Optional[str]: + """ + Get the current asynchronous task if applicable. + + Returns: + Optional[str]: asynchronous task name. + """ + task_name = None + with suppress(Exception): + task = asyncio.current_task() + task_name = task.get_name() if task else None + return task_name diff --git a/src/structlog/processors.py b/src/structlog/processors.py index 80841561..4559f35c 100644 --- a/src/structlog/processors.py +++ b/src/structlog/processors.py @@ -36,7 +36,7 @@ _format_stack, ) from ._log_levels import NAME_TO_LEVEL, add_log_level -from ._utils import get_processname +from ._utils import get_processname, get_taskname from .tracebacks import ExceptionDictTransformer from .typing import ( EventDict, @@ -757,6 +757,8 @@ class CallsiteParameter(enum.Enum): PROCESS = "process" #: The name of the process the callsite was executed in. PROCESS_NAME = "process_name" + #: The name of the asynchronous task the callsite was executed in. + TASK_NAME = "task_name" def _get_callsite_pathname(module: str, frame: FrameType) -> Any: @@ -799,6 +801,10 @@ def _get_callsite_process_name(module: str, frame: FrameType) -> Any: return get_processname() +def _get_callsite_task_name(module: str, frame: FrameType) -> Any: + return get_taskname() + + class CallsiteParameterAdder: """ Adds parameters of the callsite that an event dictionary originated from to @@ -853,6 +859,7 @@ class CallsiteParameterAdder: CallsiteParameter.THREAD_NAME: _get_callsite_thread_name, CallsiteParameter.PROCESS: _get_callsite_process, CallsiteParameter.PROCESS_NAME: _get_callsite_process_name, + CallsiteParameter.TASK_NAME: _get_callsite_task_name, } _record_attribute_map: ClassVar[dict[CallsiteParameter, str]] = { CallsiteParameter.PATHNAME: "pathname", @@ -864,6 +871,7 @@ class CallsiteParameterAdder: CallsiteParameter.THREAD_NAME: "threadName", CallsiteParameter.PROCESS: "process", CallsiteParameter.PROCESS_NAME: "processName", + CallsiteParameter.TASK_NAME: "taskName", } _all_parameters: ClassVar[set[CallsiteParameter]] = set(CallsiteParameter) @@ -913,9 +921,12 @@ def __call__( # then the callsite parameters of the record will not be correct. if record is not None and not from_structlog: for mapping in self._record_mappings: - event_dict[mapping.event_dict_key] = record.__dict__[ + # Careful since log record attribute taskName is only + # supported as of python 3.12 + # https://docs.python.org/3.12/library/logging.html#logrecord-attributes + event_dict[mapping.event_dict_key] = record.__dict__.get( mapping.record_attribute - ] + ) return event_dict diff --git a/tests/processors/test_processors.py b/tests/processors/test_processors.py index e43485e1..e38366dd 100644 --- a/tests/processors/test_processors.py +++ b/tests/processors/test_processors.py @@ -22,7 +22,7 @@ import structlog from structlog import BoundLogger -from structlog._utils import get_processname +from structlog._utils import get_processname, get_taskname from structlog.processors import ( CallsiteParameter, CallsiteParameterAdder, @@ -301,6 +301,7 @@ class TestCallsiteParameterAdder: "thread_name", "process", "process_name", + "task_name", } # Exclude QUAL_NAME from the general set to keep parity with stdlib @@ -400,7 +401,7 @@ def __init__(self): logger_params = json.loads(string_io.getvalue()) # These are different when running under async - for key in ["thread", "thread_name"]: + for key in ["thread", "thread_name", "task_name"]: callsite_params.pop(key) logger_params.pop(key) @@ -678,6 +679,7 @@ def get_callsite_parameters(cls, offset: int = 1) -> dict[str, object]: "thread_name": threading.current_thread().name, "process": os.getpid(), "process_name": get_processname(), + "task_name": get_taskname(), } diff --git a/tests/test_utils.py b/tests/test_utils.py index 63577fcf..fdc9b9ad 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,12 +3,13 @@ # 2.0, and the MIT License. See the LICENSE file in the root of this # repository for complete details. +import asyncio import multiprocessing import sys import pytest -from structlog._utils import get_processname +from structlog._utils import get_processname, get_taskname class TestGetProcessname: @@ -69,3 +70,30 @@ def _current_process() -> None: ) assert get_processname() == "n/a" + + +class TestGetTaskname: + def test_event_loop_running(self) -> None: + """ + Test returned task name when executed within an event loop. + """ + + async def aroutine() -> None: + assert get_taskname() == "AsyncTask" + + async def run() -> None: + task = asyncio.create_task(aroutine(), name="AsyncTask") + await asyncio.gather(task) + + asyncio.run(run()) + + def test_no_event_loop_running(self) -> None: + """ + Test returned task name when executed asynchronously without an event + loop. + """ + + def routine() -> None: + assert get_taskname() is None + + routine()