Repository navigation
feat(tracing)!: JSON-encode metadata attribute values - #1958
niklassemmler wants to merge 5 commits into
Conversation
Per-key `langfuse.observation.metadata.<key>` attributes now carry `json.dumps(value, cls=EventSerializer)` for every value, strings included. Previously str and int were passed raw/natively, so ints beyond int64 made the OTLP encoder drop the key, ints beyond 2^53 lost precision on the server, and a server that JSON-decodes values could not tell the string "123" from the number 123. None values are still skipped so updates keep earlier keys. The bare non-dict metadata attribute is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
871e170 to
1090a1e
Compare
|
@claude review |
There was a problem hiding this comment.
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.
The bare `langfuse.observation.metadata` attribute (non-dict metadata) is now sent as `json.dumps(metadata, cls=EventSerializer)`, strings included, matching the per-key values and langfuse-js #1005. Previously strings were passed through raw, so `metadata="foo"` sent `foo` in Python but `"foo"` in JS. None is still not written. Input/output and other `_serialize` callers are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Beyond the inline finding, I also checked the lack of a version/feature gate for the new JSON-quoted wire format against self-hosted servers that haven't added the corresponding decoding change — the PR description explicitly discloses this as an intentional, coordinated-rollout constraint ("must ship together with or after that server change"), not an overlooked gap, so I'm not raising it separately.
Extended reasoning...
The diff changes _flatten_and_serialize_metadata in langfuse/_client/attributes.py to json.dumps every per-key metadata dict value and to skip None values, with matching test updates across four unit test files; it touches span/trace attribute export (no auth/crypto) but does affect how exported string attributes are later scanned for base64 media by span_exporter.py. I verified the confirmed inline finding by tracing _is_base64_data_uri in span_exporter.py, which requires value.startswith("data:") — after json.dumps a base64 data-URI string gets wrapped in literal quotes, so the startswith check fails and the payload ships inline instead of being extracted as media, matching the reported bug. Given that confirmed regression, a human should review before merging.
| # JSON-encode every value, strings included, so the server can decode | ||
| # it back to its original type ("123" vs 123). EventSerializer turns | ||
| # ints outside the JS-safe range and NaN/Infinity into JSON strings | ||
| # and never raises, so one bad value cannot drop the other keys. | ||
| metadata_attributes[f"{prefix}.{key}"] = json.dumps( |
There was a problem hiding this comment.
🔴 Metadata values that are raw base64 data-URI strings (e.g. metadata={"img": "data:image/jpeg;base64,..."}) no longer get auto-extracted as Langfuse media; the full base64 payload now ships inline in the exported span instead of being uploaded and replaced with a short reference, unlike on base. json.dumps(value, cls=EventSerializer) at attributes.py:212-214 wraps every string in literal quotes, so the exported attribute starts with '"' instead of 'data:'. LangfuseTransformingSpanExporter._process_media_string (span_exporter.py:217 _is_base64_data_uri, :234-237 the '{'/'[' prefix check) no longer matches, so media_manager._find_and_process_media never runs. Fix: when encoding per-key metadata strings, still allow the export-stage media detector to see raw base64/JSON-media content (e.g. unwrap JSON string quoting before pattern-matching, or run media extraction before JSON-encoding).
Why this was flagged
Trigger: a caller passes metadata containing a bare base64 data-URI string as a dict value, e.g. start_as_current_observation(metadata={"img": "data:image/jpeg;base64,AAAA"}). Before this diff that string attribute value was unquoted at export, so LangfuseTransformingSpanExporter._process_media_string's _is_base64_data_uri(value) (span_exporter.py:559-565) matched it and media_manager._find_and_process_media (span_exporter.py:219) uploaded it and replaced it with a '@@@ langfuseMedia:...@@@' reference. After attributes.py:212-214 wraps the string in json.dumps, the value starts with '"' so _is_base64_data_uri fails, and the json-prefix fallback (span_exporter.py:234-237, requires '{' or '[') also fails too. Nested dict/list metadata values still round-trip through JSON and are unaffected; only the bare top-level string case silently stops being media-extracted, with no error or log, sending the raw base64 payload as a large span attribute instead.
Verification: On base, attributes.py:203-206 passed string metadata values through raw, so metadata={"img": "data:image/jpeg;base64,AAAA"} exported as the literal data:image/jpeg;base64,AAAA. _is_base64_data_uri (span_exporter.py:559-565) matched, and _find_and_process_media (span_exporter.py:217-232) uploaded it and replaced it with a short reference. After the diff, attributes.py:212-214 sets the value via json.dumps(value, cls=EventSerializer), producing '"data:image/jpeg;base64,AAAA"'.
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
The server JSON-decodes per-key metadata values only for Python SDK major >= 5 (langfuse/langfuse#18436). On 4.x the experiment items API returns the raw JSON string, so compare against the encoded value until the version bump. test_concurrency now writes an int so it round-trips the same way with and without server-side decoding. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-ASCII
Encode observation and trace metadata values with separators=(",", ":")
and ensure_ascii=False so the output is byte-identical to JS
JSON.stringify, matching propagated metadata (#1932).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… None Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Problem
Per-key metadata attributes (
langfuse.observation.metadata.<key>) are built in the dict branch of_flatten_and_serialize_metadata. Todaystrandintvalues are passed through as-is and everything else goes through_serialize(JSON). That causes two problems:"123"or"true"would decode to a number/bool, so strings and numbers would be conflated.Fix
Every value in the dict branch is now sent as
json.dumps(value, cls=EventSerializer), strings included. Non-dict metadata on the barelangfuse.observation.metadataattribute gets the same encoding.separators=(",", ":"), ensure_ascii=False, so the output is byte-identical with JSJSON.stringify(compact separators, non-ASCII kept as is), same as feat(tracing)!: JSON-serialize propagated metadata values #1932.EventSerializeralready turns ints outside the JS-safe range into JSON strings and NaN/Infinity into strings, so nothing extra is needed for those.EventSerializer.encodecatches every exception and returns a placeholder JSON string, so a value that fails to serialize can't drop the other keys. No extra per-value handling added.Nonevalues are still skipped (logged at debug level, same message as langfuse-js fix(media): allow setting IO media via decorator update #1005), soupdate(metadata={"k": None})doesn't overwrite an earlier value.langfuse.observation.metadataattribute) is now JSON-encoded too, strings included, matching langfuse-js fix(media): allow setting IO media via decorator update #1005. Before, it went through_serialize, which passesstrthrough raw, sometadata="foo"sentfoofrom Python and"foo"from JS.Noneis still not written. Input/output and the other_serializecallers are unchanged.type="trace"prefix of the same function gets the same encoding. Onprepare-v5-releaseno production code calls it with"trace"(create_trace_attributestakes no metadata); only a unit test does._flatten_and_serialize_metadata_values(propagated trace metadata viapropagate_attributes) is not touched here. feat(tracing)!: JSON-serialize propagated metadata values #1932 (already inprepare-v5-release) covers it.run_experimentonly pass metadata dicts in. Nothing in the SDK reads these attributes back, so no integration code changed.This is one of three small PRs replacing the abandoned single-JSON-blob approach in #1944 (LFE-17153).
Wire values
"hello"hello(str)"hello""123"123(str)"123""true"true(str)"true"55(OTel int)5(str)Truetrue(OTel bool)true(str)1.51.5(str)1.5(str)2**70"1180591620717411303424"float("nan")"NaN""NaN"[1, "a", None][1, "a", null][1,"a",null]{"a": {"b": [1]}}{"a": {"b": [1]}}{"a":{"b":[1]}}datetime(..., tz=UTC)"2024-01-02T03:04:05Z""2024-01-02T03:04:05Z"NoneNon-dict metadata on the bare
langfuse.observation.metadatakey, matching langfuse-js #1005:"foo"foo"foo"555[1, "a", None][1, "a", null][1,"a",null]NoneWire format change
langfuse.observation.metadataattribute for non-dict metadata, are now JSON strings (same as langfuse-js fix(media): allow setting IO media via decorator update #1005). Custom span exporters andmask_otel_spanscallbacks now see e.g.'"hello"'instead of'hello', and"5"/"true"strings instead of native int/bool attributes."hello"including the quotes) and numbers/bools as strings. This PR must ship together with or after that server change.E2E / live-provider tests that depend on server-side decoding
I did not change these expectations. They read observation metadata back through the API and will pass only once the server decodes the JSON values:
tests/e2e/test_core_sdk.py:test_concurrency(metadata["count"] == i, int),test_create_generation_complex(metadata["tags"] == ["yo"]; JSON before and after, unaffected),test_update_generation,test_update_span,test_end_generation_with_data,test_end_span_with_data,test_kwargs(string values such as"value","whatsapp")tests/e2e/test_decorators.py:test_nested_observations,test_nested_observations_with_non_parentheses_decorator,test_concurrent_decorator_executions,test_decorators_langchain,test_decorated_class_and_instance_methods,test_async_nested_openai_chat_stream(observationmetadata["key"]/someKey), plus the multiproject tests that readobs.metadata.get("level" / "async" / "type")tests/e2e/test_media.py::test_replace_media_reference_string_in_objectandtests/e2e/test_decorators.py::test_media(dict value; JSON before and after, unaffected)tests/live_provider/test_langchain.py(metadata["is_langchain_root"] is True, bool) andtests/live_provider/test_openai.py(metadata["someKey"] == "someResponse")tests/e2e/test_core_sdk.py:test_create_numeric_score,test_create_boolean_score,test_create_categorical_scoreandtest_create_text_scoreset non-dict metadata (metadata="test") on a generation but never read it back, so they don't depend on the decoding.Trace-level metadata assertions (
trace.metadata[...]) in e2e go throughpropagate_attributesand are out of scope here (see #1932).CI follow-up (2f2596f)
The server decodes per-key metadata only for Python SDK major >= 5 (langfuse/langfuse#18436, not released yet), so on this 4.x branch nothing is decoded in CI. Three e2e tests failed because of that:
test_concurrencywrotestr(i)and asserted an int. It now writes the int, which reads back the same way with or without decoding.test_run_experiment_on_local_dataset/test_run_experiment_on_langfuse_dataset: the experiment items API doesn't parse metadata values on read, so values came back as'"Euro capitals"'. They now compare againstraw_metadata_value(...)(tests/support/utils.py). That helper expects the JSON string below SDK major 5 and the plain value from 5 on, so the strict assertions return automatically with the version bump.Verification
uv run --frozen ruff check .: passeduv run --frozen ruff format --check .: onlytests/unit/test_media.pywould be reformatted. This PR doesn't touch that file and it's the same onmain.uv run --frozen mypy langfuse --no-error-summary: passeduv run --frozen pytest -n auto --dist worksteal tests/unit(after rebasing ontoprepare-v5-release): 773 passed, 2 skipped, 18 errors. All 18 errors are intests/unit/test_prompt.py("Langfuse client is not initialized"), which also happens locally onmain.tests/unit/test_otel.py::TestMetadataHandlingwere written first and failed before the fix: per-type encoding, big int surviving real OTLPencode_spans,Nonekeeping the earlier value on update, and the trace prefix.test_non_dict_metadata_is_json_encodedcovers string, int and list as non-dict metadata.🤖 Written by Claude (an AI agent) on behalf of Niklas.
🤖 Generated with Claude Code
Fix the lost run scores, repeated evaluations, and stale-output scoring before merging, and satisfy the required import layout.
What we checked:
EventSerializer.encodecatches encoding errors and returns a JSON string. Each metadata key is encoded separately.Summary
The diff JSON-encodes each observation metadata value and preserves existing values when updates contain
None. It also changes experiment tracking, batch evaluation, exporter defaults, supported Python versions, and integration tests.niklassemmleracknowledged the metadata wire-format change and its server-decoding dependency; those were not reported again.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Observation metadata] --> B[Encode each value as JSON] B --> C[OpenTelemetry attributes] C --> D[Langfuse exporter] E[Experiment items] --> F[Item spans] F --> D E --> G[Run evaluators] G --> H[Sampling check] H --> I[Run scores] J[Existing observations] --> K[Cursor pages] K --> L[Choose duplicate rows] L --> M[Evaluators and scores] K --> N[Resume token] N --> KReviews (1) · Last reviewed commit: "fix(tracing): JSON-encode metadata attri..." · Reviewed by Greptile