Skip to content

perf(sdk-python): stop re-serializing oversized structured log records - #1028

Merged
AbirAbbas merged 3 commits into
mainfrom
fix/ext-py-logger-mirror-perf
Aug 31, 2026
Merged

perf(sdk-python): stop re-serializing oversized structured log records#1028
AbirAbbas merged 3 commits into
mainfrom
fix/ext-py-logger-mirror-perf

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

The bounded stdout mirror added in #1002 still serialized the full structured record before deciding it was oversized — O(payload) CPU on the calling thread (~15 ms for a 5 MB extra payload), inside the tee lock. This change prechecks sizes from the record's top-level parts and reconstructs the envelope arithmetically, so an oversized record is never re-encoded and the emitted line is byte-identical to before.

Why

Refs #985 — the remaining "blocks the event loop" cost of the mirror after v0.1.137's AGENTFIELD_LOG_STDOUT / AGENTFIELD_LOG_MAX_LINE_BYTES work.

Changes

  • perf(sdk-python): stop re-serializing oversized structured log records
  • test(sdk-python): guard the bounded mirror line against re-serialization
  • test(sdk-python): make the mirror perf guards fail on revert

Validation contract

    1. Every emitted mirror line is valid JSON and <= AGENTFIELD_LOG_MAX_LINE_BYTES → test_structured_mirror_is_bounded_valid_json_and_cp_receives_full_record, test_public_structured_logger_emits_only_bounded_json_lines
    1. Elision markers report the same byte counts as today (<4002>/<4004>) → test_structured_mirror_elides_oversized_non_dict_attributes, test_structured_mirror_non_string_attribute_keys_have_exact_sizes
    1. A fitting record is byte-identical AND costs no more work; the precheck is a shallow O(attrs) sum, never a recursive walker → test_structured_mirror_fitting_nested_record_is_not_walked_recursively (byte-identical assert + new assert walked == []; fails with 2800 touches against a recursive-walker mutant), test_structured_mirror_many_large_attributes_stays_fast
    1. The record handed to _dispatch_to_cp is never mutated → test_structured_mirror_is_bounded_valid_json_and_cp_receives_full_record, test_structured_mirror_serializes_large_string_payload_at_most_once (asserts payload survives intact)
    1. An oversized top-level str/bytes payload is serialized at most once, never inside a whole-record dumps → test_structured_mirror_serializes_large_string_payload_at_most_once (counts json.dumps OUTPUT sizes: 1 on branch, 2 on main, 2 with the optimization neutered)
    1. Envelope reconstruction guarded against a record with no 'attributes' key → test_structured_mirror_without_attributes_reports_exact_original_size
    1. Key sizes computed from the json-coerced key string (non-str keys no longer under-report by 2 bytes) → test_structured_mirror_non_string_attribute_keys_have_exact_sizes, test_json_key_matches_json_dict_key_coercion (parametrized), test_json_key_falls_back_to_string_for_unknown_key_type
    1. Precheck is exception-safe; isinstance orders bool before int → existing precheck fallback tests in test_logger.py (suite green: 48/48 in the file, 2269 repo-wide)
    1. With AGENTFIELD_LOG_STDOUT off, no serialization happens at all → test_structured_stdout_disabled_skips_serialization, test_structured_stdout_can_be_disabled
    1. The redundant .encode('utf-8') byte-count materialization is removed → test_structured_mirror_serializes_large_string_payload_at_most_once (total bytes serialized on the 5 MB case drops 10,000,201 -> 5,000,173)

How it was tested

CI-literal gates in the worktree: ruff check ., ./scripts/run_pytest.sh (full sdk-python suite), ./scripts/coverage-surface.sh sdk-python, ./scripts/patch-coverage-gate.sh — ALL-PASS, patch gate ≥ 80 % on touched lines. Output byte-equality for the 5 MB oversized case is asserted in the tests.

Notes / follow-ups

Non-blocking review findings kept as follow-ups:

  • (nit) sdk/python/agentfield/logger.py — Carried forward from the previous round (finding 5), unchanged and by design: for records with non-str attribute keys the aggregate fallback marker {"_elided": "<N bytes elided>"} now reports the exact serialized size,
  • (nit) sdk/python/pyproject.toml — Carried forward from the previous round (finding 4), accepted as-is per that reviewer's own suggested_fix. The required patch-coverage check again reports sdk-python | 0 touched lines | — | no changes because `agentfie

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits August 31, 2026 13:11
_bounded_mirror_line serialized the whole record with json.dumps before it
knew whether the record fit the mirror budget, and serialized it a second
time at the end to report original_size. A record carrying a multi-megabyte
string attribute therefore passed that payload to json.dumps twice, and every
size check did len(<str>.encode("utf-8")), materialising a throwaway
multi-megabyte bytes copy purely to count.

Three changes, all inside that one private method and the helpers next to it:

