Skip to content

← Config, Logs and Untrusted Input step 13 of 16

Hard Framework

Turn Log Fields into JSON-Safe Data

So far in this track, each log call has produced a line of text, such as order 17 failed after 3 retries. To find every failed order, someone has to parse that text. A structured log writes each fact as a named field instead, usually one JSON object per line: {"event": "order.failed", "order_id": 17, "retries": 3}.

Two things can go wrong when you log those fields. First, json.dumps raises TypeError on a value such as a Decimal or a date. mypy cannot warn you, because it accepts json.dumps on any dict[str, object]. Second, a field name can clash with a name the log record already uses. Logging's extra argument, which adds fields to a record, raises KeyError: "Attempt to overwrite 'module' in LogRecord" for a field called "module". Your function fixes both before you log anything.

build_log_payload(
    {"event": "created", "module": "billing"},
    frozenset({"module"}),
    {"request_id": "r1"},
)
# {"event": "created", "context": {"request_id": "r1"}, "_dropped": ["module"]}

What to write

Fill in build_log_payload(record_fields, reserved, context). It returns a new dict[str, object]. It does not log anything or build a JSON string.

  • Copy each entry of record_fields to the top level, with its value converted by the rules below. Do not change the input mappings.
  • Leave out a field whose name is in reserved or in ENVELOPE_KEYS, which holds "context" and "_dropped". Do not convert its value. Leave out only those names.
  • Put the names you left out under "_dropped", as a sorted list. Leave "_dropped" out when you dropped nothing.
  • If context is not empty, convert its values and put the result under "context". Leave "context" out when context is empty.
  • Check names only at the top level of record_fields. A nested key such as "module", or any key in context, is ordinary data and stays.

Convert each value

Try these rows in order, for every kept field value and every context value, at every level of nesting. Use isinstance, so subclasses match too.

Value Becomes
None, str, int, float or bool the value itself
a Mapping a new dict, with str(key) for each key and each value converted
a Sequence other than str, bytes and bytearray a new list of the converted items
anything else repr(value)

Some results to check against:

  • The tuple (1, "x") becomes [1, "x"].
  • b"raw" becomes "b'raw'", not a list of numbers.
  • Decimal("1.50") becomes "Decimal('1.50')", not a float or "1.50".
  • A date becomes "datetime.date(2026, 7, 28)".
  • Sets are not sequences, so frozenset({7}) becomes "frozenset({7})".
  • If two keys of one mapping turn into the same string, the later one in iteration order wins.

The driver

Keep solve and _INJECTABLE as they are. solve copies record_fields and context, then sets values that JSON cannot hold. Each entry of inject_record maps a field name to one of these keys of _INJECTABLE: decimal, date, bytes, frozenset, tuple or nested. solve sets that field to the value, replacing any field of the same name. inject does the same for context. Missing inputs default to empty, and reserved reaches your function as a frozenset. Your code must pass mypy --strict.