Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added CallsiteNamespaceAdder processor #570

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
24e0ab5
Added `AddCallingClassPath` processor
pahrohfit Oct 30, 2023
d9f4186
Added missing unit test for level limiter
pahrohfit Oct 30, 2023
feb91b6
Fixed logic error in fallback path
pahrohfit Oct 30, 2023
24ff23c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 30, 2023
a3524b0
Cleaned up logic and removed test miss
pahrohfit Oct 30, 2023
f260530
Merged linting fixes
pahrohfit Oct 30, 2023
7400abe
Cleaned up typing issue
pahrohfit Oct 30, 2023
938ccb3
Added some additional comments and optimization
pahrohfit Oct 30, 2023
278c996
Changed logic to pass coverage testing
pahrohfit Oct 31, 2023
b8e4957
Merge branch 'hynek:main' into namespace_processor
pahrohfit Nov 2, 2023
18cdbaa
Renamed from 'ClassPath' to 'Namespace' for clarity
pahrohfit Nov 2, 2023
4454cdb
Merge branch 'main' into namespace_processor
pahrohfit Nov 2, 2023
8d64565
Renamed `AddCallingNamespace` to `CallsiteNamespaceAddr` to match exi…
pahrohfit Nov 2, 2023
9bf1543
Merge branch 'main' into namespace_processor
pahrohfit Nov 10, 2023
499ac44
Typing corrections from latest fork sync
pahrohfit Nov 10, 2023
a704bd5
Merge branch 'main' into namespace_processor
hynek Nov 11, 2023
8013977
Merge branch 'hynek:main' into namespace_processor
pahrohfit Nov 14, 2023
2187670
Update wording for `CallsiteNamespaceAdder()`
pahrohfit Nov 14, 2023
981c776
Corrected class name
pahrohfit Nov 14, 2023
52fb403
Corrected class name
pahrohfit Nov 14, 2023
a79e8ab
Cleaned up comments
pahrohfit Nov 14, 2023
951f1c6
Corrected class name
pahrohfit Nov 14, 2023
2fa503a
Spelling correction
pahrohfit Nov 14, 2023
d1db8da
Corrected class name
pahrohfit Nov 14, 2023
d49104c
Code pretty up
pahrohfit Nov 14, 2023
6be2223
Corrected class name
pahrohfit Nov 14, 2023
ce787d6
Code pretty up
pahrohfit Nov 14, 2023
c9795ab
Code pretty up
pahrohfit Nov 14, 2023
a314b48
Cleaned up merge artifact in docstring
pahrohfit Nov 14, 2023
604fcab
Relocated `get_qual_name()` to `structlog._frames`
pahrohfit Nov 14, 2023
3f33068
Relocated `get_qual_name()` and refactored loop for clean `break` on …
pahrohfit Nov 14, 2023
c78f7dc
Added `CallsiteNamespaceAdder` patches to `BoundLogger`
pahrohfit Nov 14, 2023
13021cb
Added additional verbiage to `CallsiteNamespaceAdder` docstring
pahrohfit Nov 14, 2023
9102dd0
Moved to more uniform `logging.LEVEL` for inclusion list
pahrohfit Nov 14, 2023
24f8887
Refactored to close branch coverage partial hit
pahrohfit Nov 14, 2023
7323bed
Remove stray code
hynek Nov 16, 2023
7aef252
Merge branch 'main' into namespace_processor
hynek Nov 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

### Added

- `structlog.processors.CallsiteNamespaceAdder()`, which attempts to determine the calling namespace and add it as `namespace` to the event dict.
Takes an optional `levels` `list`|`set` to limit which `logging.LEVEL` to include the addition in.

