I merged and improved upon both answers by re-using what is already in a structlog.
The approved answer used a custom json dump method that I want to avoid, the other solution has the side-effect of converting log from JSON to string, losing a lot of usefulness.
Complete example of mine that works both in local shell and remote server would be:
import collections
import logging
import sys
import orjson
import structlog
from structlog.typing import EventDict, WrappedLogger
class ForcedKeyOrderRenderer(structlog.processors.KeyValueRenderer):
"""Based upon KeyValueRenderer but returns dict instead of string."""
def __call__(self, _: WrappedLogger, __: str, event_dict: EventDict) -> str:
sorted_dict = self._ordered_items(event_dict)
return collections.OrderedDict(**{key: value for key, value in sorted_dict})
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=True),
]
if sys.stderr.isatty():
processors = shared_processors + [
structlog.processors.StackInfoRenderer(),
structlog.dev.set_exc_info,
structlog.dev.ConsoleRenderer(),
]
logger_factory = None
else: # pragma: no cover
processors = shared_processors + [
structlog.processors.format_exc_info,
structlog.processors.dict_tracebacks,
ForcedKeyOrderRenderer(
sort_keys=True,
key_order=[
"event",
"content_type",
"content_length",
"status_code",
],
drop_missing=True,
),
structlog.processors.JSONRenderer(serializer=orjson.dumps),
]
logger_factory = structlog.BytesLoggerFactory()
if not structlog.is_configured():
structlog.configure(
cache_logger_on_first_use=True,
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
processors=processors,
logger_factory=logger_factory,
)
logger = structlog.get_logger()