ЁЯПл The SchoolтА║ЁЯй║ ObservabilityтА║ЁЯУУ рдзрдбрд╛ 02 тАФ Logs: рдЖрд░реЛрдЧреНрдп рдХрдХреНрд╖рд╛рдЪреА рджреИрдирдВрджрд┐рдиреА
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯУУ рдзрдбрд╛ 02 тАФ Logs: рдЖрд░реЛрдЧреНрдп рдХрдХреНрд╖рд╛рдЪреА рджреИрдирдВрджрд┐рдиреА

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 12 рдкреИрдХреА рдзрдбрд╛ 02 ┬╖ рдорд╛рдЧреЗ: lesson-01-why-observability ┬╖ рдкреБрдвреЗ: lesson-03-metrics


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбрд╛ 01, рдЖрдгрд┐ рдкрд╣рд┐рд▓рд╛ signal: logs. рддреБрдореНрд╣реА structured logging (рдкреНрд░рддреНрдпреЗрдХ рдУрд│реАрдд рдПрдХ JSON object), log levels, рдкреНрд░рддреНрдпреЗрдХ рдУрд│реАрдд trace id рдХрд╛ рдЕрд╕рддреЛ, рдЖрдгрд┐ рдХрд╛рдп рдХрдзреАрдЪ рд▓рд┐рд╣реВ рдирдпреЗ рд╣реЗ рд╢рд┐рдХрддрд╛. obs/signals.py рдордзреАрд▓ log() рдЖрдгрд┐ query() log рдУрд│реА рд▓рд┐рд╣рд┐рддрд╛рдд рдЖрдгрд┐ рд╢реЛрдзрддрд╛рдд; obs/demo.py рдордзреАрд▓ logs() рддреНрдпрд╛ рдЪрд╛рд▓рд╡рддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдкреНрд░рддреНрдпреЗрдХ рд╡реЗрд│реА рдПрдЦрд╛рджрд╛ рд╡рд┐рджреНрдпрд╛рд░реНрдереА рдЖрд▓рд╛ рдХреА рдХрддрд░рд┐рдирд╛ рдЖрд░реЛрдЧреНрдп рдХрдХреНрд╖рд╛рдЪреНрдпрд╛ рджреИрдирдВрджрд┐рдиреА ЁЯУУ рдордзреНрдпреЗ рд▓рд┐рд╣рд┐рддреЗ.

рддрд┐рдЪреА рдкрд╣рд┐рд▓реА рджреИрдирдВрджрд┐рдиреА рдореЛрдХрд│реНрдпрд╛ рдордЬрдХреБрд░рд╛рдд рд╣реЛрддреА: "3A рдЪреА рдореБрд▓рдЧреА рдЖрд▓реА, рдереЛрдбреА рдЧрд░рдо, рдкрд╛рдгреА рджрд┐рд▓реЗ, рд╕рд╛рдзрд╛рд░рдг 11 рдЪреНрдпрд╛ рд╕реБрдорд╛рд░рд╛рд╕ рдкрд░рдд рдкрд╛рдард╡рд▓реЗ." рдирдВрддрд░ рджреАрдкрд┐рдХрд╛рдиреЗ рд╡рд┐рдЪрд╛рд░рд▓реЗ: "рдпрд╛ рдЖрдард╡рдбреНрдпрд╛рдд 3A рд╡рд░реНрдЧрд╛рддрд▓реЗ рдХрд┐рддреА рд╡рд┐рджреНрдпрд╛рд░реНрдереА рддрд╛рдкрд╛рдиреЗ рдЖрд▓реЗ?" рдХрддрд░рд┐рдирд╛рд▓рд╛ рдкреНрд░рддреНрдпреЗрдХ рдкрд╛рди рд╡рд╛рдЪрд╛рд╡реЗ рд▓рд╛рдЧрд▓реЗ.

