Skip to content

fix(logging): stop a bad enrichment value losing the whole JSON record - #1472

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

fix(logging): stop a bad enrichment value losing the whole JSON record#1472
groupthinking wants to merge 2 commits into
mainfrom
claude/clever-heisenberg-spk8wk

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1452

The follow-up #1452 sequenced to land after #1439. That merged at 20:47, so _format_json is now on main and this is no longer a competing implementation of #1429.

Outcome

A JSON log record is no longer dropped when an optional enrichment value cannot be serialized.

_format_json passed default=str to json.dumps and its comment claimed that kept a non-serializable extra value from costing us the record. It does not, in two ways:

Case Why default=str misses it
Circular container json.dumps rejects the cycle structurally, before default is consulted — so default=str never sees it.
Value whose __str__ raises default is consulted, and the exception propagates straight back out of it.

Either way json.dumps raises inside the logging path, logging swallows it via Handler.handleError, and the record is dropped entirely — the specific outcome the comment said was prevented.

Measured against main, three logger.info calls with the middle one poisoned:

main       ->  2 of 3 records reached the sink
this branch->  3 of 3, with serialization_error naming the cause

The fallback keeps every scalar field — notably level, which routing and alerting depend on — and reports the failure in-band rather than swallowing it.

Scope

Two deliberate choices worth flagging:

  • except Exception, not a narrow tuple. (TypeError, ValueError, RecursionError) looks more correct and the exploding-__str__ case walks straight through it.
  • Building the description is itself guarded. Rendering the exception calls the same __str__ that raised. Asserting "never lose a record" is only worth doing if the fallback cannot become the thing that loses it.

Risk

  • Risk level: low
  • Failure mode: the fallback drops non-scalar fields from the payload. It only runs on a path that previously emitted nothing at all, so it is strictly more information than before. A test pins that the normal path still carries scalar enrichments, so the fallback cannot quietly become the common case.
  • Rollback: git revert. No migration, config, or schema change; the emitted field set on the success path is byte-identical.

Verification

Head e138fdd.

  • Focused teststests/unit/test_logging_config_crlf.py: 26 passed. The 19 pre-existing tests are unmodified and still pass, so the fix(security): CWE-117 JSON field forgery via unescaped " survives the #1270 log sanitizer #1429 property and the line-oriented contract are both intact.

  • Reproduced before fixing, against main — both cases, 2 of 3 records reaching the sink.

  • Non-vacuity checked in both directions, not asserted. Reverting only the source file and keeping the new tests:

    5 failed, 21 passed
    

    The sixth new test passes either way by design — it guards the fallback's blast radius rather than the bug.

  • ruff check on both changed files — clean.

  • Required CI — will populate on this head.

  • Review threads resolved — none open yet.

Production evidence

