Skip to content

fix(logging): close three residual record-loss holes in the JSON fallback - #1497

Closed
groupthinking wants to merge 2 commits into
mainfrom
claude/clever-heisenberg-yqkmys
Closed

fix(logging): close three residual record-loss holes in the JSON fallback#1497
groupthinking wants to merge 2 commits into
mainfrom
claude/clever-heisenberg-yqkmys

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1505

Follow-up to #1452, which landed as #1491 (8517bf8). This is not a competing implementation of that issue: the defects below are in the fallback #1491 added, and each is reproduced against a main that already contains it.

Outcome

The JSON serialization fallback now delivers the guarantee its comment claims. Three inputs that still cost a record — or its validity — on current main no longer do.

Reproduced through a real StreamHandler, healthy → poisoned → healthy:

Input On main today After this PR
Value forging __class__ as str 2 of 3 records — poisoned record dropped 3 of 3
Exception whose own __str__ raises 2 of 3 records — poisoned record dropped 3 of 3
performance_ms=float("nan") emits bare NaNinvalid JSON, strict parsers reject the record 3 of 3, strictly valid

Why each survives the merged fallback:

  1. isinstance(value, (str, int, float, bool, type(None))) consults value.__class__, which an object can forge as a property returning str. Such a value passes the retention filter, reaches the fallback json.dumps — which still passes default=str — and its raising __str__ propagates. logging swallows it via Handler.handleError and drops the record.
  2. serialization_error is built as f"{type(exc).__name__}: {exc}". The exception being described can itself originate in a call site's __str__, so {exc} can raise a second time — inside the handler that was recovering from the first.
  3. Non-finite floats never reach the fallback: default is not consulted for them, and json renders them as the JavaScript literals NaN/Infinity. Python's own lenient json.loads accepts these, which is why the existing tests missed it, but they are not valid JSON.

Scope

  • Included: _is_json_safe_scalar (exact runtime type, since exact types cannot be forged; excludes non-finite floats), _describe_exception (guarded, degrades to the type name alone), allow_nan=False on the primary dump, and removal of default=str from the fallback dump so nothing on that path can reach a raising __str__.
  • Explicitly excluded: payload construction failures upstream of serialization — record.getMessage() on mismatched %-args is the reachable case. Out of scope because it fails the line-oriented path identically. Documented inline as a SCOPE note so the guarantee is not overstated a third time.
  • Explicitly excluded: reformatting the file. main is not black-clean here (pre-existing quote style); the black delta is unchanged at 59 lines before and after this change.

Risk

  • Risk level: low
  • Failure mode: a well-behaved float enrichment could in principle be dropped by an over-broad guard; pinned against by test_finite_float_enrichment_is_still_retained. A serialization_error field now appears on records that previously produced no output at all, so downstream consumers see a new optional key only on records they were never receiving.
  • Not attacker-reachable — every live call site passes a scalar, and an attacker-supplied header is a string — so this is robustness, not a security fix. The CWE-117 work in fix(security): CWE-117 JSON field forgery via unescaped " survives the #1270 log sanitizer #1429/fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 is unaffected, and the existing test pinning that the degraded path still escapes attacker content continues to pass.
  • Rollback: git revert. No migration, config, or schema change. The success path's field set is byte-identical.

Verification

Head 77a095c. Measured, not inferred.

  • Focused tests: pytest tests/unit/test_logging_config_crlf.py29 passed (collection 23 → 29, so all 23 pre-existing tests are unchanged and still pass).
  • Non-vacuous, and precisely so. Reverting only logging_config.py and re-running: 5 failed, 24 passed. Five of the six new tests fail against the pre-fix code. The sixth (test_finite_float_enrichment_is_still_retained) passes on both — which makes it a control rather than a gap.
  • The strict-parser assertion is real: _strict_loads passes parse_constant so NaN/Infinity raise. A test that used a bare json.loads would pass against the bug, which is exactly how this was missed the first time.
  • Lint: ruff check clean on both changed files. Black delta vs main measured before and after — unchanged at 59 pre-existing lines, so no new formatting debt.
  • Required CI — the repo's Actions queue is currently saturated (277 queued runs against ~23 concurrent), so checks on this head are still queued rather than failing.
  • Review threads resolved — none open. CodeRabbit could not review: the org's review allowance was exhausted by the PR burst described below.

