Skip to content

v1.2.1

Choose a tag to compare

@kidoz kidoz released this 05 Sep 20:04
· 157 commits to main since this release

structguru v1.2.1

A correctness release. Four defects are fixed: stack traces attached with stack_info= were written to the output without redaction, callable-sink lifecycle calls could deadlock or return before a sink callback had finished, the Celery hook let a parent task's identity overwrite the running task's, and the Sentry processor forwarded the raw exception object on paths that bypass redaction. The Rust workspace now requires pyo3 0.29.2, and the development toolchain is upgraded, including a mkdocs-material advisory fix for the documentation site. No breaking changes; upgrade in place from any v1.x.

Fixes

  • stack_info text is redacted in both output formats. The renderer applied sensitive_keys and sensitive_patterns to the message and to every structured field, but the stack value produced by stack_info= was written verbatim to JSON and console output. Anything sensitive that a formatted frame carried left the process unredacted.

    configure(sensitive_patterns=[r"(token=)\w+"], pattern_replacement="$1[MASKED]")
    logger.info("trace", stack_info="frame\ntoken=hunter2")
    # before: ... "stack": "frame\ntoken=hunter2", "message": "trace"}
    # after:  ... "stack": "frame\ntoken=[MASKED]", "message": "trace"}

    Pattern redaction now runs over the stack text exactly as it does over the message, and listing stack in sensitive_keys replaces the whole value with [REDACTED]. Console output redacts the appended stack the same way.

  • Callable-sink lifecycle operations neither deadlock nor return early. The callable dispatcher stopped a queue generation with a stop token under a re-entrant transition lock, which produced four related defects. A sink callback that called shutdown() or disable() while another thread was inside configure(), logger.remove(), stop(), or shutdown() deadlocked both threads: the outer call held the lock while waiting for the queue to drain, and the callback needed that lock to finish its record. A flush() or disable() racing a callback that had just disabled dispatch returned immediately, because the retired generation was no longer referenced anywhere. logger.remove(token) returned while a record captured in a generation retired by an earlier configure() was still being delivered to the removed sink. And closing a generation twice left a stop token behind that made the next flush() hang.

    Generations are now retired rather than stop-tokened: the worker exits once its generation no longer accepts producers and the queue is empty, retired generations stay tracked until they are actually drained, all waiting happens outside the state lock, and a callback never waits on its own worker thread. Every lifecycle operation still drains every record accepted before it began.

  • A Celery task launched from inside another task keeps its own identity. setup_celery_logging() bound the running task's task_id and task_name first and then applied the structguru_context header. When the producer was itself a task, that header carried the parent's task_id and task_name, so every record the child logged was attributed to the parent.

    # parent task (task_id="parent-id") calls child.delay(); the header carries the parent's context
    # before: {'request_id': 'req-999', 'task_id': 'parent-id', 'task_name': 'parent-task'}
    # after:  {'request_id': 'req-999', 'task_id': 'child-id', 'task_name': 'child-task'}

    The running task's identity is now bound after the propagated context, so correlation fields such as request_id still flow through while the task fields always describe the task that is actually executing.

  • SentryProcessor never forwards the raw exception object outside capture_exception. The event dictionary's exc_info value — the live exception — was copied into breadcrumb data, into the structlog_event extra, and, if listed in tag_keys, into a tag. The Sentry SDK serializes arbitrary objects, so an exception message containing a secret reached Sentry through those paths even though the rendered exception field had been redacted. exc_info is now stripped from all three; the raw exception is reserved for capture_exception, the one path meant to receive it.

Tooling

  • pyo3 0.29.2 is the workspace minimum. The lockfile already resolved to it; the floor now says so.

  • mkdocs-material 9.7.7 closes GHSA-xvg9-69gf-fjrf, a DOM XSS in the documentation site's search suggestions via the query parameter. This affects only the built documentation site, never the package.

  • The development toolchain is upgraded — ruff 0.16, mypy 2.3, pytest-benchmark 5.3 among others — and the dependency-group floors in pyproject.toml now match the locked versions. Ruff 0.16 formats Python code blocks inside Markdown, so the README and docs examples were reformatted to match.

Test coverage

  • 18 new Python test cases and 1 new Rust test pin the fixed behavior: stack redaction in both formats, by key and by pattern, on the linear and backtracking engines; the dispatcher's retired-generation drain, the callback-disables-dispatch race, idempotent close, and — in a bounded subprocess — the callback-shutdown deadlock against each lifecycle operation; the Celery child-task identity; and the Sentry exc_info paths — 488 Python tests and 67 Rust tests in total.