рдореНрд╣рдгреВрди рдХрддрд░рд┐рдирд╛рдиреЗ рдкреНрд░рддреНрдпреЗрдХ рдУрд│реАрд╡рд░ рдЪреМрдХрдЯреА рдХрд╛рдврд▓реНрдпрд╛: рд╡реЗрд│ ┬╖ рдХрд┐рддреА рдЧрдВрднреАрд░ ┬╖ рдХрд╛рдп рдШрдбрд▓реЗ ┬╖ рд╡рд░реНрдЧ ┬╖ рд╡рд┐рджреНрдпрд╛рд░реНрдереНрдпрд╛рдЪрд╛ рдХрд╛рд░реНрдб рдХреНрд░рдорд╛рдВрдХ. рдЖрддрд╛ рддреЛ рдкреНрд░рд╢реНрди рдПрдХрд╛ рд╕реЗрдХрдВрджрд╛рдд рд╕реБрдЯрддреЛ: "рд╡рд░реНрдЧ" рдЪреМрдХрдЯреАрдд 3A рдЖрдгрд┐ "рдХрд╛рдп" рдЪреМрдХрдЯреАрдд рддрд╛рдк рд╢реЛрдзрд╛рдпрдЪрд╛.

рдЖрдгрдЦреА рджреЛрди рдирд┐рдпрдо. рдкреНрд░рддреНрдпреЗрдХ рд╡рд┐рджреНрдпрд╛рд░реНрдереНрдпрд╛рдХрдбреЗ рдПрдХ рдорд╛рд░реНрдЧ рдХрд╛рд░реНрдб рдХреНрд░рдорд╛рдВрдХ рдЕрд╕рддреЛ, рдЖрдгрд┐ рдХрддрд░рд┐рдирд╛ рддреЛ рдУрд│реАрд╡рд░ рд▓рд┐рд╣рд┐рддреЗ тАФ рдореНрд╣рдгрдЬреЗ рджреИрдирдВрджрд┐рдиреАрддрд▓реА рдУрд│ рдирдВрддрд░ рдХрд╛рд░реНрдбрд╛рд╢реА рдЬреБрд│рд╡рддрд╛ рдпреЗрддреЗ (рдзрдбрд╛ 05). рдЖрдгрд┐ рдХрддрд░рд┐рдирд╛ рджреИрдирдВрджрд┐рдиреАрдд рд╡рд┐рджреНрдпрд╛рд░реНрдереНрдпрд╛рдЪрд╛ рдШрд░рдЪрд╛ рдкрддреНрддрд╛ рдХрд┐рдВрд╡рд╛ рдкрд╛рд▓рдХрд╛рдВрдЪреЗ bank details рдХрдзреАрдЪ рд▓рд┐рд╣рд┐рдд рдирд╛рд╣реА. рджреИрдирдВрджрд┐рдиреА рдЕрдиреЗрдХ рд▓реЛрдХ рд╡рд╛рдЪрддрд╛рдд. рдЧреБрдкрд┐рддреЗ рддрд┐рдереЗ рдЬрд╛рдд рдирд╛рд╣реАрдд.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    app["ЁЯН│ results-api"] -->|"one JSON object per line"| line["ЁЯУУ {ts, level, msg,<br/>route, status, ms,<br/>trace_id}"]
    line --> store["ЁЯЧДя╕П log store<br/>Loki ┬╖ CloudWatch Logs ┬╖ Datadog"]
    store -->|"level=ERROR AND route=/results/3A"| two["2 lines<br/>trace ids a3ce929d, b7ad6b71"]
    two -.->|"trace_id joins"| tr["ЁЯЧ║я╕П the trace (lesson 05)"]
    no["ЁЯЪл never: passwords, tokens,<br/>card numbers, full personal data"]

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/observability/lesson-diagrams.html#l02

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг outage рджрд░рдореНрдпрд╛рди рдкреНрд░рд╢реНрди рдиреЗрд╣рдореА рдирд╡рд╛ рдЕрд╕рддреЛ. "11:30 рдкрд╛рд╕реВрди /results/3A рд╡рд░ рдХрд┐рддреА 504s?" "рдХреЛрдгрддреЗ trace ids?" "рдлрдХреНрдд 3A рд╡рд░реНрдЧ, рдХреА рд╕рдЧрд│реЗ рд╡рд░реНрдЧ?" рдореЛрдХрд│реНрдпрд╛ рдордЬрдХреБрд░рд╛рдд рдкреНрд░рддреНрдпреЗрдХ рдкреНрд░рд╢реНрди рдореНрд╣рдгрдЬреЗ рдирд╡реЗ regular expression рдЖрдгрд┐ рд╣рд│реВ scan. Structured logs рдордзреНрдпреЗ рдкреНрд░рддреНрдпреЗрдХ рдкреНрд░рд╢реНрди рдореНрд╣рдгрдЬреЗ рдПрдХрд╛ field рд╡рд░рдЪрд╛ filter. рдЖрдгрд┐ рдкреНрд░рддреНрдпреЗрдХ рдУрд│реАрдд trace id рдЕрд╕реЗрд▓ рддрд░ рдПрдХ рдЕрдпрд╢рд╕реНрд╡реА request рдХрд╛рд╣реА рд╕реЗрдХрдВрджрд╛рдВрдд рдкреНрд░рддреНрдпреЗрдХ service рдордзреВрди follow рдХрд░рддрд╛ рдпреЗрддреЗ.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