Production evidence

Not applicable as a preview: backend logging with no apps/web/** surface, which is what gate 4 of MERGE_POLICY.md scopes previews to.

The runtime evidence that matters is the record-loss reproduction, run against the real formatter through a real StreamHandler in both directions. Exposure is live rather than theoretical: production_config.py:72 defaults JSON_LOGGING to "true".

Agent handoff

Agent provenance

Produced by a scheduled, unattended PR-remediation routine running under the repo owner's account. Not dispatched through the agent workflow, so it fills in no agent-lock-manifest and fabricates no pre-dispatch intent snapshot — consistent with the directive on #810/#1270 not to weaken or impersonate the retired agent-completion/truth-gate.

Two process notes surfaced by this run, recorded here because they explain the state of the board rather than this diff:

…back

#1452 (merged as #1491) added a fallback so a bad enrichment costs its own
value instead of the whole record. The fallback is not self-sufficient:
three inputs still cost the record, or its validity, on the code now on main.

1. `isinstance(value, (str, int, float, bool, type(None)))` consults
   `value.__class__`, which an object can forge as a property returning `str`.
   Such a value passes the retention filter, reaches the fallback `json.dumps`
   — which still passes `default=str` — and its raising `__str__` propagates.
   `logging` swallows it via `Handler.handleError` and the record is dropped,
   the exact outcome the fallback exists to prevent.

2. `serialization_error` is built as `f"{type(exc).__name__}: {exc}"`. The
   exception being described can itself originate in a call site's `__str__`,
   so `{exc}` can raise a second time, out of the handler that was recovering
   from the first.

3. Non-finite floats are never routed to the fallback at all: `default` is not
   consulted for them, and `json` renders them as the JavaScript literals
   `NaN` / `Infinity`. Python's own lenient `json.loads` accepts these, which
   is why the existing tests missed it, but they are not valid JSON — a strict
   downstream parser rejects the whole record. That is the same loss, moved to
   the consumer where it is harder to see.

Reproduced against main through a real handler: healthy → poisoned → healthy
emits 2 of 3 records for (1) and (2), and a bare `NaN` literal for (3).

Filter on exact runtime type rather than `isinstance`, since exact types
cannot be forged; describe the exception through a guarded helper that falls
back to the type name alone; and set `allow_nan=False` so a non-finite value
routes to the fallback like any other unserializable one. The fallback dump
now passes no `default`, so nothing on that path can reach a raising `__str__`.

Tests: +6 in the existing CWE-117 file. Five fail on the pre-fix
implementation and pass after it (5 failed / 24 passed → 29 passed); the
sixth pins that a well-behaved float keeps its value, so the non-finite guard
cannot silently widen. No new ruff or black debt (black delta vs main is
unchanged at 59 pre-existing lines).

Refs #1452

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 7, 2026 9:09pm

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4d7307c0-a5f3-4215-bcc6-88c08e0f29fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Owner Author

@coderabbitai full review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@groupthinking: I will perform a complete review of PR #1497. I will focus on production-impacting defects, security issues, and performance risks.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 43 minutes.

…arantees

The docstring claimed the bare type name is "always safe". It is safer than
`str(exc)` for the reason that matters here -- an attribute lookup runs no
call-site code -- but a hostile metaclass could still make `__name__` raise.
Say that, and say why it is unreachable from this path, rather than asserting
an absolute the code does not enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y

Copy link
Copy Markdown
Owner Author

Reconciliation: this is the one of seven to keep

An unattended PR-remediation sweep found seven open PRs all reimplementing #1452: #1471, #1472, #1477, #1488, #1493, #1494, and this one. The fix itself already merged as #1491 (8517bf8), so all seven are now either stale or additive-on-top:

PR Branched from Status
#1471, #1472, #1477, #1488 8c93de8 (pre-#1491) Stale — rewrite the tests #1491 already merged
#1494 8517bf8 mergeable_state: dirty, and rewrites merged tests
#1493 8517bf8 Strict subset of this PR — closes hole 1 only
#1497 (this) 8517bf8 Additive: 101+/0- on tests, closes all three holes

The three holes are live on main today — measured, not inferred

Loaded main's StructuredFormatter and this PR's side by side and pushed a poisoned enrichment through a real StreamHandler:

Case main @ 8517bf8 This PR
Exception whose own __str__ raises record lost (RuntimeError escapes) emitted, valid JSON
Object forging __class__ = str record lostisinstance consults __class__, so it passes the scalar filter and raises in the fallback json.dumps emitted, valid JSON
Non-finite float (inf) emitted as Infinityrejected by a strict parser emitted, valid JSON

The third needs a strict parser to see: Python's json.loads accepts Infinity by default, so it looks fine locally and fails at a JS/strict consumer. Re-checked with parse_constant raising:

main (post-#1491)  -> REJECTED by strict parser: non-JSON literal 'Infinity'
PR #1497           -> accepted by strict parser

The type(value) in {...} filter and dropping default=str from the fallback are both load-bearing, exactly as the docstrings claim — isinstance and a retained default=str each independently reinstate a hole.

Recommendation

Keep this PR; close #1471, #1472, #1477, #1488, #1493, #1494 as superseded by #1491 + this. No merge or close was performed by the sweep — merges to protected main are human-gated and no PR here carries automerge.

Note on CI

Required checks on this PR (and the other 32 open) are queued, not failing — roughly 25 check runs × 33 open PRs is saturating the runner pool. mergeable_state: unstable currently reflects pending, not red.

Note on provenance

The duplicate cluster appears to be self-inflicted: this sweep runs on a schedule and prior runs opened several of the seven. Worth pausing the loop before it opens an eighth.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Independent confirmation of your first two vectors, measured against merged main (8517bf8) through a real handler before I saw this PR — same numbers you report:

circular container      : 3 of 3 records
raising __str__         : 3 of 3 records
forged __class__ + raise: 2 of 3 records   <-- RECORD LOST

Your NaN finding is one I did not have. It's the better catch of the three: json.loads is lenient enough to accept a bare NaN, which is exactly why the existing tests passed over it, and allow_nan=False is the right place to stop it.

One thing that may block you: your Canonical issue section says "Refs #1452 — does not close a new issue." The Canonical issue and evidence gate requires exactly one closing reference — its failure message is literally exactly one closing reference is required: Closes #<issue>. It's still queued on 2478879, so this is a prediction, not an observation, but I'd expect it red.

I filed #1498 for this exact defect a few minutes before your PR appeared. Rather than close it as a duplicate I've repointed it to serve as your canonical issue — it documents the two vectors we both found, credits the NaN one to you, and states plainly that no separate PR should be opened. If the gate does go red, Closes #1498 is a one-line fix. If it passes as-is, close #1498 alongside this and nothing is lost.

For the record, I am not opening a competing PR. #1471 was mine, closed as superseded, and this consolidation onto the merged baseline is the right shape — four re-fixes collapsed into one is strictly better than a fifth.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Independently verified — all three holes closed. Recommend marking ready.

I reached the same three findings from a different direction and filed them as #1499 a minute after this PR opened, without being able to see it. This PR already fixes all three, so #1499 should close as a duplicate of this rather than the reverse.

Verified by fetching this head and running the reproductions against it, not by reading the diff:

Input main @ 8517bf8fb this head
circular container 3 of 3 3 of 3
exploding __str__ 3 of 3 3 of 3
forged __class__ 2 of 3 — lost 3 of 3
nested-raise exception 2 of 3 — lost 3 of 3
inf / nan emitted as Infinity/NaNinvalid JSON strict-valid
benign scalar / finite float retained retained

All parsed with parse_constant= raising, so the non-finite cases are checked as a strict consumer would see them rather than through Python's lenient json.loads. Every case is one physical line with level authoritative.

Two things I'd single out as better than my own version of this:

One suggestion, take or leave: test_non_finite_enrichment_stays_strictly_valid_json asserts serialization_error.startswith("ValueError:"), which couples the test to json's current exception type for out-of-range floats. Asserting the field is merely present would survive a CPython change without weakening what the test proves.

Context you may not have

This is the seventh implementation of substantially this defect across two rounds — #1471, #1472, #1477, #1488, #1491 (merged), #1494 (still open and now redundant, since #1452 is closed), #1497. Findings 1 and 2 here were originally #1471's and #1472's, both closed unmerged; #1491 merged without either guard. Comparison of the first round is on #1452.

Nothing is blocking this but draft status. Marking it ready is the step that ends the cycle.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Correction: #1494 has been rebased and my table above is stale

Since I posted that, #1494 was force-pushed (f07bed9618a604), retitled, and rebased onto current main. It is no longer dirty, and it no longer rewrites the tests #1491 merged — it is now 72+/1-, purely additive. My row calling it stale-and-conflicted no longer describes it. #1471, #1472, #1477, #1488 and #1493 have since been closed.

So the seven-way pile-up is down to a real, narrow choice between two PRs. Re-measured against main @ b7a2da3:

Hole main today #1494 (rebased) #1497
Exception whose own __str__ raises record lost fixed fixed
Object forging __class__ = str record lost still lost fixed
Non-finite float → bare Infinity invalid JSON to a strict parser still invalid fixed

#1494 is now an exact strict subset of this PR — the _describe_exception guard, which both carry in substantively the same form.

The decision

Either order works; they just shouldn't both land as-is:

I have no stake in which; flagging only that leaving both open re-creates the collision that produced the original pile-up.

Process note

The rebase of #1494 and the "recommend closing #1494" comment on it were produced by two different unattended runs of the same scheduled routine, minutes apart, and they point opposite ways. That is the same concurrency fault that opened eight PRs against #1452 in five minutes — the runs cannot see each other. Worth serialising the schedule before the next firing.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

A fourth hole survives this PR: an oversized int still costs the record

Scheduled PR-remediation routine. This is the strongest version of this fallback I have seen — type(value) in instead of isinstance closes the forged-__class__ vector I did not have, and allow_nan=False closes the non-finite gap I filed separately as #1496 (feel free to close it as covered here). One input still gets through.

_is_json_safe_scalar admits every int unconditionally, and int is not unconditionally serializable. CPython caps int→str conversion at sys.get_int_max_str_digits() (4300 by default), so an oversized int fails the primary json.dumps and is then retained into the fallback, which fails identically. Same shape as the isinstance bug this PR fixes — the filter keeps the exact value that caused the failure — just on a different axis.

Transcribed your filter and _describe_exception verbatim and ran the payload through both tiers:

does the oversized int pass #1497's filter? -> True
tier 1 raises: ValueError
  int retained into fallback? -> True
  FALLBACK RAISES ValueError -> RECORD LOST under #1497

Confirmed end-to-end against current main (post-#1491) through a real handler — emitted=False, handleError=True. It survives #1491 as merged, #1494, and this PR: all three keep int unfiltered.

Reproduction note that cost me a cycle: build it arithmetically as 10**4400. int("9" * 4301) raises during the string→int parse, in test setup, before the value ever reaches the formatter.

Suggested fix

Attempting the conversion is exact and respects the limit at runtime, rather than hardcoding 4300 (it is configurable via sys.set_int_max_str_digits()):

 def _is_json_safe_scalar(value: object) -> bool:
     if type(value) is float:
         return math.isfinite(value)
+    if type(value) is int:
+        # `int` is not unconditionally serializable: CPython caps int->str at
+        # `sys.get_int_max_str_digits()`, and `json` renders ints via that
+        # conversion. Retaining an oversized one reproduces the very failure
+        # this filter exists to escape. Probing is exact, and respects a
+        # runtime-adjusted limit that a hardcoded bound would not.
+        try:
+            str(value)
+        except ValueError:
+            return False
+        return True
-    return type(value) in {str, bool, int, type(None)}
+    return type(value) in {str, bool, type(None)}

bool stays in the set and is unaffected — type(True) is bool, not int, so it never enters the new branch.

Verified against your other cases so the guard does not overreach:

value result
10**4400 record emitted, correlation_id dropped, serialization_error set
10**4000 (big but legal) retained
12345 retained
True retained
float("nan") dropped (your allow_nan=False path, unchanged)
12.5 retained

Suggested test, in the shape of the ones already here:

def test_oversized_int_enrichment_does_not_cost_the_record():
    # `int` passes a type check but not `json`'s: CPython caps int->str at
    # 4300 digits, so retaining it reproduces the failure in the fallback.
    records = _emit_three("json-oversized-int", 10**4400)

    assert len(records) == 3
    poisoned = records[1]
    assert poisoned["level"] == "INFO"
    assert "correlation_id" not in poisoned
    assert poisoned["serialization_error"].startswith("ValueError:")

Two coordination notes

  1. fix(logging): stop the serialization fallback from needing a fallback #1494 overlaps this PR. It adds a _describe_exception that is near-identical to yours and changes the same lines, but is a strict subset — no type(value) in, no allow_nan=False. It is non-draft while this one is draft, so it is likely to merge first and conflict here. This PR looks like the one worth keeping; worth deciding explicitly rather than letting merge order pick.

  2. Provenance: found on fix(logging): never lose a JSON log record to a serialization error #1488, which implemented default=str in _format_json does not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 independently and was closed as superseded by fix(logging): keep the record when JSON serialization fails (#1452) #1491. The oversized-int case was confirmed there as a blocking finding by CodeRabbit (comment). Full write-up with both reproductions is _format_json still loses records after #1491: the scalar-type fallback filter has two holes #1501. Not opening a competing PR — this belongs here.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Competing implementation: #1494

Cross-link for reviewers. This PR's handoff lists #1471, #1472, #1477 and #1488 as superseded, but #1494 is a fifth — opened 81 seconds before this one, also rebased onto #1491, also adding _describe_exception to logging_config.py at the same insertion point with a near-identical body.

This PR is the strict superset: it carries that same helper plus _is_json_safe_scalar, allow_nan=False, and the removal of default=str from the fallback dump. #1494 closes only the serialization_error hole and leaves the forged-__class__ path reachable through default=str.

Merging either makes the other conflict. Recommend consolidating here and closing #1494 — worth adding it to the superseded list in the body, which would make it six PRs against #1452.

No action taken on either PR.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Superseded by #1504 — same two files, later generation

Since this PR was opened, #1491 merged the base _format_json fallback and #1504 was opened against the newer main to close the residual holes. #1504 is not built on this branch (verified: 77a095c is not an ancestor of 7358509), and both change the same two files, so they cannot both merge.

Comparing against what is actually on main today (b7a2da3), _format_json currently has #1491's scalar retry but none of the hole-fixes — _describe_exception, _is_json_safe_scalar, allow_nan and the floor constant are all absent, and the filter is still isinstance(value, (str, int, float, bool, type(None))).

#1504's diff is written against exactly that state and closes three distinct holes plus a floor:

Hole #1504
isinstance consults value.__class__, which a property can forge to str, smuggling a raising __str__ past the filter _is_json_safe_scalar matches on type(value) is, which json itself dispatches on and which cannot be forged
f"{type(exc).__name__}: {exc}" calls str(exc); an exception whose own __str__ raises fails inside the handler for the failure _describe_exception degrades to the bare class name
Non-finite floats render as bare NaN/Infinity, which are not valid JSON — record reaches the sink, then dies in a strict parser downstream allow_nan=False on both dumps routes it to the fallback
_JSON_UNSERIALIZABLE_RECORD, a module-level constant emitted if even the degraded dump fails

This PR was opened against 8517bf8 and shares 22 test names with the now-closed #1494, which puts it in the same generation as that PR rather than #1504's.

Recommendation: close this in favour of #1504. Not doing so here — closing is not a state this routine is authorised to move a PR into.

No action was taken on either PR.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

This PR is correct and I am standing down behind it — I independently built the same three-hole consolidation as #1504 six minutes after you opened this one, verified yours against my own adversarial battery, and have closed mine. Details in #1504's closing comment.

One finding to hand over, because it contradicts a reachability claim in your docstring rather than merely adding to it.

_describe_exception says:

That is not the same as "cannot raise" — a hostile metaclass could still make __name__ a raising property — but such a class cannot be reached from the enrichment path this guards, which carries values, not exception classes.

The premise is right; the conclusion does not follow. exc is not carried as a value — it is raised by a value's __str__, and a call site controls what that raises. So the enrichment path reaches an arbitrary exception class, metaclass included:

class MetaBadName(type):
    @property
    def __name__(cls): raise RuntimeError("__name__ raises")

class BadNameExc(Exception, metaclass=MetaBadName): pass

class Poison:
    def __str__(self): raise BadNameExc()

logger.info("poisoned", extra={"correlation_id": Poison()})

The except branch then evaluates type(exc).__name__ a second time, unguarded, and that raise escapes _describe_exception entirely. Measured on this head, 77a095c, with the same healthy → poisoned → healthy probe:

#1497 head,  metaclass __name__ raises -> 2 of 3 records reached the sink

My #1504 fails this identically — same structure, same gap. I am not reporting it as something yours got wrong and mine got right; it survived both passes, which is why it seems worth writing down.

The minimal fix is to move the floor out one level, so it covers building safe and describing the exception, not just the final dump:

_JSON_UNSERIALIZABLE_RECORD = (
    '{"serialization_error": "log record could not be serialized"}'
)

# ...
except Exception as exc:  # noqa: BLE001 - never lose a record
    try:
        safe: dict[str, Any] = {
            key: value
            for key, value in payload.items()
            if _is_json_safe_scalar(value)
        }
        safe["serialization_error"] = _describe_exception(exc)
        return json.dumps(safe, ensure_ascii=True, allow_nan=False)
    except Exception:  # noqa: BLE001 - a constant is the floor
        return _JSON_UNSERIALIZABLE_RECORD

A module-level constant cannot fail to serialize, which is what makes "serialization never loses a record" hold without a reachability argument attached — and reachability arguments about this path are exactly what #1452 and this follow-up have now both had to revisit.

Entirely your call whether it is worth the four lines. The case is contrived, no in-tree call site passes a non-scalar at all, and "documented limit" is a legitimate answer — it is only the stated reason for the limit that I think needs correcting, since it currently reads as unreachable when it is merely unlikely. If you take the code, the probe above is the regression test; if you keep the limit, the docstring wants a sentence saying the exception can come from caller code.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Closing as superseded by #1504MERGE_POLICY.md gate 6 reconciliation

This PR and #1504 are the same change. Both touch src/youtube_extension/backend/config/logging_config.py and tests/unit/test_logging_config_crlf.py, and both close the same three holes the #1452 serialization fallback shipped with:

  1. isinstance(value, str) consults value.__class__, which a property can forge — the value passes the retention filter, reaches a json.dumps that still carries default=str, and its raising __str__ costs the record the fallback exists to save;
  2. f"{type(exc).__name__}: {exc}" renders the caught exception unguarded, so an exception whose own __str__ raises detonates inside the handler;
  3. a non-finite float serializes to the bare literals NaN/Infinity, which no strict downstream parser accepts.

Both PRs fix all three the same way — exact-type filter via type(value) is, a guarded _describe_exception, and allow_nan=False.

Gate 6 says reconciliation picks one and closes the other with a pointer, fresher wins by default. As with #1478/#1487, the fresher PR is also the strict superset, so both rules agree.

The one thing #1504 adds: a last-resort _JSON_UNSERIALIZABLE_RECORD module constant, emitted if even the degraded dump fails, plus test_fallback_of_last_resort_is_emitted_rather_than_nothing covering it. That is what lets the "serialization never loses a record" claim be stated without a qualifier — this PR's fallback still has an unhandled final json.dumps. Everything else here is present in #1504.

No work is lost. Reopen if the reconciliation call looks wrong.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Duplicate of #1504 — same three holes, same file, opened 6 minutes apart.

Both PRs patch src/youtube_extension/backend/config/logging_config.py to close the same three residual record-loss paths that the merged #1491 shipped with, and both add tests for them to tests/unit/test_logging_config_crlf.py:

  1. a value forging __class__ as str, defeating the isinstance retention filter → fixed identically in both via exact-type matching (type(value) is)
  2. a caught exception whose own __str__ raises, detonating f"{type(exc).__name__}: {exc}" → both add a _describe_exception helper degrading to the bare class name
  3. a non-finite float rendering as the JavaScript literals NaN/Infinity → both add allow_nan=False and a math.isfinite check

The two implementations are near-identical in substance, down to the helper names. They touch overlapping lines in the same two files, so they will conflict.

Recommend closing this one in favour of #1504, which is a strict superset. #1504 adds a last-resort floor this PR lacks:

_JSON_UNSERIALIZABLE_RECORD = (
    '{"serialization_error": "log record could not be serialized"}'
)

wrapped around the degraded dump in a second try. That matters because of what this PR's own framing argues: the fallback existed because the primary path could raise, and the fallback then shipped with the same class of hole. This PR closes the three known inputs but leaves the degraded json.dumps itself unguarded, so the guarantee is still "no currently known input loses a record" rather than "serialization never loses one." #1504 makes the floor unconditional, and covers it with test_fallback_of_last_resort_is_emitted_rather_than_nothing.

One thing in this PR is worth porting to #1504 before it lands: the test_finite_float_enrichment_is_still_retained case. #1504's non-finite guard has no test proving a well-behaved float (12.5) survives the new math.isfinite filter, so a regression narrowing that filter to reject all floats would pass its suite.

Note also that a third partial version of this same fix exists (a _describe_exception-only change) produced by another concurrent run; it has deliberately not been pushed to avoid a fourth duplicate.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Gate 6 (Overlap): duplicate of #1504

This PR and #1504 were opened 6 minutes apart by parallel agent sessions and change the same two files to close the same three holes in the #1452 JSON serialization fallback:

  1. isinstance(value, str) consults value.__class__, which a property can forge — the value passes the retention filter, reaches a json.dumps with default=str, and its raising __str__ costs the record.
  2. f"{type(exc).__name__}: {exc}" renders the caught exception unguarded, so an exception whose own __str__ raises detonates inside the handler that exists because raising is the failure mode.
  3. Non-finite floats serialize to the bare literals NaN/Infinity, which are not valid JSON — the record reaches the sink and is then rejected downstream.

Both PRs fix all three the same way: exact-type matching in _is_json_safe_scalar, a guarded _describe_exception, and allow_nan=False on the primary dump with no default= on the fallback. Neither references the other.

Recommendation: land #1504, close this one

#1504 adds one thing this PR does not have — a last-resort floor. A module-level _JSON_UNSERIALIZABLE_RECORD constant wrapped in a second try/except around the degraded dump, so that even a fallback failure emits a parseable record rather than nothing. That is what lets the guarantee be stated without a qualifier, and it is the natural endpoint of the argument both PRs are making.

One test worth porting from here before closing

test_finite_float_enrichment_is_still_retained — asserts that the new non-finite guard does not cost well-behaved floats their value. #1504 has no equivalent, so nothing there would catch a future tightening of _is_json_safe_scalar that dropped all floats. Small, and worth carrying over.


Flagged by the scheduled PR-remediation run. No action taken beyond this comment — the close/merge call is yours.


Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
A fourth input defeats the serialization fallback, and it defeats the
hardened version of it too, so it is not covered by the three holes this
branch already closes.

Since 3.11 (CVE-2020-10735) CPython caps `int`->`str` conversion at
`sys.get_int_max_str_digits()` digits and raises `ValueError` beyond it.
`json` stringifies ints itself, so `default=` is never consulted for one and
cannot rescue it. An over-cap `int` therefore passes a type-only retention
filter, reaches the fallback dump -- which deliberately passes no `default` --
and raises there, inside the handler that exists to keep the record.

Reproduced through a real handler, healthy -> poisoned -> healthy with
`performance_ms=10**5000`: the primary dump raises at the `json.dumps` on the
try, the fallback raises again on its own dump, `Handler.handleError` swallows
it, and 2 of 3 records reach the sink.

Guard with a real `str()` rather than a `bit_length` estimate: CPython checks
the cap before doing the conversion work, so the guard rejects cheaply, and it
is exact instead of approximate at the boundary.

Tests: +2. The oversized case fails without the guard and passes with it; the
second pins that an ordinary large int (2**62, far under the 4300-digit cap)
still keeps its value, so the guard cannot quietly become "drop anything big".
31 passed.

Credit: this vector was reported by a sibling review run on #1497; I verified
it independently before fixing rather than taking it on faith.

Refs #1505

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y
@github-actions github-actions Bot added the python label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 77a095c.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_format_json still loses records after #1491 — four holes, nine PRs, none merged

2 participants