Skip to content

fix(logs): recover concatenated JSON objects in the Provider Event Stream viewer - #13115

Merged
diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.51from
hartmark:fix/request-logger-concatenated-json-stream
Sep 16, 2026
Merged

diegosouzapw merged 2 commits into
cheaper-inference:release/v3.8.51from
hartmark:fix/request-logger-concatenated-json-stream

Conversation

@hartmark

@hartmark hartmark commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

The "Provider Event Stream" viewer on a call log's detail page sometimes
showed raw, unreadable text instead of the expected collapsible JSON tree
-- but only for Gemini, and only intermittently.

Root Cause

Gemini's own stream-chunk capture concatenates multiple complete JSON
objects back-to-back with no separator between them (}{), unlike the
SSE blank-line-per-event framing every other provider's capture uses here.
A single data: payload can therefore hold several complete top-level
JSON values, not one -- JSON.parse correctly rejects the whole thing:

Unexpected non-whitespace character after JSON at position 352

...and the entire payload fell back to plain text, even though every
individual object inside it was perfectly well-formed.

I confirmed this by replaying real stored streamChunks.provider
artifacts from production call logs: 161 of 663 recent streaming logs
hit this, all gemini, some with as many as 11 concatenated objects
in a single capture.

Fix

When a data: payload fails to parse as one JSON value,
splitConcatenatedJsonValues() scans bracket/string depth (properly
handling escaped quotes) to recover each complete top-level JSON value in
sequence. Each recovered value renders as its own collapsible node instead
of the whole thing collapsing to unreadable raw text.

Genuinely incomplete or non-JSON content (a real mid-stream truncation,
SSE comments, keep-alives) still falls back to plain text exactly as
before -- the splitter only accepts a payload if it can fully account for
every byte as complete top-level JSON values, and returns null (no
change in behavior) otherwise.

Evidence

$ node --test tests/unit/request-logger-stream-concatenated-json.test.ts
tests 8, pass 8, fail 0

$ node --test tests/unit/request-logger-detail-copy-all.test.ts
tests 4, pass 4, fail 0
  • eslint: clean
  • tsc --noEmit: no errors in touched files

Live-verified: replayed 8 real production call-log artifacts (the
original incident plus 7 more found via a live scan) through the fixed
parser -- all now recover 100% as JSON segments with zero text fallback,
versus partial/total text fallback before.

…ream viewer

Gemini's own stream-chunk capture concatenates multiple complete JSON
objects back-to-back with no separator between them ("}{"), unlike the
SSE blank-line-per-event framing every other provider's capture uses here.
A single `data:` payload can therefore hold several complete top-level
JSON values, not one -- JSON.parse correctly rejected the whole thing
("Unexpected non-whitespace character after JSON") and the *entire*
payload fell back to plain text in the "Provider Event Stream" viewer,
even though every individual object inside it was perfectly well-formed.

Observed live: replayed real stored streamChunks.provider artifacts from
production call logs -- 161 of 663 recent streaming logs hit this, all
gemini, some with as many as 11 concatenated objects in a single capture.

Fix: when a `data:` payload fails to parse as one JSON value,
splitConcatenatedJsonValues() scans bracket/string depth (properly
handling escaped quotes) to recover each complete top-level JSON value in
sequence. Each recovered value renders as its own collapsible node instead
of the whole thing collapsing to unreadable raw text. Genuinely incomplete
or non-JSON content (a real mid-stream truncation, SSE comments,
keep-alives) still falls back to plain text exactly as before -- the
splitter only accepts a payload if it can fully account for every byte as
complete top-level JSON values, and returns null (no change in behavior)
otherwise.

Regression tests cover the splitter directly (multiple objects, escaped
braces/quotes inside strings, a single object correctly returning null so
it isn't double-handled, non-JSON text, and a genuinely truncated value
correctly still failing) plus parseStreamIntoSegments end-to-end using the
exact shape a real 10-chunk Gemini capture produces, and confirm no
regression for genuine non-JSON content (comments/keep-alives). Confirmed
the new test file fails against the pre-fix code (export doesn't exist
yet) and passes after.

Evidence:
- node --test tests/unit/request-logger-stream-concatenated-json.test.ts: 8/8 pass
- node --test tests/unit/request-logger-detail-copy-all.test.ts: 4/4 pass (unaffected)
- eslint: clean
- tsc --noEmit: no errors in touched files
- Live-verified: replayed 8 real production call-log artifacts (the
  original incident plus 7 more found via a live scan) through the fixed
  parser -- all now recover 100% as JSON segments with zero text fallback,
  versus partial/total text fallback before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ewer

Default stays the parsed JsonView tree (this PR's own concatenated-JSON
recovery included); a new toggle button next to autoscroll/copy lets an
operator flip to the exact byte-for-byte captured stream -- timestamp
markers and all -- for the cases where the rendered tree hides or
misrepresents what actually came over the wire. Persisted per browser
via localStorage, same pattern as the existing autoscroll toggle.

New regression test pins the default: renders with no prior opt-in and
asserts the raw-view toggle is present (labeled off) while the literal
raw `data: {...}` line does NOT appear as one contiguous substring --
only the parsed JsonView tree does.

Evidence:
- node --test tests/unit/request-log-detail-stream.test.ts: 6/6 pass
- node --test tests/unit/request-logger-stream-concatenated-json.test.ts: 8/8 pass (unaffected)
- eslint: clean on both touched source/test files
- scripts/i18n/check_translations.py: no missing-key hit for the two new
  `rawViewOn`/`rawViewOff` keys

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vk2cBRQdErwRtPea8VuhG6
hartmark added a commit to hartmark/OmniRoute that referenced this pull request Sep 13, 2026
…N objects in the Provider Event Stream viewer) into dev/omniroute-dev-combined
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Excellent debugging trail — replaying real production stream artifacts to quantify the impact
(161/663 recent Gemini logs) before writing the fix is exactly the kind of evidence that makes
this easy to trust. splitConcatenatedJsonValues() is clean and conservative (falls back to
the old behavior — null — for anything it can't fully account for byte-for-byte), and the
raw-view toggle is a nice small addition for debugging future parser gaps. Ran your full test
suite in isolation: 14/14 pass, including the real 10-chunk production artifact case. This
looks merge-ready.

@diegosouzapw
diegosouzapw merged commit ac32cea into cheaper-inference:release/v3.8.51 Sep 16, 2026
9 of 16 checks passed
diegosouzapw added a commit that referenced this pull request Sep 16, 2026
Lands the combined-board reconciliation of today's 22-PR wave as one follow-up: i18n fill for #13115's two new keys (65 locales, real translation in vi) and the file-size rebaseline for cursor.ts (#13627) and chatHelpers.ts (#13879). check-new-key-coverage PASS; only the three pre-existing file-size reds remain on the tip.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…ream viewer (cheaper-inference#13115)

Merged. Concatenated JSON objects in the Provider Event Stream are a real wire shape, not a corruption — recovering them in the viewer instead of dropping the batch makes the tool trustworthy when it matters.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thank you.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…per-inference#13904)

Lands the combined-board reconciliation of today's 22-PR wave as one follow-up: i18n fill for cheaper-inference#13115's two new keys (65 locales, real translation in vi) and the file-size rebaseline for cursor.ts (cheaper-inference#13627) and chatHelpers.ts (cheaper-inference#13879). check-new-key-coverage PASS; only the three pre-existing file-size reds remain on the tip.
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.

2 participants