Skip to content

fix(logging): keep the record when JSON serialization fails (#1452) - #1491

Merged
groupthinking merged 1 commit into
mainfrom
claude/clever-heisenberg-ctwlue
Aug 7, 2026
Merged

fix(logging): keep the record when JSON serialization fails (#1452)#1491
groupthinking merged 1 commit into
mainfrom
claude/clever-heisenberg-ctwlue

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

_format_json claimed default=str meant "a record is never lost to a serialization error". It does not. default is consulted only for values json cannot natively encode, and it is called unguarded, so two inputs defeat it:

  1. a circular container is rejected structurally, before default is ever reached (ValueError: Circular reference detected);
  2. a value whose __str__ raises propagates straight out of default.

Either way logging swallows the raise via Handler.handleError and drops the record entirely — the exact outcome the comment said was prevented. Reproduced against main through a real handler: healthy → poisoned → healthy emits 2 of 3 records for both inputs.

Reachable through the correlation_id / performance_ms enrichment loop that #1439 added; the pre-#1439 template referenced neither field, so this is a new failure mode rather than a pre-existing one. Not attacker-reachable — every live call site passes a scalar, and an attacker-supplied header is a string — so severity is low and the CWE-117 field-forgery fix is unaffected.

Wrap the dump and re-serialize with only the natively encodable fields, so a bad enrichment costs its own value instead of the whole record, and record why on the degraded record via serialization_error.

except Exception is deliberate, not a narrow tuple: the exploding-__str__ case walks straight through (TypeError, ValueError, RecursionError).

The comment now states the guarantee the code actually provides.

Tests: +3 in the existing CWE-117 file. All three fail on the pre-fix implementation and pass after it (3 failed, 20 passed → 23 passed), and one of them pins that the degraded path still escapes attacker content, so the fallback cannot become a hole in #1429.

Closes #1452

Claude-Session: https://claude.ai/code/session_01LmZfJAVpuZqEzE5jc9Bmtw

Canonical issue

Closes #

Outcome

Describe the user or operational result this PR produces.

Scope

  • Included:
  • Explicitly excluded:

Risk

  • Risk level: low / medium / high
  • Failure mode:
  • Rollback:

Verification

List exact automated and manual checks, tied to the current head SHA.

  • Focused tests
  • Required CI
  • Review threads resolved

Production evidence

Provide the Vercel preview, production deployment, runtime evidence, or state why production evidence is not applicable.

Agent handoff

  • One canonical issue is linked
  • No competing PR implements the same issue
  • Acceptance criteria are satisfied
  • Required checks pass on the current head
  • Human decision is requested only for product, security, irreversible infrastructure, or production approval

`_format_json` claimed `default=str` meant "a record is never lost to a
serialization error". It does not. `default` is consulted only for values
`json` cannot natively encode, and it is called unguarded, so two inputs
defeat it:

  1. a circular container is rejected structurally, before `default` is
     ever reached (`ValueError: Circular reference detected`);
  2. a value whose `__str__` raises propagates straight out of `default`.

Either way `logging` swallows the raise via `Handler.handleError` and drops
the record entirely — the exact outcome the comment said was prevented.
Reproduced against main through a real handler: healthy → poisoned → healthy
emits 2 of 3 records for both inputs.

Reachable through the `correlation_id` / `performance_ms` enrichment loop
that #1439 added; the pre-#1439 template referenced neither field, so this
is a new failure mode rather than a pre-existing one. Not attacker-reachable
— every live call site passes a scalar, and an attacker-supplied header is a
string — so severity is low and the CWE-117 field-forgery fix is unaffected.

Wrap the dump and re-serialize with only the natively encodable fields, so a
bad enrichment costs its own value instead of the whole record, and record
why on the degraded record via `serialization_error`.

`except Exception` is deliberate, not a narrow tuple: the exploding-`__str__`
case walks straight through `(TypeError, ValueError, RecursionError)`.

The comment now states the guarantee the code actually provides.

Tests: +3 in the existing CWE-117 file. All three fail on the pre-fix
implementation and pass after it (3 failed, 20 passed → 23 passed), and one
of them pins that the degraded path still escapes attacker content, so the
fallback cannot become a hole in #1429.

Closes #1452

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmZfJAVpuZqEzE5jc9Bmtw
@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 Building Building Preview, v0 Aug 7, 2026 9:00pm

@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: 4dd11b4b-0560-4d89-a52c-d6e01e45f777

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.

@groupthinking
groupthinking merged commit 8517bf8 into main Aug 7, 2026
22 of 25 checks passed
@groupthinking
groupthinking deleted the claude/clever-heisenberg-ctwlue branch August 7, 2026 21:00
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GRV-385

@groupthinking
groupthinking restored the claude/clever-heisenberg-ctwlue branch August 7, 2026 21:00
groupthinking pushed a commit that referenced this pull request Aug 7, 2026
#1491 closed #1452 by re-serializing from the scalar fields when
`json.dumps` fails, so a bad enrichment costs its own value rather than
the whole record. One residual path still costs the record.

The exception the fallback catches is not `json`'s own error. In the
exploding-`__str__` case it is *whatever that* `__str__` *raised*, which
is arbitrary caller code. `f"{type(exc).__name__}: {exc}"` calls
`str(exc)` on it, so an exception that also raises on `str()` fails
inside the handler for the failure — losing the record for exactly the
reason the fallback exists to prevent. Measured against main @ 8517bf8:
2 of 3 records reach the sink.

`_describe_exception` falls back to the class name, an attribute lookup
that runs no user code.

Also covers `performance_ms`. The enrichment loop reads two attributes
and #1491 tested only `correlation_id`, leaving half the reachable
surface untested. That test passes on main — it closes a coverage gap
rather than pinning a fix, and is marked as such.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cGXYUfmU6yDSLZWe697eY
@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 1b93605.
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

@github-actions github-actions Bot added the python label Aug 7, 2026
groupthinking added a commit that referenced this pull request Aug 13, 2026
#1515)

#1491 wrapped the `json.dumps` call so a bad enrichment could not cost the
whole record, and stated the guarantee as "never lose a record to a
serialization error". The wrapper holds for the two inputs it was written
against, but the *recovery* path it added can itself raise — so the guarantee
covered the anticipated failures rather than the property.

Measured against a real handler on 8517bf8, healthy -> poisoned -> healthy:

  | input                              | before | after |
  |------------------------------------|--------|-------|
  | circular container                 |  3/3   |  3/3  |
  | exploding `__str__`                |  3/3   |  3/3  |
  | exception whose own `__str__` raises | 2/3  |  3/3  |
  | value forging `__class__ = str`    |  2/3   |  3/3  |
  | int past the 4300-digit cap        |  2/3   |  3/3  |
  | non-finite float                   |  3/3*  |  3/3  |

  * emitted, but as a bare `NaN`/`Infinity` literal, which is not valid JSON —
    a strict downstream parser rejects the record, which is the same loss moved
    to the consumer.

Each cause, and the fix:

  * The fallback built `f"{type(exc).__name__}: {exc}"` directly. The exception
    it catches may be one raised from a call site's own `__str__`, so
    describing the failure became the failure. Now `_describe_exception`,
    which falls back to the type name.
  * The scalar filter used `isinstance`, which consults `__class__` and can be
    forged with a property returning `str`. `json` dispatches on the real
    runtime type, so such a value passed the filter and then raised in the
    fallback's own dump. Now matched on exact runtime type.
  * `int` is a scalar by every type test, but `json` renders ints via `str` and
    CPython caps that at `sys.get_int_max_str_digits()` (4300). Now bounded by
    `bit_length`, which avoids performing the conversion being guarded against.
  * `allow_nan=False`, so a non-finite float routes to the fallback and the
    record stays valid JSON instead of carrying a JavaScript literal.

A final constant-record tier keeps the guarantee a property of the code rather
than of the failure modes anticipated here — the exact gap #1452 was about. It
is not reachable through any input above, and the comment says so.

Tests: +10 in the existing CWE-117 file. All 10 fail against 8517bf8 and pass
after (7 failed / 26 passed -> 33 passed); the pre-existing 26 are unchanged,
so this does not weaken what #1491 established. Full `tests/unit` is unchanged
at 1637 failed / 76 errors (missing optional deps in this environment) with
+10 passed, i.e. no regressions. ruff clean; mypy unchanged at its 17
pre-existing errors in this module.

Refs #1452


Claude-Session: https://claude.ai/code/session_01DHLdfqAJcfL9LPWC7Dp9Gx

Co-authored-by: Claude <noreply@anthropic.com>
groupthinking pushed a commit that referenced this pull request Aug 13, 2026
#1491 wrapped the `json.dumps` call so a bad enrichment could not cost the
whole record, and stated the guarantee as "never lose a record to a
serialization error". The wrapper holds for the two inputs it was written
against, but the *recovery* path it added can itself raise — so the guarantee
covered the anticipated failures rather than the property.

Measured against a real handler on 8517bf8, healthy -> poisoned -> healthy:

  | input                              | before | after |
  |------------------------------------|--------|-------|
  | circular container                 |  3/3   |  3/3  |
  | exploding `__str__`                |  3/3   |  3/3  |
  | exception whose own `__str__` raises | 2/3  |  3/3  |
  | value forging `__class__ = str`    |  2/3   |  3/3  |
  | int past the 4300-digit cap        |  2/3   |  3/3  |
  | non-finite float                   |  3/3*  |  3/3  |

  * emitted, but as a bare `NaN`/`Infinity` literal, which is not valid JSON —
    a strict downstream parser rejects the record, which is the same loss moved
    to the consumer.

Each cause, and the fix:

  * The fallback built `f"{type(exc).__name__}: {exc}"` directly. The exception
    it catches may be one raised from a call site's own `__str__`, so
    describing the failure became the failure. Now `_describe_exception`,
    which falls back to the type name.
  * The scalar filter used `isinstance`, which consults `__class__` and can be
    forged with a property returning `str`. `json` dispatches on the real
    runtime type, so such a value passed the filter and then raised in the
    fallback's own dump. Now matched on exact runtime type.
  * `int` is a scalar by every type test, but `json` renders ints via `str` and
    CPython caps that at `sys.get_int_max_str_digits()` (4300). Now bounded by
    `bit_length`, which avoids performing the conversion being guarded against.
  * `allow_nan=False`, so a non-finite float routes to the fallback and the
    record stays valid JSON instead of carrying a JavaScript literal.

A final constant-record tier keeps the guarantee a property of the code rather
than of the failure modes anticipated here — the exact gap #1452 was about. It
is not reachable through any input above, and the comment says so.

Tests: +10 in the existing CWE-117 file. All 10 fail against 8517bf8 and pass
after (7 failed / 26 passed -> 33 passed); the pre-existing 26 are unchanged,
so this does not weaken what #1491 established. Full `tests/unit` is unchanged
at 1637 failed / 76 errors (missing optional deps in this environment) with
+10 passed, i.e. no regressions. ruff clean; mypy unchanged at its 17
pre-existing errors in this module.

Refs #1452

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHLdfqAJcfL9LPWC7Dp9Gx
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.

default=str in _format_json does not deliver its stated guarantee — a record can still be lost (follow-up to #1439)

2 participants