Not applicable as a preview — backend logging, no apps/web/** surface, so gate 4 of MERGE_POLICY.md does not apply.

The runtime evidence is the reproduction itself, run through a real StreamHandler in both directions (transcripts above). Exposure is real rather than theoretical: production_config.py:72 defaults JSON_LOGGING to "true", so this is the live formatter path — though reaching it needs a future call site that passes a non-scalar, since every current one passes a string or int.

Agent handoff

Agent provenance

Agent-authored under the PR remediation runbook. The finding was independently derived by three red-team passes on #1439 and tracked zero times until #1452; this is the fourth pass reading the issue instead of re-deriving it.


Generated by Claude Code

Closes #1452

`_format_json` passed `default=str` to `json.dumps` and its comment claimed
that kept a non-serializable `extra` value from costing us the record. It
does not, in two ways:

  * a circular container is rejected structurally, before `default` is ever
    consulted, so `default=str` never sees it;
  * a value whose `__str__` raises propagates that exception out of `default`
    itself.

Either way `json.dumps` raises inside the logging path, `logging` swallows it
via `Handler.handleError`, and the record is dropped. Measured against `main`
with three `logger.info` calls and the middle one poisoned: 2 of 3 records
reached the sink. With this change, 3 of 3.

The fallback keeps every scalar field -- notably `level`, which routing and
alerting depend on -- and reports the failure in-band as `serialization_error`
rather than swallowing it. `except Exception` is deliberate: a narrow
`(TypeError, ValueError, RecursionError)` looks more correct but the
exploding-`__str__` case walks straight through it. Building the description
is itself guarded, since rendering the exception calls the same `__str__` that
raised.

Reachable only through the optional `correlation_id` / `performance_ms`
enrichments; every live call site passes a scalar today, and an
attacker-supplied header is a string, so this is robustness rather than a
vulnerability. The CWE-117 field-forgery property from #1429/#1439 is
untouched.

6 regression tests; 5 fail against the pre-fix implementation. The sixth
guards the fallback's blast radius by asserting scalar enrichments still
reach a normal record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVnVRDHCJWNjN6pLhHFW8k
@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: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: 5ea31b6a-d258-4433-a552-1219fb5d79cb

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

Requesting manually because auto-review skipped again with "Auto reviews are limited based on label configuration" — worth recording, because this is evidence that #1425's fix did not close #1424.

#1425 merged reviews.auto_review.labels: [] into .coderabbit.yaml specifically to override an inherited required-labels list. This PR branched off main after that merge, and the skip notice still reports Required labels (at least one) **(1)** with the same folded bracketed scalar. The run configuration names the source: Repository YAML (base), Repository UI (inherited), **Organization UI (inherited)**. So the org-level UI setting is still supplying that one malformed pseudo-label, and the repo YAML is not overriding it. The remaining fix is in the CodeRabbit dashboard, not in this repo — no commit here can close it.

Not opening an issue for that; #1424 already owns it and should simply be reopened or have its scope corrected to "org UI, not repo YAML".

On the change itself — this is a robustness fix to a logging path, so the claims worth attacking are specific:

  1. Is the fallback reachable only where I say it is? I claim only the optional correlation_id / performance_ms enrichments can carry a non-serializable value, because every other field is a str/int derived from the LogRecord. If formatException, formatStack, or record.getMessage() can return something non-serializable, the fallback is doing more than advertised.

  2. Does the fallback preserve what actually matters? It keeps only scalars, so exception and stack_info are dropped on that path. I think that is right — the alternative is losing the record entirely — but if a traceback is the thing you most need when serialization is failing, that trade is worth challenging.

  3. Is except Exception too broad? Deliberate: my first draft used (TypeError, ValueError, RecursionError) and the exploding-__str__ case walked straight through it. But a genuinely broad catch here could mask a bug in formatTime or formatException rather than an extra value.

  4. Is the guarded description construction real or theatre? f"{type(exc).__name__}: {exc}" calls the same __str__ that raised, so it is wrapped. I could not construct a case where the inner try fires without an adversarial exception class — tell me if it is dead code.

One thing I would rather you catch than have me assert: the non-vacuity claim. 5 of the 6 new tests fail with only logging_config.py reverted to main; the sixth (test_serializable_enrichments_still_reach_the_json_record) passes either way by design, guarding the fallback's blast radius rather than the bug. If that sixth test is actually vacuous rather than deliberately-always-green, I would like to know.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== changed files and diff ==='
git diff --find-renames origin/main...HEAD -- \
  src/youtube_extension/backend/config/logging_config.py \
  tests/unit/test_logging_config_crlf.py

printf '%s\n' '=== formatter outline ==='
ast-grep outline src/youtube_extension/backend/config/logging_config.py --items all

printf '%s\n' '=== test outline ==='
ast-grep outline tests/unit/test_logging_config_crlf.py --items all

printf '%s\n' '=== logging formatter references and logger calls ==='
rg -n -C 3 --glob '*.py' \
  '(_format_json\b|JSON_LOGGING\b|correlation_id\b|performance_ms\b|extra\s*=|formatException\s*\(|formatStack\s*\(|getMessage\s*\()' \
  src tests || true

printf '%s\n' '=== logging configuration source ==='
cat -n src/youtube_extension/backend/config/logging_config.py

printf '%s\n' '=== complete changed test file ==='
cat -n tests/unit/test_logging_config_crlf.py

Length of output: 60986


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== tracked matching paths ==='
git ls-tree -r --name-only HEAD | rg '(^|/)(test_logging_config_crlf\.py|logging_config\.py)$' || true

printf '%s\n' '=== HEAD test file ==='
git show HEAD:tests/unit/test_logging_config_crlf.py 2>/dev/null || true

printf '%s\n' '=== base test file ==='
git show origin/main:tests/unit/test_logging_config_crlf.py 2>/dev/null || true

printf '%s\n' '=== formatter source on main ==='
git show origin/main:src/youtube_extension/backend/config/logging_config.py | sed -n '90,205p'

printf '%s\n' '=== all formatter test references in tracked files ==='
git grep -n -I -E 'StructuredFormatter|_make_json_logger|_format_json|serialization_error' HEAD -- ':!*.lock' || true

printf '%s\n' '=== standard-library logging LogRecord message behavior ==='
python - <<'PY'
import inspect
import logging
print(inspect.getsource(logging.LogRecord.getMessage))
print(inspect.getsource(logging.Formatter.format))
PY

Length of output: 41428


@groupthinking: Full review complete. I found no blocking defect in this change.

  1. The fallback is reachable from json.dumps failures after payload construction. The optional enrichments are the only non-scalar values under the normal LogRecord and Formatter contracts.

    record.getMessage(), formatTime(), formatException(), and formatStack() run before the try block. A failure in any of those operations still drops the record. Therefore, the comment should not state an unqualified “never lose a record.” It should state that this fallback prevents record loss from JSON serialization failures.

  2. exception and stack_info are retained by the fallback. The standard formatter methods return str, and safe keeps str values. An optional enrichment that fails JSON serialization does not remove an already-rendered traceback or stack information.

  3. except Exception does not mask failures from formatTime, getMessage, formatException, or formatStack. Those calls occur before the guarded json.dumps call. The broad catch is appropriate for the serialization boundary because a custom __str__ can raise an arbitrary Exception subclass.

  4. The guarded error-description construction is reachable. An object can raise a custom exception from __str__, and that exception can itself raise from __str__. The current _ExplodingStr test does not cover this nested case, but the guard is not dead code.

  5. test_serializable_enrichments_still_reach_the_json_record is not vacuous. It verifies that a serializable enrichment uses the normal path, retains correlation_id, and omits serialization_error. It should pass before and after this fix by design.

The reported auto-review skip is consistent with an inherited Organization UI label requirement overriding the repository YAML. #1424 should track the dashboard configuration correction.


✏️ Learnings added
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1472

Timestamp: 2026-08-07T20:56:51.189Z
Learning: For the groupthinking/EventRelay repository, an inherited Organization UI CodeRabbit auto-review label requirement can override `reviews.auto_review.labels: []` in `.coderabbit.yaml`. When auto-review skips with “Auto reviews are limited based on label configuration” and reports a required malformed pseudo-label, correct the Organization UI configuration rather than changing repository code. Issue `#1424` tracks this configuration problem.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

⚠️ 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 53 minutes.

Copy link
Copy Markdown
Owner Author

Duplicate of #1477 — three PRs (#1471, #1472, #1477) implement #1452, all created within three minutes of each other.

The diagnosis here is correct and the fix works; this is purely a reconciliation call under MERGE_POLICY.md gate 6, which breaks ties toward the fresher PR. #1477 also isolates _describe_exception and tests it directly, which is the one genuinely subtle property in this change.

Functionally the three are equivalent — your fallback passes default=str where #1471 omits it, but safe is filtered to JSON-native scalars first, so it is unreachable either way.

One thing worth carrying to #1477 if it is revised: your test_serializable_enrichments_still_reach_the_json_record guards the fallback's blast radius by proving a scalar correlation_id still rides the normal path. #1477 has the equivalent (test_benign_enrichments_still_reach_the_json_record), so nothing is lost here — noting it only so the check is known to survive the close.

Recommending close in favour of #1477. Full comparison and verification evidence is there.


Generated by Claude Code

…rces

Addresses CodeRabbit's review on #1472.

The comment asserted "never lose a record" without qualification. `payload` is
built *above* the guarded `json.dumps`, so a raise from `getMessage()` (bad
%-args), `formatTime`, `formatException` or `formatStack` still drops the
record. Confirmed rather than accepted on review:

    logger.info('user=%d', 'not-an-int')  ->  TypeError, record lost

Shipping that sentence would have been the same defect this change exists to
fix -- a comment claiming more than its code enforces. The comment now scopes
the guarantee to the serialization boundary and names the pre-existing
exposure it does not cover, which the line-oriented path shares.

Also corrects the opposite error, in my own review comment rather than the
code: I claimed `exception` and `stack_info` are dropped by the fallback. They
are not. Both are rendered to `str` before the dump, so the scalar filter keeps
them -- a failing enrichment does not cost the traceback. Now asserted by
`test_rendered_traceback_survives_the_serialization_fallback`.

Adds the nested-raise case the suite did not type: a value whose `__str__`
raises an exception that itself raises when rendered. This is what makes the
inner guard live code -- with it removed, that test and only that test fails.

28 tests pass; ruff clean.

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

Copy link
Copy Markdown
Owner Author

Both substantive points addressed in 468537d. I verified each against a running interpreter rather than accepting them, and one of them proves a claim I made in this thread was wrong.

1 — The comment overclaimed. Correct, and fixed.

You're right that payload is built above the guarded json.dumps, so the unqualified "never lose a record" was false. Confirmed:

logger.info('user=%d', 'not-an-int')   ->  TypeError raised from getMessage()
                                           outside the try; record lost

That sentence would have shipped the exact defect this change exists to remove — a comment claiming more than its code enforces. The comment now scopes the guarantee to the serialization boundary and names what it does not cover (getMessage, formatTime, formatException, formatStack), noting those are pre-existing and shared with the line-oriented path rather than introduced here.

2 — I was wrong, you were right: the traceback survives.

In my review request I wrote that "exception and stack_info are dropped on that path" and offered it as a trade worth challenging. That was simply incorrect. Both are rendered to str before the guarded dump, so the scalar filter keeps them:

fallback fired    = True
exception retained = True     # "the real cause" still present

There was no trade-off to weigh — I invented one. Now pinned by test_rendered_traceback_survives_the_serialization_fallback, with a comment recording that it exists because I claimed the opposite.

4 — Nested raise: you were right that it was reachable but untested.

Added _NestedExplodingStr → raises _ExplodingError, whose own __str__ raises. Proven live rather than asserted — with the inner guard deleted:

1 failed, 27 passed
FAILED test_exception_whose_str_also_raises_does_not_lose_the_record

Exactly that test and nothing else, which is the precise form of non-vacuity I wanted.

3 and 5 — no change needed

Agreed on both, and 5 is the answer I was fishing for: test_serializable_enrichments_still_reach_the_json_record is deliberately always-green, guarding the fallback's blast radius rather than the bug. Recording that here so a later reader doesn't "fix" it into a test of the bug.

Head 468537d — 28 tests pass, ruff clean on both files. CI re-running.

On the auto-review skip: agreed, and thank you for confirming the Organization UI reading. #1424 needs its scope corrected from repo YAML to the dashboard — no commit in this repo can close it, which is why #1425 merged without fixing it. Not something I can action from here.

Noting your rate limit: I won't re-trigger a review for this push. The change is comment scoping plus two tests, with the source behaviour unchanged apart from the guarantee it claims.


Generated by Claude Code

groupthinking pushed a commit that referenced this pull request Aug 7, 2026
Both salvaged from #1472, a competing implementation of #1452. They pass
unmodified on this head, so they are pure coverage gain rather than a
behaviour change.

  * An enrichment whose __str__ raises an exception that ITSELF raises
    when rendered. This is what makes _describe_exception's inner guard
    live code rather than defensive decoration -- the existing unit test
    calls the helper directly, which cannot show the path is reachable
    through the formatter.
  * A traceback must survive the fallback. `exception` is rendered to str
    before the guarded dump, so a poisoned enrichment firing the fallback
    must not also cost the traceback.

With this, the branch carries the union of all four competing PRs:
#1471's exact-type filter, #1472's two tests, and the non-finite float
guard found by this run's red-team pass, which none of the others have.

32 passed. ruff clean.

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

Copy link
Copy Markdown
Owner Author

Live defect here: the isinstance filter lets the fallback drop the record

Four PRs implement #1452 (#1471, #1472, #1477, #1488), opened by four sessions inside six minutes with no visibility of each other. Full comparison on #1452.

Flagging this one because it should not merge as-is. #1471 caught something the rest of us missed: isinstance consults value.__class__, which an object can forge with a property returning str. Such a value passes the scalar filter, reaches the fallback's second json.dumps, and raises — costing the record the fallback exists to save. Reproduced against #1477's head, which had the identical isinstance filter:

isinstance(v, str)      : True
_is_json_safe_scalar(v) : True   <- passed the filter
fallback json.dumps     : TypeError
end-to-end              : 2 of 3 records reached the sink

This PR is exposed twice over. The filter is isinstance, and the fallback dump keeps default=str:

return json.dumps(safe, ensure_ascii=True, default=str)

That reinstates the raising-__str__ hole on the fallback path itself — the forged value reaches default, str() raises, and the record is gone. #1471's comment is right that nothing may reach default there. The fix is type(value) in {...} and dropping default= from the second dump.

Separately, and present in all four: json.dumps renders non-finite floats as NaN/Infinity, which aren't valid JSON. No raise, so the record looks healthy and a strict downstream parser rejects it. json.loads accepts them by default, which is why no suite caught it.

Your two tests were the best in the set, and I've taken them

Both pass unmodified on #1477 — pure coverage gain, credited in the commit:

  • _NestedExplodingStr — an enrichment whose __str__ raises an exception that itself raises when rendered. This is what makes the inner reporter guard demonstrably live code; my version only unit-tested the helper directly, which can't show the path is reachable through the formatter.
  • Traceback survives the fallback. Nobody else asserted it.

Your scope note was also the most accurate of the four — you were the only one to say up front that this guards the serialization boundary only, and that a raise from getMessage()/formatTime/formatException is still fatal and shared with the line-oriented path. #1471, #1488 and my own first draft all claimed the broader "never lose a record". I've narrowed #1477's comment to match yours.

Recommending #1477 as the merge base — it carries #1471's filter, your two tests, and the non-finite guard. Human call; I haven't closed anything. If this PR is preferred instead, the isinstance filter and the default=str on the fallback both need fixing first.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Superseded — #1452 landed as #1491 while this was open, so the base fix here is now on main.

The contribution worth keeping from this one is the honesty about scope: the merged comment implies a broader guarantee than the code gives (payload construction can still raise upstream of serialization — record.getMessage() on mismatched %-args). That SCOPE note is carried into #1497, so the guarantee isn't overstated a third time.

Recommend closing this in favour of #1497.


Generated by Claude Code

@groupthinking

Copy link
Copy Markdown
Owner Author

Superseded by #1491 (merged 2026-08-07), which closed #1452 — same outcome (do not drop JSON log records on serialization failure). This PR is CONFLICTING with main and is a competing implementation of the same issue. Closing to clear the draft backlog.

@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 468537d.
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.

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

2 participants