obs/signals.py рдордзреАрд▓ log(ts, level, msg, **fields) рдПрдХ JSON рдУрд│ рдкрд░рдд рдХрд░рддреЗ, рдЬреНрдпрд╛рдд рдЖрдзреА ts, level рдЖрдгрд┐ msg, рдордЧ рддреБрдордЪреА fields рдЕрд╕рддрд╛рдд. query(lines, **match) рдкреНрд░рддреНрдпреЗрдХ рдУрд│ parse рдХрд░рддреЗ рдЖрдгрд┐ рдЬреНрдпрд╛рдд рдкреНрд░рддреНрдпреЗрдХ field рдЬреБрд│рддреЗ рддреНрдпрд╛ рдареЗрд╡рддреЗ тАФ log search рдЪреА рдПрдХ рдЫреЛрдЯреА рдЖрд╡реГрддреНрддреА. obs/demo.py рдордзреАрд▓ logs() 11:40 рдкрд╛рд╕реВрдирдЪреНрдпрд╛ рдЪрд╛рд░ рдУрд│реА рд▓рд┐рд╣рд┐рддреЗ рдЖрдгрд┐ route=/results/3A рд╡рд░ level=ERROR рдорд╛рдЧрддреЗ.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

рдПрдХ field (cls) рдЬреЛрдбрд╛, рдПрдХ WARN рдУрд│ рдЬреЛрдбрд╛, рдордЧ рдореЛрдХрд│рд╛ рдордЬрдХреВрд░ рдкрдЯрдХрди рдЙрддреНрддрд░ рджреЗрдК рд╢рдХрд▓рд╛ рдирд╕рддрд╛ рдЕрд╢рд╛ fields рд╡рд░ query рдХрд░рд╛:

python3 obs/demo.py logs
python3 - <<'EOF'
import sys, json; sys.path.insert(0, "obs"); from signals import log, query
lines = [log("11:40:01", "INFO", "results viewed", route="/results/3A", status=200, ms=92, trace_id="4bf92f35", cls="3A"),
         log("11:40:02", "ERROR", "grade service timeout", route="/results/3A", status=504, ms=1000, trace_id="a3ce929d", cls="3A"),
         log("11:40:02", "WARN", "slow grade service", route="/results/3B", status=200, ms=870, trace_id="00f067aa", cls="3B"),
         log("11:40:03", "ERROR", "grade service timeout", route="/results/3A", status=504, ms=1000, trace_id="b7ad6b71", cls="3A")]
