Search code examples
pythonpython-3.xlogging

Cannot use the extra attribute with a custom formatter in Python with the Logging module


I have a new logger I am testing to be more fleshed out in a current Python project and even though I watched a very good video on the logging python package and I've read the documentation on it, I can't for the love of me make the extra argument work. I can't set it in my format and my custom formatter doesn't see it either. I am not sure where I am going wrong at this point because everything else works as intended and it as been very fun to implement.

Here is how I initialize my logger in my main.py file and two example line of me calling it to log a message:

logger = logging.getLogger(__name__)
hostname = socket.gethostname()
host_ip = subprocess.run([r"ip -4 addr show ztjlhzlhyj | grep -oP '(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'"], capture_output=True, text=True, shell=True).stdout.splitlines()[0].strip()
logging.LoggerAdapter(logging.getLogger(__name__), extra={"hostname": hostname, "ip": host_ip})

# Truncated code
logger.debug("Loading env...", extra={"hostname": hostname, "ip": host_ip}) # Example where I implicitly add the extra on the debug call.

# Truncated code
logger.error(e) # Example where I don't add it manually but where the LoggerAdapter should take care of it.

Here is my maxlogger.py file which declares my custom Formatter and Handlers:

LOG_RECORD_BUILTIN_ATTRS = {
    "args",
    "asctime",
    "created",
    "exc_info",
    "exc_text",
    "filename",
    "funcName",
    "levelname",
    "levelno",
    "lineno",
    "module",
    "msecs",
    "message",
    "msg",
    "name",
    "pathname",
    "process",
    "processName",
    "relativeCreated",
    "stack_info",
    "thread",
    "threadName",
    "taskName",
}


class MaxJSONFormatter(logging.Formatter):
    def __init__(self, *, fmt_keys: Optional[dict[str, str]] = None):
        super().__init__()
        with open("stupid_test.log", "a+") as f:
            f.write(json.dumps(fmt_keys))
            f.write("\n")
        self.fmt_keys = fmt_keys if fmt_keys is not None else dict()

    def format(self, record: logging.LogRecord) -> str:
        message = self._prepare_log_dict(record)
        return json.dumps(message, default=str)

    def _prepare_log_dict(self, record: logging.LogRecord) -> dict:
        always_fields = {
            "message": record.getMessage(),
            "timestamp": datetime.fromtimestamp(
                record.created, tz=timezone.utc
            ).isoformat()
        }

        if record.exc_info is not None:
            always_fields["exc_info"] = self.formatException(record.exc_info)

        if record.stack_info is not None:
            always_fields["stack_info"] = self.formatStack(record.stack_info)

        message = {
            key: msg_val
            if (msg_val := always_fields.pop(val, None)) is not None
            else getattr(record, val)
            for key, val in self.fmt_keys.items()
        }

        with open("stupid_test.log", "a+") as f:
            f.write(json.dumps(getattr(record, "extra"), default=None))
            f.write("\n")
            f.write(json.dumps(record))
            f.write("\n")

        # Need to figure out the extra tag not working
        message.update(always_fields)
        message.update(getattr(record, "extra", {}))

        for key, val in record.__dict__.items():
            if key not in LOG_RECORD_BUILTIN_ATTRS:
                message[key] = val

        with open("stupid_test.log", "a+") as f:
            f.write(json.dumps(message))
            f.write("\n")

        return message


class MaxDBHandler(logging.Handler):
    def __init__(self):
        super().__init__()
        load_dotenv()
        connection_string = f"DRIVER={{FreeTDS}};SERVERNAME={env('DB_SERVER')};DATABASE={env('DATABASE')};UID={env('DB_USERNAME')};PWD={env('DB_PASSWORD')};"
        # connectio_string ONLY FOR LOCAL TESTING ONLY
        # connection_string = f"DRIVER={{SQL Server}};SERVER={env('DB_SERVER')};DATABASE={env('DATABASE')};UID={env('DB_USERNAME')};PWD={env('DB_PASSWORD')};"
        self.db = pyodbc.connect(connection_string)

    def emit(self, record):
        with self.db.cursor() as cursor:
            cursor.execute("INSERT INTO app_log (log) VALUES (?)", (self.format(record),))


class MaxQueueHandler(QueueHandler):
    def __init__(self, handlers: list[str], respect_handler_level: bool):
        super().__init__(queue=multiprocessing.Queue(-1))
        self.handlers = handlers
        self.respect_handler_level = respect_handler_level

Here is my logging config file:

{
  "version": 1,
  "disable_existing_loggers": false,
  "formatters": {
    "simple": {
      "format": "[%(levelname)s]: %(message)s"
    },
    "detailed": {
      "format": "[%(levelname)s|%(module)s|%(lineno)d] - %(asctime)s: %(message)s",
      "datefmt": "%Y-%m-%dT%H:%M:%S%z"
    },
    "json": {
      "()": "maxlogger.MaxJSONFormatter",
      "fmt_keys": {
        "level": "levelname",
        "message": "message",
        "timestamp": "timestamp",
        "logger": "name",
        "module": "module",
        "function": "funcName",
        "line": "lineno",
        "thread_name": "threadName",
        "extra": "extra"
      }
    }
  },
  "handlers": {
    "stderr": {
      "class": "logging.StreamHandler",
      "level": "WARNING",
      "formatter": "detailed",
      "stream": "ext://sys.stderr"
    },
    "file": {
      "class": "logging.handlers.RotatingFileHandler",
      "level": "DEBUG",
      "formatter": "detailed",
      "filename": "logs/debug.log",
      "maxBytes": 104857600,
      "backupCount": 3
    },
    "db": {
      "()": "maxlogger.MaxDBHandler",
      "level": "WARNING",
      "formatter": "json"
    },
    "queue": {
      "()": "maxlogger.MaxQueueHandler",
      "handlers": [
        "stderr",
        "file",
        "db"
      ],
      "respect_handler_level": true
    }
  },
  "loggers": {
    "root": {
      "level": "DEBUG",
      "handlers": [
        "queue"
      ]
    }
  }
}

In my config, I can't add the ip and hostname attributes since the logger will throw an error at me for them not being defined -> ValueError: Formatting field not found in record: 'hostname'.

Is there something I am missing about how to use the extra attribute, from what I have read it should work in a pretty straightforward manner but I am failing to do anything with it. Thank you so much for all the help in figuring this one out.


Solution

  • You have to assign adapter to variable and later use this variable

    adapter = logging.LoggerAdapter(logger, ...) 
    
    adapter.error("Loading env...") 
    

    Adapter is only wrapper on original logger - so if you need access to original logger then you have to get adapter.logger. And it allows to set values only for this message

    adapter.logger.debug("Loading env...", extra={"hostname": "other", "ip": "8.8.8.8"})
    

    It allows to put another adapter on existing adapter - and then you may need to use new adapter for all messages.


    I didn't test it on your code but I created minimal working code
    so everyone can simply copy and run it.

    It uses extra values added in adapter (it needed new string to format output)
    but it doesn't use values explicitly added to message.

    import logging
    import socket
    import subprocess
    
    logging.basicConfig(level=logging.DEBUG)
    
    hostname = socket.gethostname()
    #host_ip = subprocess.run([r"ip -4 addr show | grep -oP '(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'"], capture_output=True, text=True, shell=True).stdout.splitlines()[0].strip()
    host_ip = subprocess.run([r"echo '127.0.0.1'"], capture_output=True, text=True, shell=True).stdout.splitlines()[0].strip()
    
    #logger = logging.getLogger(__name__)  # doesn't work - it doesn't have handlers
    logger = logging.getLogger()  # get it after `basicConfig` to get correct logger. OR create logger without using `basicConfig` 
    adapter = logging.LoggerAdapter(logger, extra={"hostname": hostname, "ip": host_ip})
    
    # replace formatting in handler(s)
    #format_string = '%(asctime)s - %(name)s - %(levelname)s - %(message)s - [extra: hostname=%(hostname)s, IP=%(ip)s]'
    format_string = '%(message)s [extra: hostname=%(hostname)s, IP=%(ip)s]'
    formatter = logging.Formatter(format_string)
    adapter.logger.handlers[0].setFormatter(formatter)
    
    # explicitly add the extra - it doesn't use new values but get all values
    adapter.debug("Loading env...", extra={"hostname": "other", "ip": "8.8.8.8"})
    
    # implicitly add the extra - 
    adapter.debug("Loading env...") 
    
    # using standard logger with explicitly added extra - it works and it uses new values
    logger.debug("Loading env...", extra={"hostname": "other", "ip": "8.8.8.8"})
    #adapter.logger.debug("Loading env...", extra={"hostname": "other", "ip": "8.8.8.8"})
     
    # using standard logger without extra - it doesn't work because it needs values for `%(hostname)s %(ip)s`
    #logger.debug("Loading env...")  # raise error because format_string has `%(hostname)s %(ip)s`
    #adapter.logger.debug("Loading env...")  # raise error because format_string has `%(hostname)s %(ip)s`
    
    
    try:
        1/0
    except Exception as e:
        adapter.error(e)
    

    Result:

    Loading env... [extra: hostname=notebook, IP=127.0.0.1]
    Loading env... [extra: hostname=notebook, IP=127.0.0.1]
    Loading env... [extra: hostname=other, IP=8.8.8.8]
    division by zero [extra: hostname=notebook, IP=127.0.0.1]