Skip to content

fix(serializer): sanitize NaN/Inf nested in converted containers - #1878

Open
hassiebp wants to merge 1 commit into
mainfrom
hassiebbot/lfe-16279-serializer-nested-nan-2f08
Open

hassiebp wants to merge 1 commit into
mainfrom
hassiebbot/lfe-16279-serializer-nested-nan-2f08

Conversation

@hassiebp

@hassiebp hassiebp commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

EventSerializer already turns NaN / Infinity / -Infinity and JavaScript-unsafe integers into JSON-safe strings for values that hit _default_inner directly, and for Pydantic model_dump() output. Several other conversion branches returned a container or scalar without routing it back through default(), so nested non-finite floats were emitted as bare NaN / Infinity tokens that strict JSON parsers reject.

This routes the remaining branches through default() the same way Pydantic dumps already do:

  • tuple / set / frozenset
  • numpy ndarray / generic
  • dataclasses (asdict)
  • enum.Enum values
  • LangChain Serializable.to_json()

Fixes #1876

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactor
  • Documentation update
  • Tooling, CI, or repo maintenance

Verification

Wrote failing unit tests first, then the production change.

uv run --frozen ruff check langfuse tests/unit/test_serializer.py
uv run --frozen ruff format langfuse/_utils/serializer.py tests/unit/test_serializer.py
uv run --frozen mypy langfuse/_utils/serializer.py --no-error-summary
uv run --frozen pytest tests/unit/test_serializer.py tests/unit/test_json.py -q
  • All checks passed! (ruff)
  • 44 passed (vitest/pytest serializer + json unit tests)
  • Skipped e2e and live-provider tests: this is encoder-local unit behavior with no server.

Checklist

  • I self-reviewed the diff using code_review.md.
  • I added or updated tests for behavior changes.
  • I updated docs, examples, or .env.template if needed.
  • I did not hand-edit generated files; if generated files changed, I used the upstream regeneration path.
  • I did not commit secrets or credentials.
Open in Web Open in Cursor 

RetriggerConfidence Score: 4/5

The behavioral fix appears correct, but the explicit module-level import requirement must be satisfied before merging.

Summary

  • Converts nested non-finite floats to string tokens accepted by strict JSON parsers.
  • Applies JavaScript-safe integer normalization within converted containers.
  • Adds focused strict-JSON regression tests for each affected conversion path.
  • The implementation appears behaviorally sound, but two new function-local imports violate an explicit repository rule.

Reviews (1) · Last reviewed commit: "fix(serializer): sanitize NaN/Inf nested..."

EventSerializer already converted non-finite floats and JS-unsafe
integers for values that reached _default_inner directly, and for
Pydantic model_dump() output. tuple/set/frozenset, numpy scalars and
arrays, dataclasses, enum values, and LangChain to_json() returned
converted containers without routing them back through default(), so
nested NaN/Infinity leaked as invalid JSON tokens.

Route those branches through default() the same way Pydantic dumps
already do.

Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>
@github-actions

Copy link
Copy Markdown

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

No review was started: this request came from a bot account. Manual reviews can only be requested by someone with write access to this repository. Ask a maintainer to comment @claude review, or have your automation post the comment from a user account with write access.

Tip: disable this comment in your organization's Code Review settings.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T15:42:19.229269Z 653c06c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 653c06c52a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

def _reject_json_constant(token):
# json.loads accepts bare NaN/Infinity by default; reject them the way
# the ingestion server's strict parser does.
raise ValueError(f"invalid JSON constant emitted: {token}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Construct the exception message before raising

When the strict loader encounters a bare JSON constant, this line constructs the exception message directly inside raise, contrary to the repository’s explicit Python convention. Assign the formatted text to a variable first and then pass that variable to ValueError.

AGENTS.md reference: AGENTS.md:L147-L149

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines 127 to 133
return f"<{type(obj).__name__}>"

if is_dataclass(obj):
return asdict(obj) # type: ignore
return self.default(asdict(obj)) # type: ignore

if isinstance(obj, BaseModel):
obj.model_rebuild()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Routing dataclass/tuple-set-frozenset/numpy/langchain conversions through self.default() (lines 79-150) now makes their entire nested content consume the shared _MAX_DEPTH=20 budget, whereas before these branches handed the converted dict/list straight to the native JSON encoder with no depth cap at all. Deeply nested LangChain to_json() graphs, nested dataclasses, or multi-level tuples/sets that used to serialize completely now get silently truncated to a bare ""/"" placeholder past ~20 levels. Fix: exempt pure container recursion (dict/list/tuple values that are themselves JSON-native) from the depth counter, or only count depth for genuinely custom-object conversions, so plain nested data is never truncated just because it now passes through self.default().

Extended reasoning...

Before: is_dataclass(obj): return asdict(obj) (and similarly tolist()/list(obj)/to_json()) returned a fully-converted plain dict/list; json's native encoder walks dict/list/tuple internally without ever calling our default(), so no depth limit applied to their contents, only Python's C recursion limit. After: return self.default(asdict(obj)) immediately re-enters _default_inner, hits isinstance(obj, dict) (line 152) which recursively calls self.default() on every value, incrementing self._depth each level; once self._depth>=20 (line 126) any further nested value is replaced by f"<{type(obj).name}>" regardless of whether it is a plain dict/list/int. A LangChain chain/agent whose to_json() output nests >20 levels (common for composed Runnable graphs) or a recursive dataclass tree now loses data mid-structure that fully serialized on the base branch.

Verification: nit. The mechanism is real and demonstrable. default() increments self._depth on every entry (lines 47-51) and line 126-127 truncates any container/object to a bare <type> placeholder once _depth >= _MAX_DEPTH (20). On the base branch, is_dataclass → return asdict(obj), tuple/set/frozenset → return list(obj), ndarray → tolist(), numpy generic → item(), enum → obj.value, and…

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.

EventSerializer emits invalid JSON for non-finite floats nested inside tuples, sets, numpy arrays, dataclasses, enums, and LangChain Serializable output

2 participants