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

1import datetime 

2import inspect 

3import logging 

4import traceback 

5from functools import wraps 

6from typing import Any, Callable, Iterable, Mapping, Optional 

7 

8 

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. 

19 

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. 

22 

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. 

25 

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*. 

47 

48 Examples 

49 -------- 

50 **Basic usage**:: 

51 

52 @log_this() 

53 def add(a, b): 

54 return a + b 

55 

56 

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: {} 

59 

60 **Redacting a secret token**:: 

61 

62 @log_this(discard_params={"api_token"}) 

63 def fetch_data(url, api_token): ... 

64 

65 **Summarising large inputs**:: 

66 

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): ... 

72 

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 """ 

78 

79 handler: logging.Handler 

80 if file_path: 

81 handler = logging.FileHandler(file_path) 

82 else: 

83 handler = logging.StreamHandler() 

84 

85 if log_format: 

86 handler.setFormatter(logging.Formatter(log_format, style="{")) 

87 

88 if error_file_path: 

89 error_handler = logging.FileHandler(error_file_path) 

90 else: 

91 error_handler = logging.StreamHandler() 

92 

93 discard_set = set(discard_params or ()) 

94 

95 def decorator(func): 

96 logger_name = f"{func.__module__}.{func.__qualname__}" 

97 logger = logging.getLogger(logger_name) 

98 logger.setLevel(log_level) 

99 

100 if handler not in logger.handlers: 

101 logger.addHandler(handler) 

102 

103 sig = inspect.signature(func) 

104 

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) 

110 

111 @wraps(func) 

112 def wrapper(*args, **kwargs): 

113 bound = sig.bind_partial(*args, **kwargs) 

114 bound.apply_defaults() 

115 

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} 

123 

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}>" 

133 

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 

139 

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) 

149 

150 extra_data_repr = "" 

151 if extra_data: 

152 extra_data_repr = f" Extra: {extra_data}" 

153 

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 

163 

164 return wrapper 

165 

166 return decorator