Using structlog and journald
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.
To skip straight to my example implementation, jump to the last section of these notes.
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.
Table of contents §
The three attempts are:
- 🤔 First attempt: logging to journald from structlog from inside the processor chain (works, but not ideal).
- 😐 Second attempt: fixing the double logging issue from the first attempt (still not ideal).
- 🙂 Third attempt: implementing a custom journald logger (much better).
First attempt: logging to journald from structlog from inside the processor chain §
Implementation §
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.
Testing §
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.
Second attempt: fixing the double logging §
Let’s build upon the previous work, but fix the double logging.
Implementaton §
First, we’ll update JournaldRenderer
to add a journald_only
option. If true, this will raise structlog.DropEvent
after 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):
We’re now ready to test again.
Testing §
With journald_only=True
, now nothing gets logged to the console:
This is good.
If we use journalctl
to view journald logs, we do see logs.
Also good.
We have fixed the double logging issue, but the implementation is not ideal:
- The logging is done from within the processor chain, which still seems wrong.
- We can only toggle whether or not logs go to the console. Logs always go to journald. It would be nice to make this more flexible.
Third attempt: implementing a custom journald logger §
Let’s try and do things properly this time.
Implementation §
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.
It wasn’t clear to me at first, but after taking a closer look at this example of creating loggers manually, and reading that Logger Factories just need to be a callable, I felt more comfortable with the idea of creating my own logger factory, and putting the journal.send()
in the resulting 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.
Create a custom journald logger §
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.
Use a custom journald renderer §
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": {...}}
), andJournaldLogger
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), andstructlog.PrintLoggerFactory()
as the logger factory to useprint
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:
Allow configuring log renderer at launch §
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.
Testing §
Let’s run with console
as the argument, and check that we only log to the console:
Running journalctl
shows that nothing is being logged to journald, so we are only logging to the console. Great!
Now let’s try running with journald
as the argument:
Nothing coming out on the console. So far, so good. Let’s check journalctl
:
Great, it’s logging to journald. Let’s check the JSON output to verify our custom fields are coming through:
Looks like it all works!
Summary §
In summary, the best approach for logging to journald with structlog is:
- Create a custom journald logger, which calls
journal.send()
. - Create a custom journald renderer, which prepares the log entries for
journal.send()
. - Configure the logging to use our custom logger and renderer.
- (Optionally) If you want to be able to switch between logging to journald or the console, add a command line option to switch between the two.