* _certainly_exceeds_budget is a shallow O(top-level keys + attributes) sum
  over str/bytes payloads that skips the speculative whole-record dumps when
  it can already prove the record is oversized. It is deliberately NOT a
  recursive walker — a recursive lower-bound walk measured +318% on a fitting
  14.7 KB record and never aborts early for int-heavy payloads. Nested
  containers count as zero, so "False" means "unknown, serialize and measure",
  and any exception falls back to that same path so a mirror line is never
  dropped because the estimator misbehaved.
* _record_size reconstructs the full record size arithmetically from the
  envelope plus the already-computed attributes size instead of re-encoding,
  which removes the second whole-record dumps. It falls back to a plain dumps
  when the record has no "attributes" key, because {**record,
  "attributes": {}} would APPEND the key and overcount the envelope by 14
  bytes in the reported original_size.
* _byte_len replaces len(x.encode("utf-8")) everywhere it was used only to
  count; str.isascii() is an O(1) flag check on CPython's compact-unicode
  representation, so the common case no longer allocates at all.

_json_key fixes a latent off-by-two while it is in here: json coerces non-str
dict keys (1 -> "1", True -> "true", None -> "null"), so measuring the raw key
undercut the attributes size by two bytes per non-str key and could push an
elision boundary the wrong way. bool is checked before int because
isinstance(True, int).

Output is unchanged: a 220-case differential harness (hand-picked edges plus
200 randomised records across budgets 256-16384) produces byte-identical lines
against the previous implementation, and the record handed to _dispatch_to_cp
is still only ever shallow-copied. Measured, mean of 5: one 5 MB str attribute
20.4 ms -> 9.0 ms (json.dumps calls carrying a >=1 MB payload: 2 -> 1),
200 x 50 KB attributes 46.2 ms -> 18.8 ms, and a fitting 14.7 KB record stays
at 0.18 ms.

Refs #985

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression tests for the mirror-line precheck, derived from the behavior
contract rather than from the implementation:

* a 5 MB str attribute passes a >=1 MB payload to json.dumps at most once
  (counting proxy around logger_module.json.dumps) — the deterministic guard
  that the whole-record dumps is really skipped;
* a record that FITS the default 16384 budget is emitted byte-identically to
  a direct json.dumps and its nested values are never measured. The existing
  many-large-attributes test cannot catch a recursive precheck, because its
  first 50 KB attribute aborts the scan immediately; this one nests a str
  subclass whose isascii()/__len__ raise, which CPython's C json encoder
  never touches but a recursive walker would;
* non-str attribute keys elide at the same per-attribute boundary and report
  an aggregate _elided size that matches json.dumps exactly (4030, not the
  4024 the raw-key measurement produced);
* a record with no "attributes" key reports its exact size in the
  minimal-record fallback rather than 14 bytes too many;
* a payload that raises while being measured falls back to the full-dumps
  path, so the stdout line is still emitted instead of being dropped;
* _json_key's coercion table (str / bool / None / float / __str__ fallback).

No wall-clock assertion is added: the post-fix timing has only ~4.6x headroom
and would be a CI flake source. Every existing structured-mirror test —
including the exact <4002>/<4004> markers and the elapsed < 0.2 guard — passes
unmodified.

Refs #985

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both new guards passed against main's logger.py, so neither actually
guarded the behaviour it was named for.

- test_..._serializes_large_string_payload_at_most_once classified
  json.dumps calls by their *argument* (a >=1MB str). The redundant call
  this PR removes passes the record *dict*, not the 5 MB string, so both
  versions showed exactly one big-str argument. Count the *output* size
  instead: 1 on the branch, 2 on main, 2 with the precheck neutered.

- test_..._fitting_nested_record_is_not_walked_recursively used a
  tripwire that raised AssertionError, but _certainly_exceeds_budget
  swallows every exception and falls back to the full-dumps path, so a
  recursive precheck silently emitted the byte-identical line the test
  asserted. Record the touches in a list and assert it stays empty; the
  byte-identical assertion is kept alongside it.

Verified by mutation: with `return False` inserted at the top of
_certainly_exceeds_budget the first test now fails (2 != 1), and with
_shallow_payload_bytes made recursive over dicts/lists the second fails
with 2800 recorded touches. Both were green before this change.

Also applies `ruff format` to the one hunk in
test_..._non_string_attribute_keys_have_exact_sizes that had drifted, so
the file stays format-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 31, 2026 18:33
@github-actions

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.28 µs -20%

✓ No regressions detected

@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.60% 87.40% ↑ +0.20 pp 🟡
sdk-go 93.10% 92.00% ↑ +1.10 pp 🟢
sdk-python 94.31% 93.73% ↑ +0.58 pp 🟢
sdk-typescript 91.68% 90.42% ↑ +1.26 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.81% 85.75% ↑ +0.06 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas
AbirAbbas merged commit 5a0d275 into main Aug 31, 2026
33 checks passed
@AbirAbbas
AbirAbbas deleted the fix/ext-py-logger-mirror-perf branch August 31, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant