[{"data":1,"prerenderedAt":1054},["ShallowReactive",2],{"page-\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Fstructured-logging-that-survives-pytest-capture\u002F":3},{"id":4,"title":5,"body":6,"description":1020,"extension":1021,"meta":1022,"navigation":98,"path":1050,"seo":1051,"stem":1052,"__hash__":1053},"content\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Fstructured-logging-that-survives-pytest-capture\u002Findex.md","Structured Logging That Survives pytest Capture",{"type":7,"value":8,"toc":1009},"minimark",[9,26,32,37,60,64,208,247,323,486,490,504,521,540,544,547,585,599,670,674,681,702,709,720,787,791,798,828,839,845,849,921,925,937,950,967,971,1000,1005],[10,11,12,13,17,18,21,22,25],"p",{},"Structured logging is at its most useful when something goes wrong in a test — the fields say which request, which user, which retry — and that is exactly when it tends to be missing. A service configured to write JSON lines to stdout through structlog shows nothing in ",[14,15,16],"code",{},"caplog",". A handler created at import time with ",[14,19,20],{},"logging.StreamHandler(sys.stderr)"," writes to the real stderr, bypassing pytest's capture, so its output interleaves with the progress dots and is never attached to the failing test. Tests that try to assert \"a warning was logged with ",[14,23,24],{},"order_id=42","\" end up grepping rendered strings, which breaks the first time someone changes the renderer.",[10,27,28,29,31],{},"None of this is a pytest bug. pytest provides a capture handler on the root logger and redirects the standard streams; logs that go through the standard logging tree are captured, shown for failures and available to ",[14,30,16],{},". Logs that take another route are not. Getting structured logging to cooperate is a matter of making sure, in tests, every event goes through that tree — and then asserting on fields rather than strings.",[33,34,36],"h2",{"id":35},"prerequisites","Prerequisites",[38,39,40,51],"ul",{},[41,42,43,46,47,50],"li",{},[14,44,45],{},"pytest >= 8.0",", ",[14,48,49],{},"structlog >= 24.1"," (for the structlog parts).",[41,52,53,54,59],{},"Familiarity with ",[55,56,58],"a",{"href":57},"\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002F","Logging and observability for debugging",".",[33,61,63],{"id":62},"solution","Solution",[65,66,71],"pre",{"className":67,"code":68,"language":69,"meta":70,"style":70},"language-python shiki shiki-themes github-light github-dark","# app\u002Flogging_setup.py — one configuration used by the app and by tests.\nimport logging\nimport structlog\n\ndef configure(json: bool = True) -> None:\n    structlog.configure(\n        processors=[\n            structlog.contextvars.merge_contextvars,\n            structlog.processors.add_log_level,\n            structlog.processors.TimeStamper(fmt=\"iso\"),\n            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,\n        ],\n        logger_factory=structlog.stdlib.LoggerFactory(),     # → stdlib logging tree\n        wrapper_class=structlog.stdlib.BoundLogger,\n        cache_logger_on_first_use=False,                     # tests reconfigure\n    )\n    renderer = structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer()\n    handler = logging.StreamHandler()                        # resolves sys.stderr at emit time\n    handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=renderer))\n    root = logging.getLogger()\n    root.handlers[:] = [handler]\n    root.setLevel(logging.INFO)\n","python","",[14,72,73,81,87,93,100,106,112,118,124,130,136,142,148,154,160,166,172,178,184,190,196,202],{"__ignoreMap":70},[74,75,78],"span",{"class":76,"line":77},"line",1,[74,79,80],{},"# app\u002Flogging_setup.py — one configuration used by the app and by tests.\n",[74,82,84],{"class":76,"line":83},2,[74,85,86],{},"import logging\n",[74,88,90],{"class":76,"line":89},3,[74,91,92],{},"import structlog\n",[74,94,96],{"class":76,"line":95},4,[74,97,99],{"emptyLinePlaceholder":98},true,"\n",[74,101,103],{"class":76,"line":102},5,[74,104,105],{},"def configure(json: bool = True) -> None:\n",[74,107,109],{"class":76,"line":108},6,[74,110,111],{},"    structlog.configure(\n",[74,113,115],{"class":76,"line":114},7,[74,116,117],{},"        processors=[\n",[74,119,121],{"class":76,"line":120},8,[74,122,123],{},"            structlog.contextvars.merge_contextvars,\n",[74,125,127],{"class":76,"line":126},9,[74,128,129],{},"            structlog.processors.add_log_level,\n",[74,131,133],{"class":76,"line":132},10,[74,134,135],{},"            structlog.processors.TimeStamper(fmt=\"iso\"),\n",[74,137,139],{"class":76,"line":138},11,[74,140,141],{},"            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,\n",[74,143,145],{"class":76,"line":144},12,[74,146,147],{},"        ],\n",[74,149,151],{"class":76,"line":150},13,[74,152,153],{},"        logger_factory=structlog.stdlib.LoggerFactory(),     # → stdlib logging tree\n",[74,155,157],{"class":76,"line":156},14,[74,158,159],{},"        wrapper_class=structlog.stdlib.BoundLogger,\n",[74,161,163],{"class":76,"line":162},15,[74,164,165],{},"        cache_logger_on_first_use=False,                     # tests reconfigure\n",[74,167,169],{"class":76,"line":168},16,[74,170,171],{},"    )\n",[74,173,175],{"class":76,"line":174},17,[74,176,177],{},"    renderer = structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer()\n",[74,179,181],{"class":76,"line":180},18,[74,182,183],{},"    handler = logging.StreamHandler()                        # resolves sys.stderr at emit time\n",[74,185,187],{"class":76,"line":186},19,[74,188,189],{},"    handler.setFormatter(structlog.stdlib.ProcessorFormatter(processor=renderer))\n",[74,191,193],{"class":76,"line":192},20,[74,194,195],{},"    root = logging.getLogger()\n",[74,197,199],{"class":76,"line":198},21,[74,200,201],{},"    root.handlers[:] = [handler]\n",[74,203,205],{"class":76,"line":204},22,[74,206,207],{},"    root.setLevel(logging.INFO)\n",[65,209,211],{"className":67,"code":210,"language":69,"meta":70,"style":70},"# conftest.py\nimport pytest\nfrom app.logging_setup import configure\n\n@pytest.fixture(autouse=True, scope=\"session\")\ndef _logging():\n    configure(json=False)            # readable output in failure reports\n",[14,212,213,218,223,228,232,237,242],{"__ignoreMap":70},[74,214,215],{"class":76,"line":77},[74,216,217],{},"# conftest.py\n",[74,219,220],{"class":76,"line":83},[74,221,222],{},"import pytest\n",[74,224,225],{"class":76,"line":89},[74,226,227],{},"from app.logging_setup import configure\n",[74,229,230],{"class":76,"line":95},[74,231,99],{"emptyLinePlaceholder":98},[74,233,234],{"class":76,"line":102},[74,235,236],{},"@pytest.fixture(autouse=True, scope=\"session\")\n",[74,238,239],{"class":76,"line":108},[74,240,241],{},"def _logging():\n",[74,243,244],{"class":76,"line":114},[74,245,246],{},"    configure(json=False)            # readable output in failure reports\n",[65,248,250],{"className":67,"code":249,"language":69,"meta":70,"style":70},"# test_orders.py — asserting on fields, two ways.\nimport logging\nimport structlog\n\ndef test_rejected_order_logs_reason(caplog):\n    caplog.set_level(logging.WARNING, logger=\"app.orders\")\n    submit_order(order_id=42, qty=0)\n    [rec] = [r for r in caplog.records if r.name == \"app.orders\"]\n    assert rec.msg[\"event\"] == \"order_rejected\"\n    assert rec.msg[\"order_id\"] == 42\n\ndef test_rejected_order_logs_reason_capture_logs():\n    with structlog.testing.capture_logs() as events:\n        submit_order(order_id=42, qty=0)\n    assert {\"event\": \"order_rejected\", \"order_id\": 42, \"reason\": \"qty\"}.items() \u003C= events[0].items()\n",[14,251,252,257,261,265,269,274,279,284,289,294,299,303,308,313,318],{"__ignoreMap":70},[74,253,254],{"class":76,"line":77},[74,255,256],{},"# test_orders.py — asserting on fields, two ways.\n",[74,258,259],{"class":76,"line":83},[74,260,86],{},[74,262,263],{"class":76,"line":89},[74,264,92],{},[74,266,267],{"class":76,"line":95},[74,268,99],{"emptyLinePlaceholder":98},[74,270,271],{"class":76,"line":102},[74,272,273],{},"def test_rejected_order_logs_reason(caplog):\n",[74,275,276],{"class":76,"line":108},[74,277,278],{},"    caplog.set_level(logging.WARNING, logger=\"app.orders\")\n",[74,280,281],{"class":76,"line":114},[74,282,283],{},"    submit_order(order_id=42, qty=0)\n",[74,285,286],{"class":76,"line":120},[74,287,288],{},"    [rec] = [r for r in caplog.records if r.name == \"app.orders\"]\n",[74,290,291],{"class":76,"line":126},[74,292,293],{},"    assert rec.msg[\"event\"] == \"order_rejected\"\n",[74,295,296],{"class":76,"line":132},[74,297,298],{},"    assert rec.msg[\"order_id\"] == 42\n",[74,300,301],{"class":76,"line":138},[74,302,99],{"emptyLinePlaceholder":98},[74,304,305],{"class":76,"line":144},[74,306,307],{},"def test_rejected_order_logs_reason_capture_logs():\n",[74,309,310],{"class":76,"line":150},[74,311,312],{},"    with structlog.testing.capture_logs() as events:\n",[74,314,315],{"class":76,"line":156},[74,316,317],{},"        submit_order(order_id=42, qty=0)\n",[74,319,320],{"class":76,"line":162},[74,321,322],{},"    assert {\"event\": \"order_rejected\", \"order_id\": 42, \"reason\": \"qty\"}.items() \u003C= events[0].items()\n",[324,325,328,482],"figure",{"className":326},[327],"diagram",[329,330,337,338,337,342,337,346,337,370,337,378,337,388,337,396,337,402,337,405,337,409,337,414,337,418,337,427,337,433,337,439,337,447,337,450,337,454,337,458,337,462,337,467,337,473,337,477],"svg",{"viewBox":331,"role":332,"ariaLabelledBy":333,"xmlns":336},"0 0 800 256","img",[334,335],"sl-t","sl-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[339,340,341],"title",{"id":334},"Log routes that pytest can and cannot see",[343,344,345],"desc",{"id":335},"Three routes for a log event. structlog's default PrintLogger writes straight to stdout and never reaches caplog. A handler bound to sys.stderr at import time bypasses capture. Routing structlog through the stdlib logging tree reaches the root logger, where pytest's capture handler and caplog see every event.",[347,348,349,350,349,364,337],"defs",{},"\n    ",[351,352,359],"marker",{"id":353,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"sl-a","0 0 10 10","9","5","7","auto-start-reverse",[360,361],"path",{"d":362,"fill":363},"M0 0 L10 5 L0 10 z","#81b29a",[351,365,367],{"id":366,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"sl-b",[360,368],{"d":362,"fill":369},"#e07a5f",[371,372],"rect",{"x":373,"y":373,"width":374,"height":375,"rx":376,"fill":377},"0","800","256","14","#fffdf8",[379,380,387],"text",{"x":381,"y":382,"textAnchor":383,"fontSize":384,"fontWeight":385,"fill":386},"400","28","middle","15.5","700","#3d405b","Only one route reaches caplog",[371,389],{"x":390,"y":391,"width":392,"height":393,"rx":355,"fill":394,"stroke":369,"strokeWidth":395},"26","52","200","40","#fbe9e3","1.8",[379,397,401],{"x":398,"y":399,"textAnchor":383,"fontSize":400,"fill":386},"126","77","11","structlog PrintLogger",[371,403],{"x":390,"y":404,"width":392,"height":393,"rx":355,"fill":394,"stroke":369,"strokeWidth":395},"108",[379,406,408],{"x":398,"y":407,"textAnchor":383,"fontSize":400,"fill":386},"133","handler(sys.stderr) at import",[371,410],{"x":390,"y":411,"width":392,"height":393,"rx":355,"fill":412,"stroke":363,"strokeWidth":413},"164","#e6f0ea","2",[379,415,417],{"x":398,"y":416,"textAnchor":383,"fontSize":400,"fill":386},"189","structlog → stdlib logger",[371,419],{"x":420,"y":421,"width":422,"height":423,"rx":424,"fill":425,"stroke":426},"330","60","180","80","10","#f4f1de","rgba(61,64,91,0.4)",[379,428,432],{"x":429,"y":430,"textAnchor":383,"fontSize":431,"fontWeight":385,"fill":386},"420","94","11.5","real stdout\u002Fstderr",[379,434,438],{"x":429,"y":435,"textAnchor":383,"fontSize":436,"fill":437},"114","10.5","#8f3d22","not attached to test",[76,440],{"x1":441,"y1":442,"x2":443,"y2":444,"stroke":369,"strokeWidth":445,"markerEnd":446},"230","72","326","88","1.6","url(#sl-b)",[76,448],{"x1":441,"y1":449,"x2":443,"y2":435,"stroke":369,"strokeWidth":445,"markerEnd":446},"128",[371,451],{"x":420,"y":452,"width":422,"height":453,"rx":424,"fill":412,"stroke":363,"strokeWidth":413},"160","50",[379,455,457],{"x":429,"y":456,"textAnchor":383,"fontSize":431,"fontWeight":385,"fill":386},"190","root logger",[76,459],{"x1":441,"y1":460,"x2":443,"y2":460,"stroke":363,"strokeWidth":395,"markerEnd":461},"184","url(#sl-a)",[371,463],{"x":464,"y":465,"width":460,"height":466,"rx":400,"fill":386},"590","150","70",[379,468,472],{"x":469,"y":470,"textAnchor":383,"fontSize":471,"fontWeight":385,"fill":377},"682","178","12","pytest capture",[379,474,476],{"x":469,"y":475,"textAnchor":383,"fontSize":436,"fill":377},"198","caplog · failure report",[76,478],{"x1":479,"y1":480,"x2":481,"y2":480,"stroke":363,"strokeWidth":395,"markerEnd":461},"514","185","586",[483,484,485],"figcaption",{},"Anything that bypasses the standard logging tree is invisible to caplog and missing from failure reports.",[33,487,489],{"id":488},"why-this-works","Why this works",[10,491,492,493,495,496,499,500,503],{},"pytest's logging plugin adds two handlers to the root logger for each test: one that feeds ",[14,494,16],{},", and one that collects records for the report section shown on failure. Any record that propagates to the root logger reaches both. ",[14,497,498],{},"structlog.stdlib.LoggerFactory()"," makes every structlog logger a thin wrapper around a standard ",[14,501,502],{},"logging.Logger",", so structlog events become log records, propagate, and are captured like any other.",[10,505,506,509,510,513,514,517,518,520],{},[14,507,508],{},"ProcessorFormatter.wrap_for_formatter"," keeps the event dictionary intact on the record — as ",[14,511,512],{},"record.msg"," — until a handler's formatter renders it. That is why the first test can read ",[14,515,516],{},"rec.msg[\"order_id\"]"," directly: the structure survives until rendering, and ",[14,519,16],{},"'s handler does not render. The JSON renderer only runs in the stream handler, so production output is unchanged.",[10,522,523,524,527,528,530,531,534,535,539],{},"The stream handler is created without an explicit stream, which makes it resolve ",[14,525,526],{},"sys.stderr"," at emit time rather than at construction. When pytest swaps ",[14,529,526],{}," for its capture buffer, the handler follows. A handler constructed as ",[14,532,533],{},"StreamHandler(sys.stderr)"," at import captures the ",[536,537,538],"em",{},"original"," stream object and keeps writing to the terminal regardless.",[33,541,543],{"id":542},"seeing-logs-while-debugging","Seeing logs while debugging",[10,545,546],{},"Captured logs appear in the \"Captured log call\" section of a failing test's report, which is usually enough. When debugging a hang or a slow test, it is more useful to watch logs live:",[65,548,552],{"className":549,"code":550,"language":551,"meta":70,"style":70},"language-bash shiki shiki-themes github-light github-dark","pytest -o log_cli=true -o log_cli_level=DEBUG -k test_checkout -s\n","bash",[14,553,554],{"__ignoreMap":70},[74,555,556,560,564,568,571,573,576,579,582],{"class":76,"line":77},[74,557,559],{"class":558},"sScJk","pytest",[74,561,563],{"class":562},"sj4cs"," -o",[74,565,567],{"class":566},"sZZnC"," log_cli=",[74,569,570],{"class":562},"true",[74,572,563],{"class":562},[74,574,575],{"class":566}," log_cli_level=DEBUG",[74,577,578],{"class":562}," -k",[74,580,581],{"class":566}," test_checkout",[74,583,584],{"class":562}," -s\n",[10,586,587,590,591,594,595,598],{},[14,588,589],{},"log_cli"," streams records to the terminal as they are emitted, with its own format controlled by ",[14,592,593],{},"log_cli_format",". Combined with the console renderer in tests, the output is readable key-value lines rather than dense JSON. For CI, where logs are read after the fact, keep the default capture and add ",[14,596,597],{},"-rA"," to include captured logs for passing tests in the summary when needed.",[324,600,602,667],{"className":601},[327],[329,603,337,608,337,611,337,614,337,617,337,620,337,624,337,630,337,634,337,639,337,644,337,647,337,650,337,654,337,658,337,661,337,664],{"viewBox":604,"role":332,"ariaLabelledBy":605,"xmlns":336},"0 0 800 226",[606,607],"slv-t","slv-d",[339,609,610],{"id":606},"Choosing how to view test logs",[343,612,613],{"id":607},"Three viewing modes are compared. Default capture shows logs only for failing tests in the report. log_cli streams logs live to the terminal, useful for hangs. The -rA flag adds captured logs for passing tests to the end-of-run summary, useful in CI archives.",[371,615],{"x":373,"y":373,"width":374,"height":616,"rx":376,"fill":377},"226",[379,618,619],{"x":381,"y":382,"textAnchor":383,"fontSize":384,"fontWeight":385,"fill":386},"Three ways to read the same records",[371,621],{"x":390,"y":453,"width":622,"height":623,"rx":471,"fill":412,"stroke":363,"strokeWidth":413},"236","152",[379,625,629],{"x":626,"y":627,"textAnchor":383,"fontSize":628,"fontWeight":385,"fill":386},"144","78","12.5","default capture",[379,631,633],{"x":626,"y":632,"textAnchor":383,"fontSize":436,"fill":386},"110","shown for failures only",[379,635,638],{"x":626,"y":636,"textAnchor":383,"fontSize":436,"fontWeight":385,"fill":637},"170","#2a5f49","everyday runs",[371,640],{"x":641,"y":453,"width":622,"height":623,"rx":471,"fill":642,"stroke":643,"strokeWidth":413},"282","#f7f0da","#f2cc8f",[379,645,646],{"x":381,"y":627,"textAnchor":383,"fontSize":628,"fontWeight":385,"fill":386},"log_cli=true",[379,648,649],{"x":381,"y":632,"textAnchor":383,"fontSize":436,"fill":386},"streamed live",[379,651,653],{"x":381,"y":636,"textAnchor":383,"fontSize":436,"fontWeight":385,"fill":652},"#8a5a00","hangs and slow tests",[371,655],{"x":656,"y":453,"width":622,"height":623,"rx":471,"fill":425,"stroke":386,"strokeWidth":657},"538","1.5",[379,659,597],{"x":660,"y":627,"textAnchor":383,"fontSize":628,"fontWeight":385,"fill":386},"656",[379,662,663],{"x":660,"y":632,"textAnchor":383,"fontSize":436,"fill":386},"passing tests too",[379,665,666],{"x":660,"y":636,"textAnchor":383,"fontSize":436,"fontWeight":385,"fill":386},"CI archives",[483,668,669],{},"All three read the same captured records; only the moment and place they are shown differ.",[33,671,673],{"id":672},"asserting-on-logs-without-making-tests-brittle","Asserting on logs without making tests brittle",[10,675,676,677,680],{},"Once logs are capturable, there is a temptation to assert on all of them, and suites that do end up breaking every time someone rewords a message. Log assertions are worth writing only where the log ",[536,678,679],{},"is"," the behaviour: an audit event that compliance depends on, a warning that operators alert on, a security event that must include the actor's identity. Everywhere else, logs are diagnostics, and tests should not pin them.",[10,682,683,684,46,687,690,691,694,695,698,699,701],{},"When a log is part of the contract, assert on the fields that carry meaning and ignore the rest. The subset comparison in the second test above — the expected dictionary's items are a subset of the event's items — does exactly that: it checks ",[14,685,686],{},"event",[14,688,689],{},"order_id"," and ",[14,692,693],{},"reason",", and stays green when someone adds a ",[14,696,697],{},"request_id"," or changes the timestamp format. Assert on the ",[14,700,686],{}," name rather than a human-readable message; event names are identifiers and change rarely, messages are prose and change often.",[10,703,704,705,708],{},"For negative assertions — \"no error was logged during this operation\" — filter by level rather than by content: ",[14,706,707],{},"assert not [r for r in caplog.records if r.levelno >= logging.ERROR]",". That catches unexpected errors from any logger, including libraries, which is often the most valuable log assertion a test can make. An integration test that passes while the HTTP client logged three connection-reset errors is hiding a problem, and a level-based check surfaces it without knowing anything about the client's messages.",[10,710,711,712,715,716,719],{},"A shared helper keeps these patterns consistent across a suite. A fixture that yields ",[14,713,714],{},"structlog.testing.capture_logs()"," events, plus a function ",[14,717,718],{},"assert_logged(events, event=..., **fields)"," that performs the subset match and prints all captured events on failure, makes the intent of each assertion obvious and the failure output useful.",[324,721,723,784],{"className":722},[327],[329,724,337,729,337,732,337,735,337,737,337,740,337,743,337,748,337,753,337,757,337,761,337,764,337,767,337,771,337,775,337,778,337,781],{"viewBox":725,"role":332,"ariaLabelledBy":726,"xmlns":336},"0 0 800 236",[727,728],"sla-t","sla-d",[339,730,731],{"id":727},"What to assert about logs",[343,733,734],{"id":728},"Two columns contrast log assertions. Worth asserting: event names and meaningful fields for audit, alerting and security events, and the absence of error-level records. Not worth asserting: full rendered message strings, timestamps, field order and incidental diagnostic logs.",[371,736],{"x":373,"y":373,"width":374,"height":622,"rx":376,"fill":377},[379,738,739],{"x":381,"y":382,"textAnchor":383,"fontSize":384,"fontWeight":385,"fill":386},"Pin the contract, not the prose",[371,741],{"x":390,"y":453,"width":742,"height":411,"rx":471,"fill":412,"stroke":363,"strokeWidth":413},"360",[379,744,747],{"x":745,"y":746,"textAnchor":383,"fontSize":628,"fontWeight":385,"fill":386},"206","76","assert",[379,749,752],{"x":750,"y":751,"fontSize":400,"fill":386},"44","106","event name: \"order_rejected\"",[379,754,756],{"x":750,"y":755,"fontSize":400,"fill":386},"130","meaningful fields as a subset",[379,758,760],{"x":750,"y":759,"fontSize":400,"fill":386},"154","audit, alert and security events",[379,762,763],{"x":750,"y":470,"fontSize":400,"fill":637},"no records at ERROR or above",[371,765],{"x":766,"y":453,"width":742,"height":411,"rx":471,"fill":394,"stroke":369,"strokeWidth":413},"414",[379,768,770],{"x":769,"y":746,"textAnchor":383,"fontSize":628,"fontWeight":385,"fill":386},"594","leave alone",[379,772,774],{"x":773,"y":751,"fontSize":400,"fill":386},"432","full rendered message strings",[379,776,777],{"x":773,"y":755,"fontSize":400,"fill":386},"timestamps and field order",[379,779,780],{"x":773,"y":759,"fontSize":400,"fill":386},"incidental debug logs",[379,782,783],{"x":773,"y":470,"fontSize":400,"fill":437},"exact count of info records",[483,785,786],{},"Assertions on the left survive renderer changes and rewording; those on the right break on both.",[33,788,790],{"id":789},"correlating-log-lines-with-the-test-that-produced-them","Correlating log lines with the test that produced them",[10,792,793,794,797],{},"In a large suite, especially under ",[14,795,796],{},"pytest-xdist",", logs from many tests end up in the same CI output, and matching a log line to the test that emitted it is tedious. Binding the test's node id into the logging context fixes that with a few lines:",[65,799,801],{"className":67,"code":800,"language":69,"meta":70,"style":70},"@pytest.fixture(autouse=True)\ndef _bind_test_id(request):\n    structlog.contextvars.bind_contextvars(test=request.node.nodeid)\n    yield\n    structlog.contextvars.clear_contextvars()\n",[14,802,803,808,813,818,823],{"__ignoreMap":70},[74,804,805],{"class":76,"line":77},[74,806,807],{},"@pytest.fixture(autouse=True)\n",[74,809,810],{"class":76,"line":83},[74,811,812],{},"def _bind_test_id(request):\n",[74,814,815],{"class":76,"line":89},[74,816,817],{},"    structlog.contextvars.bind_contextvars(test=request.node.nodeid)\n",[74,819,820],{"class":76,"line":95},[74,821,822],{},"    yield\n",[74,824,825],{"class":76,"line":102},[74,826,827],{},"    structlog.contextvars.clear_contextvars()\n",[10,829,830,831,834,835,838],{},"Every event emitted during the test now carries a ",[14,832,833],{},"test"," field, rendered in both the console and JSON formats. When logs are shipped from CI to a log store, filtering by that field reconstructs the exact sequence of events for one failing test, even when it ran interleaved with dozens of others on the same worker. The ",[14,836,837],{},"clear_contextvars"," call at teardown also fixes the leak described in the edge cases below, so the fixture pays for itself twice.",[10,840,841,842,844],{},"The same idea extends to request-scoped identifiers. If the code under test binds a ",[14,843,697],{}," at the start of each request, a failing integration test's captured logs show every event for that request together, and the id links those logs to the matching spans when tracing is also enabled. Together, the test id and the request id give two levels of grouping — which test, and which request within that test — which is usually all that is needed to turn a wall of interleaved CI output into a readable story of what happened before the failure.",[33,846,848],{"id":847},"edge-cases-and-failure-modes","Edge cases and failure modes",[38,850,851,865,878,895,908],{},[41,852,853,860,861,864],{},[854,855,856,859],"strong",{},[14,857,858],{},"cache_logger_on_first_use=True"," in tests."," Loggers bound before ",[14,862,863],{},"capture_logs"," or reconfiguration keep the old processors. Disable caching in tests.",[41,866,867,873,874,877],{},[854,868,869,870,59],{},"Libraries that call ",[14,871,872],{},"logging.basicConfig"," A stray ",[14,875,876],{},"basicConfig"," adds a handler bound to stderr. Reset root handlers in your configuration function, as above.",[41,879,880,883,884,887,888,890,891,894],{},[854,881,882],{},"Propagation disabled."," A logger with ",[14,885,886],{},"propagate = False"," never reaches the root, so ",[14,889,16],{}," misses it. Enable propagation or attach ",[14,892,893],{},"caplog.handler"," to that logger in the test.",[41,896,897,900,901,903,904,907],{},[854,898,899],{},"Level filtering."," ",[14,902,16],{}," records only what passes both the logger level and the handler level. Use ",[14,905,906],{},"caplog.set_level(..., logger=...)"," for the specific logger under test.",[41,909,910,900,913,916,917,920],{},[854,911,912],{},"Contextvars leaking between tests.",[14,914,915],{},"structlog.contextvars.bind_contextvars"," state persists across tests in the same thread. Call ",[14,918,919],{},"clear_contextvars()"," in a fixture.",[33,922,924],{"id":923},"frequently-asked-questions","Frequently Asked Questions",[10,926,927,930,931,933,934,59],{},[854,928,929],{},"Why is caplog empty when my code uses structlog?","\nstructlog's default configuration prints directly to stdout instead of going through the standard logging module, so pytest's ",[14,932,16],{}," handler never sees the events. Configure structlog to render through stdlib logging in tests, or use ",[14,935,936],{},"structlog.testing.capture_logs",[10,938,939,942,943,946,947,949],{},[854,940,941],{},"Why do my logs disappear when a test passes?","\npytest captures log records and only shows them in the report for failing tests. Use ",[14,944,945],{},"-o log_cli=true"," to stream logs live, or ",[14,948,597],{}," to show captured output for passing tests too.",[10,951,952,955,956,959,960,963,964,966],{},[854,953,954],{},"How do I assert on structured log fields?","\nWith stdlib logging, read record attributes from ",[14,957,958],{},"caplog.records",", where fields passed via ",[14,961,962],{},"extra"," appear as attributes. With structlog, ",[14,965,863],{}," returns a list of event dictionaries you can assert on directly.",[33,968,970],{"id":969},"related","Related",[38,972,973,979,986,993],{},[41,974,975,978],{},[55,976,977],{"href":57},"Logging and Observability for Debugging"," — logging strategy for debuggable systems.",[41,980,981,985],{},[55,982,984],{"href":983},"\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Ftracing-a-request-through-a-test-with-opentelemetry\u002F","Tracing a Request Through a Test with OpenTelemetry"," — spans alongside logs.",[41,987,988,992],{},[55,989,991],{"href":990},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fcapturing-artifacts-from-a-failed-ci-test-run\u002F","Capturing Artifacts from a Failed CI Test Run"," — keeping logs after CI fails.",[41,994,995,999],{},[55,996,998],{"href":997},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Freproducing-ci-only-test-failures-locally\u002F","Reproducing CI-Only Test Failures Locally"," — when logs point at environment.",[10,1001,1002,1003],{},"← Back to ",[55,1004,977],{"href":57},[1006,1007,1008],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":70,"searchDepth":83,"depth":83,"links":1010},[1011,1012,1013,1014,1015,1016,1017,1018,1019],{"id":35,"depth":83,"text":36},{"id":62,"depth":83,"text":63},{"id":488,"depth":83,"text":489},{"id":542,"depth":83,"text":543},{"id":672,"depth":83,"text":673},{"id":789,"depth":83,"text":790},{"id":847,"depth":83,"text":848},{"id":923,"depth":83,"text":924},{"id":969,"depth":83,"text":970},"Make JSON and structlog logs visible and assertable under pytest: how caplog and capture interact with handlers, configuring structlog for tests, and asserting on structured fields.","md",{"slug":1023,"type":1024,"breadcrumb":1025,"datePublished":1026,"dateModified":1026,"faq":1027,"howto":1034},"structured-logging-that-survives-pytest-capture","article","Structured logging in tests","2026-09-18",[1028,1030,1032],{"q":929,"a":1029},"structlog's default configuration prints directly to stdout instead of going through the standard logging module, so pytest's caplog handler never sees the events. Configure structlog to render through stdlib logging in tests, or use structlog.testing.capture_logs.",{"q":941,"a":1031},"pytest captures log records and only shows them in the report for failing tests. Use -o log_cli=true to stream logs live, or -rA to show captured output for passing tests too.",{"q":954,"a":1033},"With stdlib logging, read record attributes from caplog.records, where fields passed via extra appear as attributes. With structlog, capture_logs returns a list of event dictionaries you can assert on directly.",{"name":1035,"description":1036,"steps":1037},"How to keep structured logs working under pytest","Route structured logs through the standard logging tree in tests, then read and assert on them with caplog or structlog's capture tools.",[1038,1041,1044,1047],{"name":1039,"text":1040},"Route through stdlib logging","Configure structlog's LoggerFactory to use logging.getLogger so caplog's handler receives events.",{"name":1042,"text":1043},"Avoid handlers bound to sys.stderr at import","Create stream handlers lazily or rely on pytest's handlers so capture redirection works.",{"name":1045,"text":1046},"Stream logs when debugging","Run with log_cli enabled and a suitable log_cli_level to see logs live.",{"name":1048,"text":1049},"Assert on fields","Use caplog.records attributes or structlog.testing.capture_logs to check event fields.","\u002Fsystematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Fstructured-logging-that-survives-pytest-capture",{"title":5,"description":1020},"systematic-debugging-performance-profiling\u002Flogging-and-observability-for-debugging\u002Fstructured-logging-that-survives-pytest-capture\u002Findex","MqfaqXTrLbcWewfokrgZ1aCZ9ujGQI7f5ObpVmuF0FQ",1789718769178]