watchtower icon indicating copy to clipboard operation
watchtower copied to clipboard

CloudWatchLogFormatter with add_log_record_attrs does not correctly format string messages

Open dgilmanAIDENTIFIED opened this issue 2 years ago • 4 comments

If the add_log_record_attrs is set string messages are not formatted before being shoved into the "msg" in the CloudWatch JSON.

Here's a minimal reproduction:

import logging
import watchtower

logger = logging.getLogger("foo")
handler = watchtower.CloudWatchLogHandler()
handler.formatter.add_log_record_attrs=["levelname", "filename", "process", "thread"]
logger.addHandler(handler)
logger.critical("hello %s world %s", 5, 10)

This produces a JSON in CloudWatch like so:

{
    "msg": "hello %s world %s",
    "levelname": "CRITICAL",
    "filename": "test.py",
    "process": 9155,
    "thread": 4545472000
}

If you comment out the handler.formatter.add_log_record_attrs you get the plain string logged to CloudWatch with formatting done, e.g. just hello 5 world 10.

dgilmanAIDENTIFIED avatar Nov 22 '22 20:11 dgilmanAIDENTIFIED

I think this is by design, but you can swap out your own log format once CloudWatchLogHandler is instantiated like so:

class CloudWatchLogFormatter(logging.Formatter):
    def __init__(self, fmt: Optional[str] = None):
        super().__init__()
        if fmt:
            self._style._fmt = fmt
            self._style.validate()

    def format(self, record: logging.LogRecord) -> str:
        record.message = record.msg % record.args
        return self.formatMessage(record=record)

def main():
    cw_log_handler = CloudWatchLogHandler()
    cw_log_handler.setFormatter(CloudWatchLogFormatter(fmt=CLOUDWATCH_LOG_FORMATTING))

    logging.basicConfig(
        format=LOCAL_LOG_FORMATTING,
        level=logging.INFO,
        handlers=[cw_log_handler]
    )

Because I have CLOUDWATCH_LOG_FORMATTING set to "%(levelname)s - %(name)s - %(thread)d - %(message)s", it will display the logs how I want in CloudWatch.

I initially thought along your lines that it would include those log attributes in the log line similar to python's logging, but unfortunately, all it does is give you a JSON string.

riebecj avatar Feb 20 '23 22:02 riebecj

Thanks for reporting and contributing. I have a possible solution in #184 but I need to think through the implications.

kislyuk avatar Feb 22 '23 06:02 kislyuk

I recently encountered this problem. When I looked at how Python's built in Formatter handles this, it calls self.formatMessage to create the message string. I tried this with one of my applications and it's working great.

class CloudWatchLogFormatter(logging.Formatter):
...
    def format(self, message):
        if self.add_log_record_attrs:
            msg = message.msg if isinstance(message.msg, Mapping) else {"msg": self.formatMessage(message)}
            for field in self.add_log_record_attrs:
                if field != "msg":
                    msg[field] = getattr(message, field)  # type: ignore
            message.msg = msg
        if isinstance(message.msg, Mapping):
            return json.dumps(message.msg, default=self.json_serialize_default)
        return super().format(message)

WiredNerd avatar Aug 15 '24 15:08 WiredNerd