print(lines[2])
print("status=504         тЖТ", len(query(lines, status=504)), "lines")
print("cls=3B             тЖТ", [d["msg"] for d in query(lines, cls="3B")])
print("trace_id=a3ce929d  тЖТ", [(d["ts"], d["level"], d["msg"]) for d in query(lines, trace_id="a3ce929d")])
order = ["DEBUG", "INFO", "WARN", "ERROR"]
for floor in ("INFO", "WARN", "ERROR"):
    kept = [l for l in lines if order.index(json.loads(l)["level"]) >= order.index(floor)]
    print(f"keep level тЙе {floor:<5} тЖТ {len(kept)} of {len(lines)} lines")
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

logs рд╣реЗ print рдХрд░рддреЗ:

тФАтФА structured logs: one JSON object per line, not free text
   {"ts": "11:40:02", "level": "ERROR", "msg": "grade service timeout", "route": "/results/3A", "status": 504, "ms": 1000, "trace_id": "a3ce929d", "user": "parent-9"}
тФАтФА query level=ERROR route=/results/3A тЖТ 2 lines ┬╖ trace ids ['a3ce929d', 'b7ad6b71']
   levels: DEBUG < INFO < WARN < ERROR ┬╖ never log passwords, tokens or full personal data
   a request id / trace id in EVERY line is what joins logs to traces

рддреБрдордЪрд╛ snippet рд╣реЗ print рдХрд░рддреЛ:

{"ts": "11:40:02", "level": "WARN", "msg": "slow grade service", "route": "/results/3B", "status": 200, "ms": 870, "trace_id": "00f067aa", "cls": "3B"}
status=504         тЖТ 2 lines
cls=3B             тЖТ ['slow grade service']
trace_id=a3ce929d  тЖТ [('11:40:02', 'ERROR', 'grade service timeout')]
keep level тЙе INFO  тЖТ 4 of 4 lines
keep level тЙе WARN  тЖТ 3 of 4 lines
keep level тЙе ERROR тЖТ 2 of 4 lines

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдирд╡реЗ field (cls) рд▓рдЧреЗрдЪ рд╡рд┐рдЪрд╛рд░рддрд╛ рдпреЗрдгрд╛рд░рд╛ рдирд╡рд╛ рдкреНрд░рд╢реНрди рдмрдирд▓реЗ тАФ рдХреЛрдгрддреЗрд╣реА regular expression рдирд╛рд╣реА. Trace id рдиреЗ рдиреЗрдордХреА рдПрдХ request рд╡реЗрдЧрд│реА рдХрд╛рдврд▓реА. рдЖрдгрд┐ рдХрд┐рдорд╛рди level рдард░рд╡рддреЛ рдХреА рддреБрдореНрд╣реА рдХрд┐рддреА рдареЗрд╡рддрд╛: ERROR рд▓рд╛ рддреБрдореНрд╣реА рдЕрд░реНрдзреНрдпрд╛ рдУрд│реА рд╕рд╛рдард╡рддрд╛, рдкрдг grade service рд╣рд│реВ рд╣реЛрдд рдЪрд╛рд▓рд▓реА рдЖрд╣реЗ рд╣реЗ рд╕рд╛рдВрдЧрдгрд╛рд░реА WARN рдУрд│ рдЧрдорд╛рд╡рддрд╛ тАФ рдореНрд╣рдгрдЬреЗ рдкрд╣рд┐рд▓реА рдЦреВрдг.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

On a real account тАФ Python рдордзреВрди structlog рд╡рд╛рдкрд░реВрди JSON logs, redaction рдЪреНрдпрд╛ рдкрд╛рдпрд░реАрд╕рд╣ рдЖрдгрд┐ рд╕рдзреНрдпрд╛рдЪрд╛ OpenTelemetry trace id рдкреНрд░рддреНрдпреЗрдХ рдУрд│реАрдд рдЬреЛрдбреВрди:

