Repository navigation
perf(serializer): skip the isinstance chain for plain JSON values - #2023
Merged
hassiebp merged 2 commits intoOct 20, 2026
Merged
Conversation
EventSerializer.encode walks the whole object through default() before the C encoder runs, and every str, int, float, dict and list went through the full isinstance chain. default() now returns exact str, None, bool, JS-safe int and finite float values directly, and exact dicts, lists and tuples are walked before the other checks, with the same depth limit. Subclasses and all other types take the existing path, so the output is unchanged.
DavidTraina
marked this pull request as ready for review
October 19, 2026 21:21
Collaborator
|
@claude review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
The SDK serializes every observation's input and output on the caller's thread with
json.dumps(obj, cls=EventSerializer). Metadata values go through the same encoder. In async services that trace large LLM payloads, this runs on the event loop.EventSerializer.encodefirst walks the whole object throughdefault()and only then hands the result to the C encoder. Every str, int, float, dict and list in the payload went through theisinstancechain in_default_innerbefore reaching its branch. On a 42 KB chat completion request, that walk took 6 to 8 times as long asjson.dumpsitself.Changes, all in
langfuse/_utils/serializer.py:default()returns exactstr,None,bool, JS-safeintand finitefloatvalues before the depth accounting._default_innerreturns these same objects unchanged. Ints outside the JS-safe range and NaN/Infinity still take the existing path, so they still become strings, including the fallback for ints too large forstr()._default_inner()walks exactdict,listandtuplevalues before the other checks, with the same depth limit and the same comprehensions as the existing branches. None of the earlier checks can match these exact types.type(x) is ..., so subclasses such as str and int enums, numpy scalars,OrderedDictand namedtuples, and every other type, take the existing path in the existing order.is_js_safe_integer.The output is byte-for-byte unchanged for every input. That includes dict key conversion, depth truncation (
"<dict>","<list>","<tuple>"), circular references, theseenset and both exception fallbacks. The separators andensure_asciisettings only affect the C encoder stage, which is untouched.Two internal details do change. Plain values no longer add a
_default_innerstack frame, so a call made within a few frames of the recursion limit can now succeed wheremainfell back to the not-serializable marker. AnEventSerializersubclass that overrides_default_inneroris_js_safe_integerno longer sees exact plain scalars. The module is private.Benchmark
Thread CPU time per
_serialize()call, which is whatcreate_span_attributesandcreate_generation_attributesrun for input and output. Each value is the median over 6 runs per checkout, alternating which checkout runs first. Environment: Apple M5 Max, pydantic 2.14.0. The machine had other load, so absolute numbers are somewhat inflated. The output hash matchedmainfor every payload.Python 3.13.15:
json.dumps(us)@observeargs/kwargsThe last three rows are the trade-off. A value that misses the fast path, such as a datetime, a UUID or an int above 253, now pays a few extra type checks first, about 35 to 70 ns depending on the Python version. A list made only of such values gets slower: 5 to 8% for datetimes and UUIDs, and 11 to 15% for ints above 253. The cheaper a value is to convert, the larger the relative cost, so a list made only of NaN and Infinity is about 25 to 35% slower. Containers that mix such values with plain ones still get faster, because their plain values take the fast path.
Considered and not done:
default(). That was up to 14% faster on plain payloads, mostly lists of ints, and 3 to 19% slower on lists of values that miss the fast path. I kept the smaller diff.Benchmark script
Run it from outside the repo with
PYTHONPATH=<checkout> python bench_serializer.py, solangfuseis imported from that checkout.Type of change
Verification
List the main commands you ran:
Output equivalence was checked with the script below. It compares the output and the debug log messages of this branch with a copy of the module from
main. Besides plain values, the corpus covers most of the types the serializer special-cases: subclasses and enums of str, int and float,Streaming-named subclasses of the fast-pathed built-ins, ints at and beyond the JS-safe bounds and beyond thestr()digit limit, NaN and infinities, non-str dict keys including keys that convert to unhashable values, sets, models, dataclasses, objects with__dict__or__slots__, and circular containers and objects. It runs both encoder settings the SDK uses, with_MAX_DEPTHset to 1, 2, 3, 5 and 20.python fuzz_equivalence.py 20000runs 102,900 cases, and it found no difference on Python 3.11.15, 3.13.15 or 3.14.5.As a check on the script itself, it fails on each of these deliberate bugs in the fast path: no JS-safe range check, no
isfinitecheck, an off-by-one depth check, no depth check for lists and tuples, dict keys not passed throughdefault(), andisinstanceinstead of the exact type checks for str, int, float or the containers.Test changes:
test_nested_containers_are_truncated_at_depth_boundaryreplaces the dict-only depth test and adds list, tuple and circular-list cases. Dropping the depth check for lists and tuples fails only these new cases.test_plain_containers_convert_nested_valuespins the exact output for ints on both sides of the JS-safe bounds and for NaN and infinities inside plain dicts and lists.test_builtin_subclasses_take_the_full_branch_chainchecks thatStreaming-named subclasses of str, int, float, dict, list and tuple still reach the existingStreamingbranch. It fails if any of the exact type checks becomesisinstance.maintoo.Not run:
tests/e2eandtests/live_provider, which need a server and provider keys. The serialized output is unchanged, so they exercise nothing new here.Equivalence script
Checklist
code_review.md..env.templateif needed. (n/a, no public behavior change)The PR appears safe to merge, with a non-blocking request to add subclass regression tests.
Summary
Speeds up
EventSerializerby returning plain scalar values early and moving exact built-in containers ahead of the other checks.Reviews (1) · Last reviewed commit: "perf(serializer): skip the isinstance ch..." · Reviewed by Greptile