- Async log methods (those starting with an `a`) now also support the collection of callsite information using `structlog.processors.CallsiteParameterAdder`.
[#565](https://github.com/hynek/structlog/issues/565)

Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ API Reference

.. autoclass:: CallsiteParameterAdder

.. autoclass:: CallsiteNamespaceAdder


`structlog.stdlib` Module
-------------------------
Expand Down
36 changes: 36 additions & 0 deletions src/structlog/_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import inspect
import sys
import traceback

Expand Down Expand Up @@ -76,3 +77,38 @@ def _format_stack(frame: FrameType) -> str:
sio.close()

return sinfo


def _get_qual_name(frame: FrameType) -> str:
"""
For a given app frame, attempt to deduce the namespace
by crawling through the frame's ``f_globals`` to find
matching object code.

This O(n) procedure should return as O(1) in most situations,
but buyer beware.

Arguments:

frame:
Frame to process.

Returns:

string of the deduced namespace

.. versionadded:: 23.3.0
"""
identified_namespace = frame.f_code.co_name

for cls in {
obj for obj in frame.f_globals.values() if inspect.isclass(obj)
}:
member = getattr(cls, frame.f_code.co_name, None)
# store the current namespace as a fall back (probably the namespace)
identified_namespace = f"{cls.__module__}.{frame.f_code.co_name}"
if inspect.isfunction(member) and member.__code__ == frame.f_code:
# we found our code match, can stop looking
return f"{member.__module__}.{member.__qualname__}"

return identified_namespace
41 changes: 41 additions & 0 deletions src/structlog/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
_find_first_app_frame_and_name,
_format_exception,
_format_stack,
_get_qual_name,
)
from ._log_levels import _NAME_TO_LEVEL, add_log_level
from ._utils import get_processname
Expand All @@ -46,6 +47,7 @@
"add_log_level",
"CallsiteParameter",
"CallsiteParameterAdder",
"CallsiteNamespaceAdder",
"dict_tracebacks",
"EventRenamer",
"ExceptionPrettyPrinter",
Expand Down Expand Up @@ -909,3 +911,42 @@ def __call__(
event_dict["event"] = replace_by

return event_dict


class CallsiteNamespaceAdder:
"""
Attempt to identify and add the caller namespace to the event dict
under the ``namespace`` key.

Attempts to deduce the namespace by crawling through the
calling frame's ``f_globals`` to find matching object code.

This O(n) procedure should return as O(1) in most situations,
but buyer beware.

Arguments:

levels:
A optional set of log levels to add the ``namespace`` key and
information to. The log levels should be supplied as an integer.
You can use the constants from `logging` like ``logging.INFO``
or pass the values directly. See `this table from the logging
docs <https://docs.python.org/3/library/logging.html#levels>`_ for
possible values. Providing `None` or an empty set == *

.. versionadded:: 23.3.0
"""

def __init__(self, levels: set[int] | list[int] | None = None):
self.levels = levels

def __call__(
self, logger: WrappedLogger, name: str, event_dict: EventDict
) -> EventDict:
if self.levels and _NAME_TO_LEVEL[name] not in self.levels:
return event_dict

f, _ = _find_first_app_frame_and_name()
event_dict["namespace"] = _get_qual_name(f)

return event_dict
5 changes: 5 additions & 0 deletions src/structlog/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,18 @@ async def _dispatch_to_sync(
) -> None:
"""
Merge contextvars and log using the sync logger in a thread pool.

.. versionchanged:: 23.3.0
Callsite parameters are now also collected under asyncio.
"""
scs_token = _ASYNC_CALLING_STACK.set(sys._getframe().f_back.f_back) # type: ignore[arg-type, union-attr]
ctx = contextvars.copy_context()

await asyncio.get_running_loop().run_in_executor(
None,
lambda: ctx.run(lambda: meth(event, *args, **kw)),
)
_ASYNC_CALLING_STACK.reset(scs_token)

async def adebug(self, event: str, *args: Any, **kw: Any) -> None:
"""
Expand Down
99 changes: 99 additions & 0 deletions tests/test_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import structlog

from structlog import BoundLogger
from structlog._frames import _get_qual_name
from structlog._utils import get_processname
from structlog.processors import (
CallsiteParameter,
Expand Down Expand Up @@ -1171,3 +1172,101 @@ def test_replace_by_key_is_optional(self):
assert {"msg": "hi", "foo": "bar"} == EventRenamer("msg", "missing")(
None, None, {"event": "hi", "foo": "bar"}
)


class TestCallsiteNamespaceAdder:
def test_simple_lookup(self):
"""
Simple verification of path interogation
"""
assert "{}.{}.{}".format(
self.__module__,
self.__class__.__qualname__,
sys._getframe().f_code.co_name,
) == _get_qual_name(sys._getframe())

async def test_async_lookup(self):
"""
Simple verification of path interogation against async function
"""
assert "{}.{}.{}".format(
self.__module__,
self.__class__.__qualname__,
sys._getframe().f_code.co_name,
) == _get_qual_name(sys._getframe())

def test_async_lookup_fallback(self):
"""
Simple verification of path interogation fallback when no match
can be found
"""
assert _get_qual_name(sys._getframe().f_back).endswith(
"pytest_pyfunc_call"
)

def test_processor(self):
"""
`CallsiteNamespaceAdder` Processor can be enabled and
``namespace`` details are present.
"""
cf = structlog.testing.CapturingLoggerFactory()
structlog.configure(
logger_factory=cf,
processors=[
structlog.processors.CallsiteNamespaceAdder(),
structlog.processors.JSONRenderer(),
],
)
structlog.get_logger().info("test!")

assert (
"{}.{}.{}".format(
self.__module__,
self.__class__.__qualname__,
sys._getframe().f_code.co_name,
)
== json.loads(cf.logger.calls.pop().args[0])["namespace"]
)

def test_level_limiter(self):
"""
`CallsiteNamespaceAdder` Processor limits to which levels
the ``namespace`` details are added.
"""
cf = structlog.testing.CapturingLoggerFactory()
structlog.configure(
logger_factory=cf,
processors=[
structlog.processors.CallsiteNamespaceAdder(levels={"debug"}),
structlog.processors.JSONRenderer(),
],
)
structlog.get_logger().info("test!")

# limiter is set to 'debug', so 'info' should not get the param added
assert "namespace" not in json.loads(cf.logger.calls.pop().args[0])
pahrohfit marked this conversation as resolved.
Show resolved Hide resolved

async def test_async_processor(self):
"""
`CallsiteNamespaceAdder` Processor can be enabled and
``namespace`` details are present for an async log entry.
"""
cf = structlog.testing.CapturingLoggerFactory()
structlog.configure(
logger_factory=cf,
processors=[
structlog.processors.CallsiteNamespaceAdder(),
structlog.processors.JSONRenderer(),
],
)

await structlog.get_logger().ainfo("test!")

assert (
"{}.{}.{}".format(
self.__module__,
self.__class__.__qualname__,
sys._getframe().f_code.co_name,
)
== json.loads(cf.logger.calls.pop().args[0])["namespace"]
)