Coverage for src/functionalytics/log_this.py: 0%
62 statements
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-22 22:45 -0700
« prev ^ index » next coverage.py v7.8.1, created at 2025-05-22 22:45 -0700
1import datetime
2import inspect
3import logging
4import traceback
5from functools import wraps
6from typing import Any, Callable, Iterable, Mapping, Optional
9def log_this(
10 log_level: int = logging.INFO,
11 file_path: Optional[str] = None,
12 log_format: Optional[str] = None,
13 param_attrs: Optional[Mapping[str, Callable[[Any], Any]]] = None,
14 discard_params: Optional[Iterable[str]] = None,
15 extra_data: Optional[Mapping[str, Any]] = None,
16 error_file_path: Optional[str] = None,
17):
18 """Flexibly log every invocation of your application's functions.
20 If you want to know what option(s) your users select from a certain dropdown, or
21 maybe the length of a string they entered, this is function decorator for you.
23 The invocations will be logged using the options you set, and can later be parsed
24 and analyzed to understand how your users are using your application.
26 Parameters
27 ----------
28 log_level
29 Logging level (``logging.INFO`` by default).
30 file_path
31 Path to a log file. If *None*, output goes to *stderr*.
32 log_format
33 ``{}``-style format string for the emitted records.
34 param_attrs
35 Mapping whose *keys* are parameter names and whose *values* are
36 callables that receive the parameter value and return **what should be
37 logged** under *Attrs*.
38 discard_params
39 Iterable of parameter names whose *values* **must not appear in the
40 Args/Kwargs sections** of the log line. These parameters are still
41 eligible for inclusion in *Attrs* via *param_attrs*.
42 extra_data
43 Optional dictionary of arbitrary data to be appended to the log
44 line. Appears as ``Extra: {key1: val1, ...}``.
45 error_file_path
46 Path to a log file for errors. If *None*, error output goes to *stderr*.
48 Examples
49 --------
50 **Basic usage**::
52 @log_this()
53 def add(a, b):
54 return a + b
57 add(1, 2)
58 # → Calling: __main__.add [2025-05-19T17:25:21.780733+00:00 2025-05-19T17:25:21.781115+00:00] Args: [10, 20] Kwargs: {} Attrs: {}
60 **Redacting a secret token**::
62 @log_this(discard_params={"api_token"})
63 def fetch_data(url, api_token): ...
65 **Summarising large inputs**::
67 @log_this(param_attrs={"payload": len}, discard_params={"payload"})
68 # It's generally good to discard parameter that are large, so you don't clutter
69 # your logs with huge objects. You can still log their attributes like length,
70 # though.
71 def send(payload: bytes): ...
73 See Also
74 --------
75 You can use advertools.logs_to_df() to parse, compress and analyze the logs
76 generated by this decorator.
77 """
79 handler: logging.Handler
80 if file_path:
81 handler = logging.FileHandler(file_path)
82 else:
83 handler = logging.StreamHandler()
85 if log_format:
86 handler.setFormatter(logging.Formatter(log_format, style="{"))
88 if error_file_path:
89 error_handler = logging.FileHandler(error_file_path)
90 else:
91 error_handler = logging.StreamHandler()
93 discard_set = set(discard_params or ())
95 def decorator(func):
96 logger_name = f"{func.__module__}.{func.__qualname__}"
97 logger = logging.getLogger(logger_name)
98 logger.setLevel(log_level)
100 if handler not in logger.handlers:
101 logger.addHandler(handler)
103 sig = inspect.signature(func)
105 error_logger_name = f"{func.__module__}.{func.__qualname__}.error"
106 error_logger = logging.getLogger(error_logger_name)
107 error_logger.setLevel(logging.ERROR)
108 if error_handler not in error_logger.handlers:
109 error_logger.addHandler(error_handler)
111 @wraps(func)
112 def wrapper(*args, **kwargs):
113 bound = sig.bind_partial(*args, **kwargs)
114 bound.apply_defaults()
116 positional_param_names = list(sig.parameters)[: len(args)]
117 args_repr = [
118 value
119 for name, value in zip(positional_param_names, args)
120 if name not in discard_set
121 ]
122 kwargs_repr = {k: v for k, v in kwargs.items() if k not in discard_set}
124 attrs_repr = {}
125 if param_attrs:
126 for name, transformer in param_attrs.items():
127 if name not in bound.arguments:
128 continue
129 try:
130 attrs_repr[name] = transformer(bound.arguments[name])
131 except Exception as exc:
132 attrs_repr[name] = f"<transform error: {exc}>"
134 try:
135 utc = datetime.UTC
136 except AttributeError:
137 # Fallback for Python versions < 3.11 which didn't have datetime.UTC
138 utc = datetime.timezone.utc
140 t0 = datetime.datetime.now(utc)
141 try:
142 result = func(*args, **kwargs)
143 except Exception as exc:
144 error_logger.error(
145 f"Error in {logger_name} at {t0.isoformat()} Args: {args_repr} Kwargs: {kwargs_repr} Attrs: {attrs_repr} Exception: {exc}\n{traceback.format_exc()}"
146 )
147 raise
148 t1 = datetime.datetime.now(utc)
150 extra_data_repr = ""
151 if extra_data:
152 extra_data_repr = f" Extra: {extra_data}"
154 logger.log(
155 log_level,
156 (
157 f"Calling: {logger_name} "
158 f"[{t0.isoformat()} {t1.isoformat()}] "
159 f"Args: {args_repr} Kwargs: {kwargs_repr} Attrs: {attrs_repr}{extra_data_repr}"
160 ),
161 )
162 return result
164 return wrapper
166 return decorator