Coverage for src/functionalytics/log_this.py: 100%

75 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-05-28 05:12 -0400

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 log_conditions: Optional[Mapping[str, Callable[[Any], bool]]] = None, 

18): 

19 """Flexibly log every invocation of your application's functions. 

20 

21 If you want to know what option(s) your users select from a certain dropdown, or 

22 maybe the length of a string they entered, this is function decorator for you. 

23 

24 The invocations will be logged using the options you set, and can later be parsed 

25 and analyzed to understand how your users are using your application. 

26 

27 Parameters 

28 ---------- 

29 log_level 

30 Logging level (``logging.INFO`` by default). 

31 file_path 

32 Path to a log file. If *None*, output goes to *stderr*. 

33 log_format 

34 ``{}``-style format string for the emitted records. 

35 param_attrs 

36 Mapping whose *keys* are parameter names and whose *values* are 

37 callables that receive the parameter value and return **what should be 

38 logged** under *Attrs*. 

39 discard_params 

40 Iterable of parameter names whose *values* **must not appear in the 

41 Args/Kwargs sections** of the log line. These parameters are still 

42 eligible for inclusion in *Attrs* via *param_attrs*. 

43 extra_data 

44 Optional dictionary of arbitrary data to be appended to the log 

45 line. Appears as ``Extra: {key1: val1, ...}``. 

46 error_file_path 

47 Path to a log file for errors. If *None*, error output goes to *stderr*. 

48 log_conditions 

49 Optional mapping of parameter names to boolean functions. Logging only 

50 occurs if ALL conditions evaluate to True. Functions receive the parameter 

51 value and should return a boolean. If *None*, logging always occurs. 

52 If a key in this mapping does not correspond to a parameter of the 

53 decorated function or is not available at call time, a `KeyError` 

54 will be raised. If a condition function itself raises an exception 

55 during its execution, a `RuntimeError` will be raised, encapsulating 

56 the original exception. 

57 

58 Examples 

59 -------- 

60 **Basic usage**:: 

61 

62 @log_this() 

63 def add(a, b): 

64 return a + b 

65 

66 

67 add(1, 2) 

68 Calling: __main__.add [2025-05-19T17:25:21.780733+00:00 2025-05-19T17:25:21.781115+00:00] Values: a=1 b=2 Attrs: {} 

69 

70 **Conditional logging**:: 

71 

72 @log_this(log_conditions={"value": lambda x: x > 0}) 

73 def process_value(value): 

74 return value * 2 

75 

76 **Redacting a secret token**:: 

77 

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

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

80 

81 **Summarising large inputs**:: 

82 

83 It's generally good to discard parameters that are large, so you don't clutter 

84 your logs with huge objects. You can still log their attributes like length though. 

85 

86 @log_this(param_attrs={"payload": len}, discard_params={"payload"}) 

87 def send(payload: bytes): 

88 ... 

89 

90 See Also 

91 -------- 

92 You can use advertools.logs_to_df() to parse, compress and analyze the logs 

93 generated by this decorator. 

94 """ 

95 

96 handler: logging.Handler 

97 if file_path: 

98 handler = logging.FileHandler(file_path) 

99 else: 

100 handler = logging.StreamHandler() 

101 

102 if log_format: 

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

104 error_handler: logging.Handler 

105 if error_file_path: 

106 error_handler = logging.FileHandler(error_file_path) 

107 else: 

108 error_handler = logging.StreamHandler() 

109 

110 discard_set = set(discard_params or ()) 

111 

112 def decorator(func): 

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

114 logger = logging.getLogger(logger_name) 

115 logger.setLevel(log_level) 

116 

117 if handler not in logger.handlers: 

118 logger.addHandler(handler) 

119 

120 sig = inspect.signature(func) 

121 

122 error_logger_name = f"{func.__module__}.{func.__qualname__}.error" 

123 error_logger = logging.getLogger(error_logger_name) 

124 error_logger.setLevel(logging.ERROR) 

125 error_logger.propagate = False # Prevent propagation to parent logger 

126 if error_handler not in error_logger.handlers: 

127 error_logger.addHandler(error_handler) 

128 

129 @wraps(func) 

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

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

132 bound.apply_defaults() 

133 

134 values_parts = [] 

135 for name, value in bound.arguments.items(): 

136 if name in discard_set: 

137 values_parts.append(f"{name}=discarded") 

138 else: 

139 values_parts.append(f"{name}={repr(value)}") 

140 values_repr = " ".join(values_parts) 

141 

142 attrs_repr = {} 

143 if param_attrs: 

144 for name, transformer in param_attrs.items(): 

145 try: 

146 attrs_repr[name] = transformer(bound.arguments[name]) 

147 except KeyError as e: 

148 raise KeyError( 

149 f"Parameter {str(e)} referenced in param_attrs " 

150 f"is not a valid parameter for function '{func.__name__}' " 

151 f"or not available at call time." 

152 ) from e 

153 except Exception as exc: 

154 attrs_repr[name] = f"<transform error: {exc}>" 

155 

156 try: 

157 utc = datetime.UTC 

158 except AttributeError: 

159 # Fallback for Python versions < 3.11 which didn't have datetime.UTC 

160 utc = datetime.timezone.utc 

161 

162 t0 = datetime.datetime.now(utc) 

163 try: 

164 result = func(*args, **kwargs) 

165 except Exception as exc: 

166 error_logger.error( 

167 f"Error in {logger_name} at {t0.isoformat()} " 

168 f"Values: {values_repr} " 

169 f"Attrs: {attrs_repr} Exception: {exc}\n{traceback.format_exc()}" 

170 ) 

171 raise 

172 t1 = datetime.datetime.now(utc) 

173 

174 extra_data_repr = "" 

175 if extra_data: 

176 extra_data_repr = f" Extra: {extra_data}" 

177 log_conditions_met = True 

178 if log_conditions is not None: 

179 try: 

180 log_conditions_met = all( 

181 v(bound.arguments[k]) for k, v in log_conditions.items() 

182 ) 

183 except KeyError as e: 

184 raise KeyError( 

185 f"Parameter {str(e)} referenced in log_conditions " 

186 f"is not a valid parameter for function '{func.__name__}' " 

187 f"or not available at call time." 

188 ) from e 

189 except Exception as e: 

190 raise RuntimeError( 

191 f"Error evaluating a condition function within log_conditions " 

192 f"for function '{func.__name__}': {e}" 

193 ) from e 

194 if log_conditions_met: 

195 logger.log( 

196 log_level, 

197 ( 

198 f"Calling: {logger_name} " 

199 f"[{t0.isoformat()} {t1.isoformat()}] " 

200 f"Values: {values_repr} " 

201 f"Attrs: {attrs_repr}{extra_data_repr}" 

202 ), 

203 ) 

204 return result 

205 

206 return wrapper 

207 

208 return decorator