OTel Attribute Serialization#
OpenTelemetry protobuf spans carry attributes as key-value pairs where each value is a typed oneof field: stringValue, doubleValue, boolValue, arrayValue, intValue, or kvlistValue. Before any downstream logic runs, OtelIngestionProcessor converts these protobuf-typed values into plain JavaScript types.
The three attribute-extraction entry points — extractResourceAttributes, extractScopeAttributes, and extractSpanAttributes — all reduce over the attribute array and call convertValueToPlainJavascript(attr.value) on each entry, building a Record<string, unknown>. The resulting plain-JS map is then passed to all downstream extractors (input/output, metadata, model name, usage, etc.).
After conversion, several downstream steps may re-serialize values back to JSON strings — to fit ClickHouse Map(String, String) columns or preserve backward compatibility. Understanding where these re-serializations happen is key to diagnosing double-encoded or repr-string bugs.
convertValueToPlainJavascript — Core Protobuf Conversion#
convertValueToPlainJavascript maps each protobuf AnyValue shape to a JS primitive or structure:
| Protobuf field | JS result |
|---|---|
stringValue | string (returned as-is) |
doubleValue | number |
boolValue | boolean |
arrayValue.values | recursive array |
intValue (plain number) | number |
intValue (high === 0) | intValue.low (small 64-bit) |
intValue (high === -1, low === -1) | -1 |
intValue (high !== 0) | high * 2^32 + low (large 64-bit) |
| anything else | JSON.stringify(value) ← fallback |
The fallback JSON.stringify means unrecognized protobuf shapes are stringified. This preserves data but can produce double-encoded values if callers later try to JSON.parse the result. The Python SDK sends span IDs as integer arrays while the JS SDK sends hex strings; parseId() handles both formats separately .
JSON Attribute Parsing Helpers#
Several attributes are expected to carry JSON-encoded objects (not just primitive strings). Two helpers handle these:
parseJsonObjectAttribute — used for attributes that must be plain objects (e.g., langfuse.observation.usage_details, langfuse.observation.cost_details, langfuse.observation.metadata):
- Returns the parsed object on success
- Returns
{}if the attribute is present but not a string or parses to a non-object (logs a warning and incrementslangfuse.ingestion.otel.bad_json_attribute) - Returns
nullif the attribute is absent or contains invalid JSON — thenullreturn signals callers to try fallback extraction paths
parseJsonPayload — lighter-weight helper used by Genkit and Vercel AI SDK extractors:
- Returns the value unchanged if it is already an object
- Calls
JSON.parseif it is a string, returnsundefinedon failure
The distinction matters: parseJsonObjectAttribute returns {} (not null) for a present but malformed attribute, which causes usage/cost extractors to short-circuit their fallback chains . Feeding a non-object JSON value (e.g., a JSON array) to langfuse.observation.usage_details will silently zero out usage details.
filteredAttributes Stringify and IngestionService Merge#
After extracting known input/output keys, the remaining span attributes are placed in filteredAttributes and stored under metadata.attributes. Non-string values are explicitly JSON.stringify()'d at this point to match the old v3 behavior and satisfy ClickHouse Map(LowCardinality(String), String) column constraints.
The same conversion is available as a standalone utility: convertRecordValuesToString in IngestionService/utils.ts iterates a record and stringifies any non-string value.
IngestionService.stringify() is the pass-through used when writing input/output to the observations table: strings pass through as-is; everything else goes through JSON.stringify. This means whatever string the OTel pipeline produces is stored verbatim. If the Python SDK produced a single-quoted repr string, stringify() preserves it unchanged.
overwriteObject in utils.ts governs how two observation records are merged when multiple events target the same id:
- Non-overwritable fields (
id,project_id,start_time,created_at,environment) always retain the earlier value metadatais deep-merged via lodashmerge()tagsare unioned and sorted- For all other fields, the newer non-null/non-empty value wins
Known SDK Serialization Bugs#
Python LangChain: content_blocks → Python repr strings (single quotes)#
Issue: #15143 — When using SystemMessage(content_blocks=[...]) in LangChain, message.content is a Python list. The langfuse-python LangChain callback handler converts it with str() instead of json.dumps(), producing a Python repr string with single quotes as the OTel span attribute value.
Since stringify() on the server passes string values through unchanged , these single-quoted repr strings are stored verbatim in ClickHouse — making json.loads() fail and the Langfuse UI show unreadable content.
Confirmed: The server consistently uses JSON.stringify() ; the bug is upstream in the langfuse-python SDK's LangChain callback handler, where list/dict content values must use json.dumps() before being set as OTel span attributes. HumanMessage(content="...") is unaffected because str("plain string") is still a valid string.
Fix location: langfuse/langfuse-python — LangChain callback handler message serialization.
Python LangChain: ChatHuggingFace model name not parsed#
Issue: #14103 — ChatHuggingFace is not LangChain-serializable, so it arrives at the callback as a not_implemented stub with the model ID only present in the repr string (model_id='...'). The Python SDK's _extract_model_name had no pattern for this, returning None and triggering the "Langfuse was not able to parse the LLM model" warning.
Fix: langfuse-python PR #1728 adds a repr-pattern entry that reads model_id from the repr string, consistent with how HuggingFaceHub, Ollama, and similar non-serializable models are handled.
JS SDK LangChain: PromptTemplate objects not extracted as strings#
Issue: #1214 — PromptTemplate outputs are serialized as LangChain objects rather than plain strings; HumanMessagePromptTemplate does not produce the expected {role, content} shape. This affects the JS LangChain CallbackHandler, not the OTel ingestion pipeline. Open to contributions.