import structlog
from opentelemetry import trace

SECRET_KEYS = {"password", "token", "authorization", "card_number"}

def redact(logger, method, event):
    for k in SECRET_KEYS & event.keys():
        event[k] = "[REDACTED]"
    return event

def add_trace_id(logger, method, event):
    ctx = trace.get_current_span().get_span_context()
    if ctx.is_valid:
        event["trace_id"] = format(ctx.trace_id, "032x")     # 32 hex characters
        event["span_id"] = format(ctx.span_id, "016x")       # 16 hex characters
    return event

structlog.configure(processors=[
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso", utc=True),
    add_trace_id,
    redact,
    structlog.processors.JSONRenderer(),
])
log = structlog.get_logger(service="results-api")
log.error("grade service timeout", route="/results/3A", status=504, duration_ms=1000, user="parent-9")

рдлрдХреНрдд standard logging module рд╡рд╛рдкрд░реВрди, рдПрдХ рдЫреЛрдЯрд╛ JSON formatter рддреЗрдЪ рдХрд╛рдо рдХрд░рддреЛ:

import json, logging
class JsonFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({"ts": self.formatTime(record), "level": record.levelname,
                           "msg": record.getMessage(), **getattr(record, "fields", {})})
handler = logging.StreamHandler(); handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.getLogger("results-api").warning("slow grade service", extra={"fields": {"route": "/results/3B", "ms": 870}})

CloudWatch Logs Insights рдордзреНрдпреЗ рддреЛрдЪ search:

fields @timestamp, level, msg, route, trace_id
| filter level = "ERROR" and route = "/results/3A"
| stats count(*) as errors by bin(1m)

Grafana Loki (LogQL) рдЖрдгрд┐ Datadog Logs рдордзреНрдпреЗ:

{service="results-api"} | json | level="ERROR" | route="/results/3A"
service:results-api status:error @route:"/results/3A"

ЁЯПн Production рдордзреНрдпреЗ рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: рд╕рд╛рдорд╛рдпрд┐рдХ field names (service, trace_id, route, status, duration_ms) рдПрдХрджрд╛рдЪ, рд╕рдЧрд│реНрдпрд╛ teams рд╕рд╛рдареА рдард░рд╡рд╛, рдЖрдгрд┐ redaction рд╕рд╛рдорд╛рдпрд┐рдХ logger рдордзреНрдпреЗ рдареЗрд╡рд╛ тАФ рдкреНрд░рддреНрдпреЗрдХ developer рдЪреНрдпрд╛ рдЖрдард╡рдгреАрд╡рд░ рд╕реЛрдбреВ рдирдХрд╛.

тПня╕П рдкреБрдвреЗ

рджреИрдирдВрджрд┐рдиреА рдПрдХрд╛ рднреЗрдЯреАрдЪреА рдЧреЛрд╖реНрдЯ рд╕рд╛рдВрдЧрддреЗ. рд╡реЗрд│реЗрдиреБрд╕рд╛рд░ рдХрд┐рддреА рдЖрдгрд┐ рдХрд┐рддреА рдЬрд▓рдж рд╣реЗ рдкрд╛рд╣рдгреНрдпрд╛рд╕рд╛рдареА рдХрддрд░рд┐рдирд╛рд▓рд╛ рдПрдХ рддрдХреНрддрд╛ рд▓рд╛рдЧрддреЛ.

git checkout lesson-03-metrics

ЁЯУУ Lesson 02 тАФ Logs: the health room diary

ЁЯУН You are here: Lesson 02 of 12 ┬╖ Previous: lesson-01-why-observability ┬╖ Next: lesson-03-metrics


ЁЯУж What's in this branch

