While making cwgrep, I wanted to log every AWS call the tool made without wrapping each method manually.

Turns out, Python lets you do that cleanly.

__getattr__

This is a Python dunder method that’s only called when: The attribute named name is not found in the usual places (__dict__, class, etc.). So if self.describe_log_groups doesn’t exist on the AWSInterceptor instance, Python calls __getattr__(‘describe_log_groups’). This lets you act as a transparent proxy, forwarding unknown method accesses to the real client.

 1import time 
 2import functools
 3
 4SENSITIVE_KEYS = {"password", "secret", "token", "key"}
 5
 6def sanitize(params):
 7    def _sanitize(obj):
 8        if isinstance(obj, dict):
 9            return {k: "***" if k.lower() in SENSITIVE_KEYS else _sanitize(v) for k, v in obj.items()}
10        if isinstance(obj, list) and len(obj) > 10:
11            return f"[{len(obj)} items]"
12        return obj
13    return _sanitize(params)
14
15class AWSInterceptor:
16    def __init__(self, client, logger):
17        self._client = client
18        self._logger = logger
19
20    def __getattr__(self, name):
21        method = getattr(self._client, name)
22        if not callable(method):
23            return method
24
25        @functools.wraps(method)
26        def wrapped(*args, **kwargs):
27            start = time.time()
28            try:
29                result = method(*args, **kwargs)
30                duration = time.time() - start
31                self._logger.log_call(name, duration, success=True, params=sanitize(kwargs))
32                return result
33            except Exception as e:
34                duration = time.time() - start
35                self._logger.log_call(name, duration, success=False, error=str(e), params=sanitize(kwargs))
36                raise
37
38        return wrapped

Notice the weird decorator ?

1        @functools.wraps(method)
2        def wrapped(*args, **kwargs):

This is classic decorator technique, but applied dynamically. It preserves metadata like __name__, __doc__, and signature, so logs and debuggers still refer to the original method, not wrapped.

Now i only had to do

1real_client = boto3.client("logs")
2proxy = AWSInterceptor(real_client, logger)
3proxy.describe_log_groups()

That’s it.