I was surprised to find practically nothing online regarding using structlog with journald. This seemed odd given how both are strong proponents of structured logging.
Update 2023-12-08: After writing this up, I did find this structlog issue, which seems to indicate there is interest in journald integration.
If you want to see how I got there, carry on reading. In the following notes I experiment with connecting structlog up to journald before converging onto a good solution. The end result is a Python application that uses structlog, and can be configured to send logs either to the console or journald. This allows the application to be run as a command line tool, or as a service under systemd.
Since I’m using systemd-python, somewhere I need to call journal.send() to send the logs to journald. The ideal place for that looks like it would be in a logger, which is created by a LoggerFactory. However I don’t quite have my head around how it all fits together, so let’s start by writing to journald from within the processor chain, since this looks like an easy way to get our hands on the log event.
So first let’s create a JournaldRenderer. All it does is takes the incoming event_dict, converts it into the right format for journald, then calls journal.send() to write to journald. Then it passes the event_dict unmodified on to the next processor in the chain. It’s a little bit sneaky, but it should work.
Next we need to call structlog.configure() somewhere and add our JournaldRenderer into the processors chain.
This is a very help example of a fast, production-ready use of structlog.configure(). We’ll use it as the starting point. Though instead of JSONRenderer we’ll use our JournaldRenderer, and instead of using structlog.BytesLoggerFactory() as the logger factory we’ll use structlog.PrintLoggerFactory(), which means our output will get printed to the console.
Then, in the very basic main loop for this application (created using these steps), we’ll call logging_setup() and do a bit of logging.
When run from the CLI, we can see the logs are formatted nicely by the logger which our PrintLoggerFactory() has created:
Then if we use journalctl, we can see it is logging to journald too:
Notice anything wrong?
This is a good first pass, but has a signficant issue: everything gets logged to journald twice, the latter of which is unstructured. This is because we are explicitly writing to journald with journal.send(), and then journald is helpfully collecting the logs from our logger created by PrintLoggerFactory(), which are unstructured.
First, we’ll update JournaldRenderer to add a journald_only option. If true, this will raise structlog.DropEventafter sending the log to journald, which will silently drop that log entry.
This way the log entry will always go to journald, but we can toggle whether logs make it to the console. This is not very efficient, but gets us a step closer (structlog has a clever solution for efficient dropping based on log level, which explains more about why filtering in this manner is not super efficient).
Next we need to update our configure code to pass the journald_only option through. This is just plumbing:
Then in our main application we can hard-code the journald_only to true or false as needed. (We’ll move it out to be configurable later):
Putting the calls to journal.send() inside JournaldRenderer within the list of processors feels like the wrong approach. The processors are intended to manipulate the log event, not perform I/O. That should be done by the logger.
In short, the value for logger_factory is just a callable. So you can supply a logger factory instance with a __call__ method. This is what LoggerFactory does: it just calls logger.getLogger(name), and automatically populates the name field for you. Alternatively, you can supply a logger class (i.e. pass in the class without the parentheses at the end ()). For example, you can do logger_factory=structlog.PrintLogger and the “factory” will create a new PrintLogger each time it is invoked.
Taking a closer look at PrintLogger, all we really need to do is inherit from that class and override the msg() method with one that calls journal.send() instead of print. Though after doing so, we must also replace log, debug, info, warn and warning with our new msg method, because they need overriding too. This is also what PrintLogger does:
Note how our msg() method expects two arguments:
msg_text: The “message” that gets logged to journald.
msg_dict: A dictionary of uppercase keys (i.e. custom journald fields) to values.
We need to modify our JournaldRenderer to pass the log event in this format (and remove the calls to journal.send() from the renderer):
The renderer is now much simpler. All it does is renames the event key to msg_text, and converts the rest of the keys to uppercase (as expected by journald) and packs them in msg_dict as a dictionary.
Configure logging to use custom logger and renderer §
Now we need to change the logging setup to support journald logging or console logging:
If journald, use JournaldRenderer() as the last processor (to output {"msg_text": "...", "msg_dict": {...}}), and JournaldLogger as the logger factory to send the logs entries to journald.
If console, use ConsoleRenderer() as the last processor (to output a string suitable for printing in the console), and structlog.PrintLoggerFactory() as the logger factory to use print to output the log entries.
Let’s replace the journald_only flag with the renderer option, which takes a string. Then we set the processors and logger factory as desired:
Finally, let’s change main function to set renderer using the first command line argument. This way we can do poetry run service-run console to log to console, and poetry run service-run journald to log to journald:
This use of sys.argv is brittle as it does no error handling, but it’s good enough for testing purposes.