Skip to content

Commit 87de70c

Browse files
heitorlessaOGoodness
and
OGoodness
authored
feat(logger): accept arbitrary keyword=value for ephemeral metadata (#1658)
Co-authored-by: OGoodness <[email protected]>
1 parent 0c742e0 commit 87de70c

File tree

5 files changed

+230
-4
lines changed

5 files changed

+230
-4
lines changed

aws_lambda_powertools/logging/logger.py

+171-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import functools
22
import inspect
3+
import io
34
import logging
45
import os
56
import random
67
import sys
7-
from typing import IO, Any, Callable, Dict, Iterable, Optional, TypeVar, Union
8+
import traceback
9+
from typing import IO, Any, Callable, Dict, Iterable, Mapping, Optional, TypeVar, Union
810

911
import jmespath
1012

@@ -235,6 +237,9 @@ def _init_logger(self, **kwargs):
235237
self._logger.addHandler(self.logger_handler)
236238
self.structure_logs(**kwargs)
237239

240+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
241+
self._logger.findCaller = self.findCaller
242+
238243
# Pytest Live Log feature duplicates log records for colored output
239244
# but we explicitly add a filter for log deduplication.
240245
# This flag disables this protection when you explicit want logs to be duplicated (#262)
@@ -359,6 +364,126 @@ def decorate(event, context, *args, **kwargs):
359364

360365
return decorate
361366

367+
def info(
368+
self,
369+
msg: object,
370+
*args,
371+
exc_info=None,
372+
stack_info: bool = False,
373+
stacklevel: int = 2,
374+
extra: Optional[Mapping[str, object]] = None,
375+
**kwargs,
376+
):
377+
extra = extra or {}
378+
extra = {**extra, **kwargs}
379+
380+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
381+
if sys.version_info < (3, 8): # pragma: no cover
382+
return self._logger.info(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
383+
return self._logger.info(
384+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
385+
)
386+
387+
def error(
388+
self,
389+
msg: object,
390+
*args,
391+
exc_info=None,
392+
stack_info: bool = False,
393+
stacklevel: int = 2,
394+
extra: Optional[Mapping[str, object]] = None,
395+
**kwargs,
396+
):
397+
extra = extra or {}
398+
extra = {**extra, **kwargs}
399+
400+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
401+
if sys.version_info < (3, 8): # pragma: no cover
402+
return self._logger.error(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
403+
return self._logger.error(
404+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
405+
)
406+
407+
def exception(
408+
self,
409+
msg: object,
410+
*args,
411+
exc_info=True,
412+
stack_info: bool = False,
413+
stacklevel: int = 2,
414+
extra: Optional[Mapping[str, object]] = None,
415+
**kwargs,
416+
):
417+
extra = extra or {}
418+
extra = {**extra, **kwargs}
419+
420+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
421+
if sys.version_info < (3, 8): # pragma: no cover
422+
return self._logger.exception(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
423+
return self._logger.exception(
424+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
425+
)
426+
427+
def critical(
428+
self,
429+
msg: object,
430+
*args,
431+
exc_info=None,
432+
stack_info: bool = False,
433+
stacklevel: int = 2,
434+
extra: Optional[Mapping[str, object]] = None,
435+
**kwargs,
436+
):
437+
extra = extra or {}
438+
extra = {**extra, **kwargs}
439+
440+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
441+
if sys.version_info < (3, 8): # pragma: no cover
442+
return self._logger.critical(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
443+
return self._logger.critical(
444+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
445+
)
446+
447+
def warning(
448+
self,
449+
msg: object,
450+
*args,
451+
exc_info=None,
452+
stack_info: bool = False,
453+
stacklevel: int = 2,
454+
extra: Optional[Mapping[str, object]] = None,
455+
**kwargs,
456+
):
457+
extra = extra or {}
458+
extra = {**extra, **kwargs}
459+
460+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
461+
if sys.version_info < (3, 8): # pragma: no cover
462+
return self._logger.warning(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
463+
return self._logger.warning(
464+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
465+
)
466+
467+
def debug(
468+
self,
469+
msg: object,
470+
*args,
471+
exc_info=None,
472+
stack_info: bool = False,
473+
stacklevel: int = 2,
474+
extra: Optional[Mapping[str, object]] = None,
475+
**kwargs,
476+
):
477+
extra = extra or {}
478+
extra = {**extra, **kwargs}
479+
480+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
481+
if sys.version_info < (3, 8): # pragma: no cover
482+
return self._logger.debug(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra)
483+
return self._logger.debug(
484+
msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra
485+
)
486+
362487
def append_keys(self, **additional_keys):
363488
self.registered_formatter.append_keys(**additional_keys)
364489

@@ -462,6 +587,41 @@ def _get_caller_filename():
462587
caller_frame = frame.f_back.f_back.f_back
463588
return caller_frame.f_globals["__name__"]
464589

590+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
591+
def findCaller(self, stack_info=False, stacklevel=2): # pragma: no cover
592+
"""
593+
Find the stack frame of the caller so that we can note the source
594+
file name, line number and function name.
595+
"""
596+
f = logging.currentframe() # noqa: VNE001
597+
# On some versions of IronPython, currentframe() returns None if
598+
# IronPython isn't run with -X:Frames.
599+
if f is None:
600+
return "(unknown file)", 0, "(unknown function)", None
601+
while stacklevel > 0:
602+
next_f = f.f_back
603+
if next_f is None:
604+
## We've got options here.
605+
## If we want to use the last (deepest) frame:
606+
break
607+
## If we want to mimic the warnings module:
608+
# return ("sys", 1, "(unknown function)", None) # noqa: E800
609+
## If we want to be pedantic: # noqa: E800
610+
# raise ValueError("call stack is not deep enough") # noqa: E800
611+
f = next_f # noqa: VNE001
612+
if not _is_internal_frame(f):
613+
stacklevel -= 1
614+
co = f.f_code
615+
sinfo = None
616+
if stack_info:
617+
with io.StringIO() as sio:
618+
sio.write("Stack (most recent call last):\n")
619+
traceback.print_stack(f, file=sio)
620+
sinfo = sio.getvalue()
621+
if sinfo[-1] == "\n":
622+
sinfo = sinfo[:-1]
623+
return co.co_filename, f.f_lineno, co.co_name, sinfo
624+
465625

466626
def set_package_logger(
467627
level: Union[str, int] = logging.DEBUG,
@@ -500,3 +660,13 @@ def set_package_logger(
500660
handler = logging.StreamHandler(stream)
501661
handler.setFormatter(formatter)
502662
logger.addHandler(handler)
663+
664+
665+
# Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work
666+
# The following is based on warnings._is_internal_frame. It makes sure that
667+
# frames of the import mechanism are skipped when logging at module level and
668+
# using a stacklevel value greater than one.
669+
def _is_internal_frame(frame): # pragma: no cover
670+
"""Signal whether the frame is a CPython or logging module internal."""
671+
filename = os.path.normcase(frame.f_code.co_filename)
672+
return filename == logging._srcfile or ("importlib" in filename and "_bootstrap" in filename)

docs/core/logger.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ To ease routine tasks like extracting correlation ID from popular event sources,
158158
You can append additional keys using either mechanism:
159159

160160
* Persist new keys across all future log messages via `append_keys` method
161-
* Add additional keys on a per log message basis via `extra` parameter
161+
* Add additional keys on a per log message basis as a keyword=value, or via `extra` parameter
162162

163163
#### append_keys method
164164

@@ -184,14 +184,33 @@ You can append your own keys to your existing Logger via `append_keys(**addition
184184

185185
This example will add `order_id` if its value is not empty, and in subsequent invocations where `order_id` might not be present it'll remove it from the Logger.
186186

187+
#### ephemeral metadata
188+
189+
You can pass an arbitrary number of keyword arguments (kwargs) to all log level's methods, e.g. `logger.info, logger.warning`.
190+
191+
Two common use cases for this feature is to enrich log statements with additional metadata, or only add certain keys conditionally.
192+
193+
!!! info "Any keyword argument added will not be persisted in subsequent messages."
194+
195+
=== "append_keys_kwargs.py"
196+
197+
```python hl_lines="8"
198+
--8<-- "examples/logger/src/append_keys_kwargs.py"
199+
```
200+
201+
=== "append_keys_kwargs_output.json"
202+
203+
```json hl_lines="7"
204+
--8<-- "examples/logger/src/append_keys_kwargs_output.json"
205+
```
206+
187207
#### extra parameter
188208

189209
Extra parameter is available for all log levels' methods, as implemented in the standard logging library - e.g. `logger.info, logger.warning`.
190210

191211
It accepts any dictionary, and all keyword arguments will be added as part of the root structure of the logs for that log statement.
192212

193-
???+ info
194-
Any keyword argument added using `extra` will not be persisted for subsequent messages.
213+
!!! info "Any keyword argument added using `extra` will not be persisted in subsequent messages."
195214

196215
=== "append_keys_extra.py"
197216

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.utilities.typing import LambdaContext
3+
4+
logger = Logger()
5+
6+
7+
def handler(event: dict, context: LambdaContext) -> str:
8+
logger.info("Collecting payment", request_id="1123")
9+
10+
return "hello world"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"level": "INFO",
3+
"location": "collect.handler:8",
4+
"message": "Collecting payment",
5+
"timestamp": "2022-11-26 11:47:12,494+0200",
6+
"service": "payment",
7+
"request_id": "1123"
8+
}

tests/functional/test_logger.py

+19
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,25 @@ def test_logger_extra_kwargs(stdout, service_name):
415415
assert "request_id" not in no_extra_fields_log
416416

417417

418+
def test_logger_arbitrary_fields_as_kwargs(stdout, service_name):
419+
# GIVEN Logger is initialized
420+
logger = Logger(service=service_name, stream=stdout)
421+
422+
# WHEN `request_id` is an arbitrary field in a log message to the existing structured log
423+
fields = {"request_id": "blah"}
424+
425+
logger.info("with arbitrary fields", **fields)
426+
logger.info("without extra fields")
427+
428+
extra_fields_log, no_extra_fields_log = capture_multiple_logging_statements_output(stdout)
429+
430+
# THEN first log should have request_id field in the root structure
431+
assert "request_id" in extra_fields_log
432+
433+
# THEN second log should not have request_id in the root structure
434+
assert "request_id" not in no_extra_fields_log
435+
436+
418437
def test_logger_log_twice_when_log_filter_isnt_present_and_root_logger_is_setup(monkeypatch, stdout, service_name):
419438
# GIVEN Lambda configures the root logger with a handler
420439
root_logger = logging.getLogger()

0 commit comments

Comments
 (0)