User's Guide ¶
Introduction¶
The primary reason for creating the certlib.log library was to
make it easier to configure structured logging across various
systems created and used by CERT Polska
– in a possibly consistent way and with minimal impact on
existing code.
However, despite a few opinionated defaults, the library is quite versatile, so it may prove useful for a much broader audience of developers and system administrators. Apart from the structured logging stuff, it also offers a few other features…
Note
certlib.log uses only the Python standard library, i.e.,
it does not depend on any third-party packages.
How to Install¶
You can install the certlib.log library by running the following
command (typically, you will do this within a Python virtual
environment):
python3 -m pip install certlib.log
The library is compatible with Python 3.10 and all newer versions of Python.
Note
The canonical name of the distribution package is certlib-log
(with a hyphen), but pip and other tools accept also the
certlib.log form (with a dot); the latter may feel more natural,
as it is also the importable module’s name (used in Python code).
TL;DR: How to Quickly Enable Structured Logging¶
-
Does your program already make use of the standard
loggingfacilities and do you want it to start emitting structured JSON-serialized log entries? Just make your configuration of logging includecertlib.log.StructuredLogsFormatteras a formatter. To do that easily, you may want to look at one of these examples (please also read the comments included there): -
The
system,componentandcomponent_typekeys (which you can see in those examples) are intended to be set with the following semantics in mind:-
system– the name of the entire system or project your script/application is part of; -
component– the name of a particular script or application being executed (for a CLI script it should be its basename); -
component_type– a conventional label of the type of that script or application, agreed upon in your organization (such as:"web","parser","collector"…).
-
-
That’s it! Everything else is optional (but probably worth a try, so you might want to read on).
Library Overview¶
The tools provided by certlib.log are intended for use with the standard
logging module’s toolset. Essentially, they enhance that toolset with
the following possibilities:
-
to emit structured log entries – each being a
dict, hereinafter referred to as output data (serialized in JSON format before actually being emitted); -
to permanently assign to selected output data keys: not only constant defaults, but also dynamic factories of values, hereinafter referred to as auto-makers; each auto-maker is just an argumentless function (or method, or other callable), automatically called to produce a value for the respective key – whenever a new log record object is created by a logger (which only occurs if the logger is enabled for the specified log level), before the log record is processed by any handlers, filters and formatters;
-
to replace the legacy
%-based style of log message formatting with the modern and more convenient{}-based one, or (when what you need to log is just data) to omit passing the text message altogether; both gained by giving a little tweak to each logger method call…
While it is possible to use each of these capabilities independently of
the others, the certlib.log’s stuff encourages combining them.
The following sections will discuss the two main tools provided by the
library: StructuredLogsFormatter and xm.
Tool: StructuredLogsFormatter¶
To make the standard logging module’s machinery able
to emit structured log entries (each being a JSON-serialized
dict), you need to configure it to employ an instance
of certlib.log.StructuredLogsFormatter as a formatter.
Note
Directly below it is shown how to do that in an imperative manner. You may prefer, however, a more declarative approach (especially if your program is not just a small script). In that case, please check out at least one of the following subsections (but first read everything above them as well!):
Basic Configuration¶
Let us start by creating our StructuredLogsFormatter instance
(obviously, the specific values used in the following code snippet
are just sample ones – they are supposed to be replaced with values
appropriate for your program/system):
import itertools
import json
import logging
import sys
from certlib.log import StructuredLogsFormatter
structured_logs_formatter = StructuredLogsFormatter(
defaults={
# Each key in this dict should be an *output data* key.
# Each value specifies the *default value* for that key.
# For example:
"system": "MyOwn",
"component": "Portal",
"component_type": "web",
"example_custom_default": 42,
},
auto_makers={
# Each key in this dict should be an *output data* key.
# Each value should be either some argumentless callable
# (function) or a *dotted path* to such a callable. In
# particular, that callable *may* be the `get()` method
# of some `ContextVar` instance (see
# https://docs.python.org/3/library/contextvars.html).
# For example:
# (here: dotted paths pointing to callables)
"client_ip": "myown.portal.client_ip_context_var.get",
"example_nano_time": "time.time_ns",
# (here: a callable passed directly)
"example_local_counter": itertools.count(1).__next__,
},
# The value of `serializer` should be either a callable (function)
# that accepts exactly one argument (being a JSON-serializable dict)
# and returns a str object, or a *dotted path* to such a callable.
# Note: the following serializer is the default -- so, in fact, you
# do not need to specify it. But the possibility to define a custom
# serializer comes in handy when you want to use, e.g., a faster
# alternative to the standard `json.dumps()` function (or even a
# tool which serializes data in some other format...).
serializer=json.dumps,
)
See also
You may also want to look at the reference documentation for
the StructuredLogsFormatter class.
Technically, each of the three keyword arguments accepted by the
StructuredLogsFormatter constructor is optional. However,
when it comes to the defaults and auto_makers ones,
you need to consider that:
-
It is required that each of the following keys appears in at least one of those two mappings (in
defaultsand/orauto_makers):-
"system"(the name of the entire system or project your script/application is part of; e.g.:"My Lovely System","MWDB","n6", etc.), -
"component"(the name of a particular script or application being executed; for a CLI script it should be its basename), -
"component_type"(a conventional label of the type of that script or application, agreed upon in your organization; e.g.:"web","parser","collector"…).- [maybe TBD: suggest a list of valid values of
component_type?]
- [maybe TBD: suggest a list of valid values of
Note
If it is OK for you/your organization that some (or all) of the output data items listed above will remain unspecified, you can provide such a
defaultsmapping in which some (or all) of the aforementioned keys will be mapped toNonevalues. Then, the said requirement will still be met, even though such void items will be automatically omitted from the ultimatedefaultscollection.See also:
StructuredLogsFormatter.get_output_keys_required_in_defaults_or_auto_makers(the method which defines the requirement in question). -
-
It is recommended (though not enforced) that each of the following keys, if it is relevant to the particular
component_type, appears in at least one of those two mappings (indefaultsand/orauto_makers, typically in the latter):-
"client_ip"(the real IP of the client who communicates with us; for this information to be reliable, the way it is obtained needs to follow best practices specific to the protocol being used; for example, when it comes to HTTP, see: https://httptoolkit.com/blog/what-is-x-forwarded-for); -
"user_id","request_id", etc. -
[TBD: more of the recommended output data keys to be suggested here].
-
Tip
If the presence of some output data key makes sense only in
a certain context (e.g., when handling a HTTP request…), just
make the respective auto-maker return None in any
other contexts. Such void items will be automatically omitted
from output data.
The next step is to prepare the root logger, and then add a handler to it with our formatter attached:
# (continuing with the previous example)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.name = "stderr"
stderr_handler.setFormatter(structured_logs_formatter) # <- Our formatter
root_logger.addHandler(stderr_handler)
See also
You may also want to take a look at the relevant parts of the
documentation for the standard logging module.
Basic Usage¶
OK. Once the stuff is configured, let us emit some structured log entries!
We can do that in the legacy (standard, yet old-fashioned) manner…
import datetime as dt
import logging
import sys
logger = logging.getLogger(__name__)
# [...]
logger.info("Hello world!")
logger.warning("Hello %s!", sys.platform)
logger.error(
"Here we have %x and %r.", sys.maxsize, sys.byteorder,
extra={
"example_stuff": [1, "foo", False],
"other_example_item": {42: dt.datetime.now()},
},
exc_info=True,
)
…or (better!) by making use of the certlib.log.xm tool:
import datetime as dt
import ipaddress
import logging
import sys
from certlib.log import xm
logger = logging.getLogger(__name__)
# [...]
logger.info(xm("Hello world!"))
logger.warning(xm("Hello {}!", sys.platform))
logger.error(xm(
"Here we have {:x} and {!r}.", sys.maxsize, sys.byteorder,
example_stuff=[1, "foo", False],
other_example_item={42: dt.datetime.now()},
exc_info=True,
))
pure_data_dict = {
'this': 123,
'that': ipaddress.IPv4Address('192.168.0.42'),
'there': 'example.com',
'then': dt.datetime(2026, 1, 2, 3, 4, 56, tzinfo=dt.timezone.utc),
}
logger.info(xm(pure_data_dict)) # <- No text message at all.
logger.warning(xm(
"{who} owns {fract:.2%} of all issues of {title!r} magazine.",
who="John",
fract=0.87239,
title="Bajtek",
first_issue_date=dt.date(1985, 9, 1),
))
See also
To learn more about using the xm tool, see this guide’s section
Tool: xm (below).
Regarding the last logger.warning(...) call in the example above, the
resultant JSON-serialized output data dict (i.e., the ultimate content
of the log entry to be emitted) could look like the following (note that
the serialized data presented here contains arbitrary example values for
many keys, and – just for visual clarity – we present it here as being
sorted by key, and with extra newlines/indentation):
{
"client_ip": "192.168.0.123",
"component": "Portal",
"component_type": "web",
"example_custom_default": 42,
"example_local_counter": 6,
"example_nano_time": 1771629287019638820,
"first_issue_date": "1985-09-01",
"fract": 0.87239,
"func": "<module>",
"level": "WARNING",
"levelno": 30,
"lineno": 253,
"logger": "myown.portal.example_module",
"message": "John owns 87.24% of all issues of 'Bajtek' magazine.",
"message_base": {
"pattern": "{who} owns {fract:.2%} of all issues of {title!r} magazine."
},
"pid": 324485,
"process_name": "MainProcess",
"py_ver": "3.14.3.final.0",
"script_args": [
"/opt/MyOwn/conf/web/portal.wsgi"
],
"src": "/opt/MyOwn/py/myown/portal/example_module.py",
"system": "MyOwn",
"thread_id": 139781835344768,
"thread_name": "MainThread",
"timestamp": "2026-02-20 23:14:47.019574Z",
"title": "Bajtek",
"who": "John"
}
Note
As you can see, the dt.date instance provided
as first_issue_date, before becoming an output data value,
was converted to a string – thanks to an automatic invocation of the
prepare_value method
(before the actual data serialization).
All output data values are subject to preparation by that method
(which processes them differenttly depending on their types…). By
extending/overriding it in your StructuredLogsFormatter
subclass you can gain full control over that preparation.
Nevertheless, you can get quite well just by sticking with the default implementation of that method.
logging.config.dictConfig-Style Configuration Example¶
import logging.config
logging_configuration_dict = {
"formatters": {
"structured": {
"()": "certlib.log.StructuredLogsFormatter",
"defaults": {
# Each key in this dict should be an *output data* key.
# Each value specifies the *default value* for that key.
# For example:
"system": "MyOwn",
"component": "Portal",
"component_type": "web",
"example_custom_default": 42
# ^ Important: by default, each of the "system", "component"
# and "component_type" keys is *required* to be included
# either *here* or in "auto_makers" (below). Note: *here*
# each of them can be assigned None -- if excluding this
# key from *output data* is OK for you/your organization.
},
"auto_makers": {
# Each key in this dict should be an *output data* key.
# Each value should be either some argumentless callable
# (function) or a *dotted path* to such a callable. In
# particular, that callable *may* be the `get()` method
# of some `ContextVar` instance (see
# https://docs.python.org/3/library/contextvars.html).
# For example:
"client_ip": "myown.portal.client_ip_context_var.get",
"example_nano_time": "time.time_ns"
},
# The value of "serializer", if specified, should be either
# a callable (function) which accepts exactly one argument
# (being a JSON-serializable dict) and returns a str object,
# or a *dotted path* to such a callable. If "serializer" is
# not specified, the standard `json.dumps()` function will
# be used.
"serializer": "some_package.faster_replacement_for_json_dumps"
}
},
"handlers": {
"stderr": {
"class": "logging.StreamHandler",
"formatter": "structured",
"stream": "ext://sys.stderr"
}
},
"root": {
"level": "INFO",
"handlers": ["stderr"]
},
"disable_existing_loggers": False,
"version": 1
}
logging.config.dictConfig(logging_configuration_dict)
Tip
Typically, applications load such a configuration dict from some file (usually in TOML, YAML or JSON format).
See also
You can learn more about the logging.config.dictConfig-specific
configuration dict schema by referring to the relevant
section
of the documentation for the standard logging.config module.
logging.config.fileConfig-Style Configuration Example¶
[loggers]
keys = root
[handlers]
keys = stderr
[formatters]
keys = structured
[logger_root]
level = INFO
handlers = stderr
[handler_stderr]
class = StreamHandler
formatter = structured
args = (sys.stderr,)
[formatter_structured]
class = certlib.log.StructuredLogsFormatter
format = {
"defaults": {
# Each key in this dict should be an *output data* key.
# Each value specifies the *default value* for that key.
# For example:
"system": "MyOwn",
"component": "Portal",
"component_type": "web",
"example_custom_default": 42,
# ^ Important: by default, each of the "system", "component"
# and "component_type" keys is *required* to be included
# either *here* or in "auto_makers" (below). Note: *here*
# each of them can be assigned None -- if excluding this
# key from *output data* is OK for you/your organization.
},
"auto_makers": {
# Each key in this dict should be an *output data* key.
# Each value should be a *dotted path* to some argumentless
# callable (function). In particular, that callable *may* be
# the `get()` method of some `ContextVar` instance (see
# https://docs.python.org/3/library/contextvars.html).
# For example:
"client_ip": "myown.portal.client_ip_context_var.get",
"example_nano_time": "time.time_ns",
},
# The value of "serializer", if specified, should be a *dotted path*
# to a callable (function) which accepts exactly one argument (being
# a JSON-serializable dict) and returns a str object. If "serializer"
# is not specified, the standard `json.dumps()` function will be used.
"serializer": "some_package.faster_replacement_for_json_dumps",
}
# ^ *Note:* all non-comment and non-blank continuation lines, *including*
# the one with the closing `}`, *must be indented* (by at least 1 space).
Tip
If logging.config.fileConfig is called by your code (rather
than automatically by some framework/library…), you may want to set
the disable_existing_loggers argument to False (because
if its default value, True, is in effect, then some loggers
created before that call may be turned off, which is usually not what
you want). This is a general advice (not specific to certlib.log).
See also
You can learn more about the logging.config.fileConfig-specific
configuration format by referring to the relevant
section
of the documentation for the standard logging.config module.
Tool: xm¶
Essentially, the purpose of xm is two-fold:
-
to make it more convenient to emit structured log entries (each being representable as a
dict), especially if aStructuredLogsFormatteris in use; -
if you choose the traditional text-message-focused style of logging (rather than a pure-data-focused, message-less one) – to easily replace the legacy
%-based log message formatting style with the modern and more convenient{}-based one (regardless of what formatter is in use).
Note
xm is just a convenience alias of ExtendedMessage
(the latter is the actual name of the class, but the former is
definitely more handy when you want to log a message or data).
Let examples speak…
Dealing with Pure Data¶
Below – a couple of examples of logging just some data (without the need to specify any text message).
import logging
from certlib.log import xm
logger = logging.getLogger(__name__)
# Logging pure data:
logger.info(xm(
some_key=["example", "data"],
another=lambda: 42, # (<- function/method: to be called by formatter)
yet_another={"abc": 1.0, "qwerty": [True, False]},
))
# Same as above, but here we pass our data just *as one dict*:
my_data = {
"some_key": ["example", "data"],
"another": lambda: 42, # (<- function/method: to be called by formatter)
"yet_another": {"abc": 1.0, "qwerty": [True, False]},
}
logger.info(xm(my_data))
Tip
Regarding the "another" item in the above examples as well as some
of the items/arguments that appear in the next subsection’s examples:
if you pass a function/method (also a lambda
expression) instead of a plain value, it will be automatically
called to obtain the actual value (at most once per xm
use, by a formatter of any type, that is, not necessarily by a
StructuredLogsFormatter).
In practice, this feature is useful if the creation of a certain value is costly – then you may prefer it to be created when (and if) the log entry is actually about to be formatted and emitted.
Note
By default, the mechanism is applied only if you pass a function or method object – not just an arbitrary callable (as that could lead to inadvertent calls).
Modern Formatting Style¶
Below there are a few examples of traditional text-message-focused
logging, but – what using the xm tool makes possible – with the
modern and convenient {}-based style of message formatting
(rather than the legacy, less convenient and less powerful, %-based one).
Note
What we are discussing here concerns the formatting of text
messages themselves (i.e., the contents of log records’ message),
rather than the formatting of entire log entries (where message
is just a field). Note that the latter is completely orthogonal to
the former. Whereas the standard tools provided by the logging
module do support
the {}-based formatting style for the latter, they do not support
it for the former.
import datetime as dt
import logging
from certlib.log import xm
logger = logging.getLogger(__name__)
some_name = "foo"
some_value = "Bar"
logger.info(xm(
"Note: {} is {!r} (in {:%Y-%m})",
some_name, some_value,
dt.date.today, # (<- function/method: to be called by formatter)
))
The resultant message will be: "Note: foo is 'Bar' (in 2026-02)"
(assuming that, for this particular example, the dt.date.today
class method would return an instance of dt.date
representing a February 2026 date, e.g., one equal to dt.date(2026,
2, 21)).
What that means if the logging system is configured to employ a
StructuredLogsFormatter, is that:
- the formatted message will appear in the JSON-serialized output
data as the item:
"message": "Note: foo is 'Bar' (in 2026-02)", - and the raw message pattern will also be included, like this:
"message_base": {"pattern": "Note: {} is {!r} (in {:%Y-%m})"}.
Info
When you use xm, you still benefit from the standard
mechanism of deferring message formatting until the log entry
really needs to be emitted (regardless of what formatter is in
use).
The code in the next example does the same as above; the only difference is that here the replacement fields in the message pattern are explicitly numbered:
logger.info(xm(
"Note: {0} is {1!r} (in {2:%Y-%m})",
some_name, some_value,
dt.date.today, # (<- function/method: to be called by formatter)
))
Below there is an example similar to the previous two, but with some of the replacement fields being named (and, therefore, with the corresponding keyword arguments specifying the values to be interpolated):
logger.info(xm(
"Note: {} is {val!r} (in {today:%Y-%m})",
some_name,
val=some_value,
today=dt.date.today, # (<- function/method: to be called by formatter)
))
It is worth noting that if a StructuredLogsFormatter is in use,
then any keyword arguments (named ones) passed to xm, apart
from being used to fill in the respective replacement fields, are also
included as output data items. For example, output data resulting
from the logger.info(...) call in the last example will contain, among
others, the following items:
"message": "Note: foo is 'Bar' (in 2026-02)","message_base": {"pattern": "Note: {} is {val!r} (in {today:%Y-%m})"},"val": "Bar","today": "2026-02-21".
And below there is almost the same call as previously, but with a couple of extra keyword arguments (conveying some additional data, unrelated to message formatting):
logger.info(xm(
"Note: {} is {val!r} (in {today:%Y-%m})",
some_name,
val=some_value,
today=dt.date.today, # (<- function/method: to be called...)
something=lambda: 123456789, # (<- function/method: to be called...)
something_more=(1, 2, 3, 4, True, None, {5: [6789, 10]}),
))
In this case, the resultant output data generated by the
StructuredLogsFormatter’s machinery will contain,
among others, the following items:
"message": "Note: foo is 'Bar' (in 2026-02)","message_base": {"pattern": "Note: {} is {val!r} (in {today:%Y-%m})"},"val": "Bar","today": "2026-02-21","something": 123456789,"something_more": [1, 2, 3, 4, true, null, {"5": [6789, 10]}].
The entire resultant JSON-serialized output data (i.e., the ultimate content of the log entry to be emitted) could look like the following (note that the serialized data presented here contains arbitrary example values for many keys, and – just for visual clarity – we present it here as being sorted by key, and with extra newlines/indentation):
{
"client_ip": "192.168.0.123",
"component": "Portal",
"component_type": "web",
"example_custom_default": 42,
"example_local_counter": 4,
"example_nano_time": 1771631594315719605,
"func": "<module>",
"level": "INFO",
"levelno": 20,
"lineno": 179,
"logger": "myown.portal.another_example_module",
"message": "Note: foo is 'Bar' (in 2026-02)",
"message_base": {
"pattern": "Note: {} is {val!r} (in {today:%Y-%m})"
},
"pid": 327578,
"process_name": "MainProcess",
"py_ver": "3.14.3.final.0",
"script_args": [
"/opt/MyOwn/conf/web/portal.wsgi"
],
"something": 123456789,
"something_more": [
1, 2, 3, 4, true, null, {
"5": [
6789, 10
]
}
],
"src": "/opt/MyOwn/py/myown/portal/another_example_module.py",
"system": "MyOwn",
"thread_id": 140062429502336,
"thread_name": "MainThread",
"timestamp": "2026-02-20 23:53:14.315296Z",
"today": "2026-02-21",
"val": "Bar"
}
See also
You may also want to look at the reference documentation for the
ExtendedMessage class (among other things, you will find
there information about three special arguments you can also pass
to xm – namely: exc_info, stack_info and
stacklevel).
Advanced Topics and Finer Points¶
More About StructuredLogsFormatter (Including Subclassing)¶
If you have not read the reference documentation for the
StructuredLogsFormatter class yet, you are strongly encouraged to
do so. Among other things, you will find there a list of hook methods
that can be extended/overridden in your subclasses. Apart from that, the
documentation in question includes (also in the individual descriptions
of those hook methods) valuable information about other elements of the
StructuredLogsFormatter’s interface and behavior.
Other Stuff Provided by certlib.log¶
Besides StructuredLogsFormatter and xm (ExtendedMessage),
the certlib.log module provides the following public stuff:
-
the
make_constant_value_providerfunction (a minor helper, useful when you need to create an auto-maker that will always return the same value); -
the
register_log_record_attr_auto_makerandunregister_log_record_attr_auto_makerfunctions (typically, they do not need to be used directly – see the reference documentation for each of them…); -
the
COMMONLY_EXPECTED_NON_STANDARD_OUTPUT_KEYSconstant (its value is returned by theStructuredLogsFormatter’s default implementation of theget_output_keys_required_in_defaults_or_auto_makersmethod); -
the
STANDARD_RECORD_ATTR_TO_OUTPUT_KEYconstant (defines the default mapping of the standard log record attribute names to actual output data keys; this mapping is used by theStructuredLogsFormatter’s default implementation of themake_base_record_attr_to_output_keymethod); -
a few static typing helpers (ancillary stuff you do not usually need to pay much attention to).
Roadmap Outline¶
Future ideas under consideration include:
-
StructuredLogsFormatter: add the ability to specify keys related to sensitive data – so that values assigned to them in output data will be automatically masked/anonymized. -
xm: add dedicated suport forpatternof typestring.templatelib.Template– instances of which can be created by evaluating t-strings (available in Python 3.14 and newer). Additionaly, to support passing such t-string-made template objects directly to logger methods, add an opt-in mechanism that will automatically wrap such templates inxmobjects (so that, e.g., you could just do:logger.info(t"2n is {2 * n}, not {sys.maxsize:x}").