Plain prints are great for one-line scripts. Real automations need structured logs — one JSON object per line, parseable by any aggregator (Datadog, Loki, your own grep).
import json
def log(level, event, **fields):
line = {"level": level, "event": event, **fields}
print(json.dumps(line))
log("info", "step", n=1, action="fetch")
log("info", "step", n=2, action="filter", count=3)
log("info", "step", n=3, action="done", processed=2)Output:
{"level": "info", "event": "step", "n": 1, "action": "fetch"}
{"level": "info", "event": "step", "n": 2, "action": "filter", "count": 3}
{"level": "info", "event": "step", "n": 3, "action": "done", "processed": 2}
Every field is a key. Every line is a parseable record. Run cat log.txt | jq 'select(.action == "filter")' and you've got a query.
Why not use Python's logging module?
Real production code does. The stdlib logging adds level filtering, handlers (file, syslog, stdout), formatters, rotation. For learning, plain print(json.dumps(...)) keeps the content of the log line — the structure — front and center. You'll graduate to logging when the script needs filtering, files, or rotation; the field shape is the same.
And why round-trip through json.loads in the verification?
To confirm the line is parseable JSON. A common bug is forgetting json.dumps and printing the dict directly — {'level': 'info'} looks like JSON but isn't (single quotes). The round-trip catches that.
Plain text logs:
fetched 42 messages
filtered to 5 unread
sent 5 alerts
Looks fine until you need to ask:
None of these are answerable without parsing — and parsing prose is brittle. Structured logs make the analysis trivial:
{"level":"info","event":"fetch","count":42}
{"level":"info","event":"filter","count":5}
{"level":"info","event":"alert","count":5}
Now cat log | jq 'select(.event=="filter") | .count' gives you every fetch count, ever.
Three fields are nearly universal:
| Field | Type | Purpose |
|---|---|---|
level | info / warn / error | severity for filtering |
event | string | what happened — for grouping |
ts | ISO 8601 timestamp | when (often added by the log shipper, not the code) |
Everything else is event-specific (count, user_id, latency_ms, etc).
import json
def log(level, event, **fields):
print(json.dumps({"level": level, "event": event, **fields}))
log("info", "step", n=1, action="fetch")
log("warn", "slow_call", endpoint="/items", latency_ms=2400)
log("error", "handler_failed", event_type="item.created", err="ValueError")That's enough for most automations. Field names: snake_case. Avoid noise ("hello", "starting", "done") — every log line should answer a question.
latency_ms, total_msresult: "sent", result: "skipped"err: "ConnectionError" (not the full traceback)# before
print(f"step 1: got {len(messages)} messages")
# after
log("info", "step", n=1, action="fetch", count=len(messages))One extra import, three extra characters, and your logs are now machine-parseable.
Plain prints are great for one-line scripts. Real automations need structured logs — one JSON object per line, parseable by any aggregator (Datadog, Loki, your own grep).
import json
def log(level, event, **fields):
line = {"level": level, "event": event, **fields}
print(json.dumps(line))
log("info", "step", n=1, action="fetch")
log("info", "step", n=2, action="filter", count=3)
log("info", "step", n=3, action="done", processed=2)Output:
{"level": "info", "event": "step", "n": 1, "action": "fetch"}
{"level": "info", "event": "step", "n": 2, "action": "filter", "count": 3}
{"level": "info", "event": "step", "n": 3, "action": "done", "processed": 2}
Every field is a key. Every line is a parseable record. Run cat log.txt | jq 'select(.action == "filter")' and you've got a query.
Why not use Python's logging module?
Real production code does. The stdlib logging adds level filtering, handlers (file, syslog, stdout), formatters, rotation. For learning, plain print(json.dumps(...)) keeps the content of the log line — the structure — front and center. You'll graduate to logging when the script needs filtering, files, or rotation; the field shape is the same.
And why round-trip through json.loads in the verification?
To confirm the line is parseable JSON. A common bug is forgetting json.dumps and printing the dict directly — {'level': 'info'} looks like JSON but isn't (single quotes). The round-trip catches that.
Plain text logs:
fetched 42 messages
filtered to 5 unread
sent 5 alerts
Looks fine until you need to ask:
None of these are answerable without parsing — and parsing prose is brittle. Structured logs make the analysis trivial:
{"level":"info","event":"fetch","count":42}
{"level":"info","event":"filter","count":5}
{"level":"info","event":"alert","count":5}
Now cat log | jq 'select(.event=="filter") | .count' gives you every fetch count, ever.
Three fields are nearly universal:
| Field | Type | Purpose |
|---|---|---|
level | info / warn / error | severity for filtering |
event | string | what happened — for grouping |
ts | ISO 8601 timestamp | when (often added by the log shipper, not the code) |
Everything else is event-specific (count, user_id, latency_ms, etc).
import json
def log(level, event, **fields):
print(json.dumps({"level": level, "event": event, **fields}))
log("info", "step", n=1, action="fetch")
log("warn", "slow_call", endpoint="/items", latency_ms=2400)
log("error", "handler_failed", event_type="item.created", err="ValueError")That's enough for most automations. Field names: snake_case. Avoid noise ("hello", "starting", "done") — every log line should answer a question.
latency_ms, total_msresult: "sent", result: "skipped"err: "ConnectionError" (not the full traceback)# before
print(f"step 1: got {len(messages)} messages")
# after
log("info", "step", n=1, action="fetch", count=len(messages))One extra import, three extra characters, and your logs are now machine-parseable.
Create a free account to get started. Paid plans unlock all tracks.