Lesson 01, plus the first signal: logs. You learn structured logging (one JSON object per line), log levels, why every line carries a trace id, and what you must never write down. log() and query() in obs/signals.py write and search log lines; logs() in obs/demo.py runs them.

ЁЯзТ Explain like I'm 5

Katrina writes in the health room diary ЁЯУУ every time a pupil comes in.

Her first diary was free text: "girl from 3A came in, bit hot, gave her water, sent back maybe 11-ish." Later, Dipika asked: "How many pupils from class 3A came in with a fever this week?" Katrina had to read every page.

So Katrina drew boxes on each line: time ┬╖ how serious ┬╖ what happened ┬╖ class ┬╖ pupil card number. Now the question takes one second: look down the "class" box for 3A and the "what" box for fever.

Two more rules. Every pupil has a route card number, and Katrina writes it on the line тАФ so the diary line can be matched with the card later (lesson 05). And Katrina never writes a pupil's home address or a parent's bank details in the diary. Many people read the diary. Secrets do not go there.

ЁЯЧ║я╕П Diagram

flowchart LR
    app["ЁЯН│ results-api"] -->|"one JSON object per line"| line["ЁЯУУ {ts, level, msg,<br/>route, status, ms,<br/>trace_id}"]
    line --> store["ЁЯЧДя╕П log store<br/>Loki ┬╖ CloudWatch Logs ┬╖ Datadog"]
    store -->|"level=ERROR AND route=/results/3A"| two["2 lines<br/>trace ids a3ce929d, b7ad6b71"]
    two -.->|"trace_id joins"| tr["ЁЯЧ║я╕П the trace (lesson 05)"]
    no["ЁЯЪл never: passwords, tokens,<br/>card numbers, full personal data"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/observability/lesson-diagrams.html#l02

тЭУ What

ЁЯдФ Why

Because during an outage, the question is always new. "How many 504s on /results/3A since 11:30?" "Which trace ids?" "Only class 3A, or all classes?" With free text, each question is a new regular expression and a slow scan. With structured logs, each question is a filter on a field. And with a trace id in every line, one failed request can be followed across every service in seconds.

ЁЯФз How (in this repo)

log(ts, level, msg, **fields) in obs/signals.py returns one JSON line with ts, level and msg first, then your fields. query(lines, **match) parses each line and keeps those where every field matches тАФ a tiny version of a log search. logs() in obs/demo.py writes four lines from 11:40 and asks for level=ERROR on route=/results/3A.

ЁЯзк Try it

Add a field (cls), add a WARN line, then query by fields that free text could not answer quickly:

python3 obs/demo.py logs
python3 - <<'EOF'
import sys, json; sys.path.insert(0, "obs"); from signals import log, query
lines = [log("11:40:01", "INFO", "results viewed", route="/results/3A", status=200, ms=92, trace_id="4bf92f35", cls="3A"),
         log("11:40:02", "ERROR", "grade service timeout", route="/results/3A", status=504, ms=1000, trace_id="a3ce929d", cls="3A"),
         log("11:40:02", "WARN", "slow grade service", route="/results/3B", status=200, ms=870, trace_id="00f067aa", cls="3B"),
         log("11:40:03", "ERROR", "grade service timeout", route="/results/3A", status=504, ms=1000, trace_id="b7ad6b71", cls="3A")]
print(lines[2])
print("status=504         тЖТ", len(query(lines, status=504)), "lines")
print("cls=3B             тЖТ", [d["msg"] for d in query(lines, cls="3B")])
print("trace_id=a3ce929d  тЖТ", [(d["ts"], d["level"], d["msg"]) for d in query(lines, trace_id="a3ce929d")])
order = ["DEBUG", "INFO", "WARN", "ERROR"]
for floor in ("INFO", "WARN", "ERROR"):
    kept = [l for l in lines if order.index(json.loads(l)["level"]) >= order.index(floor)]
    print(f"keep level тЙе {floor:<5} тЖТ {len(kept)} of {len(lines)} lines")
EOF

тЬЕ Verify тАФ what you should see

logs prints:

тФАтФА structured logs: one JSON object per line, not free text
   {"ts": "11:40:02", "level": "ERROR", "msg": "grade service timeout", "route": "/results/3A", "status": 504, "ms": 1000, "trace_id": "a3ce929d", "user": "parent-9"}
тФАтФА query level=ERROR route=/results/3A тЖТ 2 lines ┬╖ trace ids ['a3ce929d', 'b7ad6b71']
   levels: DEBUG < INFO < WARN < ERROR ┬╖ never log passwords, tokens or full personal data
   a request id / trace id in EVERY line is what joins logs to traces

Your snippet prints:

{"ts": "11:40:02", "level": "WARN", "msg": "slow grade service", "route": "/results/3B", "status": 200, "ms": 870, "trace_id": "00f067aa", "cls": "3B"}
status=504         тЖТ 2 lines
cls=3B             тЖТ ['slow grade service']
trace_id=a3ce929d  тЖТ [('11:40:02', 'ERROR', 'grade service timeout')]
keep level тЙе INFO  тЖТ 4 of 4 lines
keep level тЙе WARN  тЖТ 3 of 4 lines
keep level тЙе ERROR тЖТ 2 of 4 lines

ЁЯПБ What you just proved

A new field (cls) became a new question you could ask at once тАФ no regular expression. The trace id picked out exactly one request. And the minimum level decides how much you keep: at ERROR you store half the lines, but you lose the WARN line that said the grade service was getting slow тАФ the early sign.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ JSON logs from Python with structlog, with a redaction step and the current OpenTelemetry trace id added to every line:

import structlog
from opentelemetry import trace

SECRET_KEYS = {"password", "token", "authorization", "card_number"}

def redact(logger, method, event):
    for k in SECRET_KEYS & event.keys():
        event[k] = "[REDACTED]"
    return event

def add_trace_id(logger, method, event):
    ctx = trace.get_current_span().get_span_context()
    if ctx.is_valid:
        event["trace_id"] = format(ctx.trace_id, "032x")     # 32 hex characters
        event["span_id"] = format(ctx.span_id, "016x")       # 16 hex characters
    return event

structlog.configure(processors=[
    structlog.contextvars.merge_contextvars,
    structlog.processors.add_log_level,
    structlog.processors.TimeStamper(fmt="iso", utc=True),
    add_trace_id,
    redact,
    structlog.processors.JSONRenderer(),
])
log = structlog.get_logger(service="results-api")
log.error("grade service timeout", route="/results/3A", status=504, duration_ms=1000, user="parent-9")

With the standard logging module only, a small JSON formatter does the same job:

import json, logging
class JsonFormatter(logging.Formatter):
    def format(self, record):
        return json.dumps({"ts": self.formatTime(record), "level": record.levelname,
                           "msg": record.getMessage(), **getattr(record, "fields", {})})
handler = logging.StreamHandler(); handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.getLogger("results-api").warning("slow grade service", extra={"fields": {"route": "/results/3B", "ms": 870}})

The same search in CloudWatch Logs Insights:

fields @timestamp, level, msg, route, trace_id
| filter level = "ERROR" and route = "/results/3A"
| stats count(*) as errors by bin(1m)

In Grafana Loki (LogQL) and Datadog Logs:

{service="results-api"} | json | level="ERROR" | route="/results/3A"
service:results-api status:error @route:"/results/3A"

ЁЯПн Why this matters in production: decide the shared field names (service, trace_id, route, status, duration_ms) once, for every team, and put the redaction in the shared logger тАФ not in each developer's memory.

тПня╕П Next

The diary tells the story of one visit. To see how many and how fast over time, Katrina needs a chart.

git checkout lesson-03-metrics
тЖР Previouswhy observabilityNext тЖТmetrics

This page is the lesson's README from the lesson-02-logs branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.