Repository navigation
feat(tracing)!: send observation metadata as one JSON attribute and cap it at 128 keys - #994
niklassemmler wants to merge 8 commits into
Conversation
Observation metadata is now written as one `langfuse.observation.metadata` JSON attribute instead of one `langfuse.observation.metadata.<key>` attribute per key. The SDK merges metadata across updates on the same span and throws when merged metadata has more than 128 top-level keys. BREAKING CHANGE: observation metadata is sent as a single JSON attribute. Nested objects are now deep-flattened by the server into dotted keys (e.g. `database.host`), metadata is passed through the span processor mask function, and more than 128 top-level metadata keys throws an error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@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.
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
packages/openai/src/traceMethod.ts— Successful OpenAI calls wrapped by Langfuse can now throw instead of returning the completion, when combined observation metadata exceeds 128 keys. generation.update({...}) at traceMethod.ts:122-129 calls the new setObservationAttributes, which throws once the merged metadata exceeds MAX_OBSERVATION_METADATA_KEYS. That throw inside the .then() handler is caught by the chained .catch() at line 134, which rethrows, so the caller gets a rejected promise and loses the LLM response. Before this diff, metadata flattening never threw. Fix: callers of update()/setObservationAttributes that run in success-path hooks must catch the metadata-cap error and drop/log metadata instead of letting it fail the call, matching what @ langfuse/vercel-ai-sdk now does.Why this was flagged
Trigger: a generation created with metadata close to the 128-key cap (startObservation's finalMetadata, traceMethod.ts ~line 90) completes a real OpenAI API call; parseModelDataFromResponse (packages/openai/src/parseOpenAI.ts:285) adds up to 8 more top-level keys (reasoning, incomplete_details, instructions, previous_response_id, tools, metadata, status, error), pushing the merged total over 128. generation.update(...) at traceMethod.ts:122 calls setObservationAttributes (packages/tracing/src/attributes.ts), which now throws Error('Observation metadata has N keys...'). This throw happens inside the .then() callback (line 109), so it is caught by the adjacent .catch() (line 134) rather than by the outer try/catch, which marks the generation ERROR and rethrows the error to the application. The same unguarded generation.update(...) call also appears in wrapAsyncIterable (line ~226) for streaming Response API chunks, aborting the stream the same way. Nothing in traceMethod.ts isolates this new throw path; the base branch's flattening logic never threw for metadata.
Verification: serializeObservationMetadata throws when merged metadata keys exceed 128 (packages/core/src/utils.ts:166-170), reached from traceMethod.ts:122-130's generation.update() in the .then() handler; the .catch() at traceMethod.ts:134 rethrows, rejecting the promise instead of returning the completion. Base never threw; the OpenAI wrapper lacks vercel-ai-sdk's guard.
-
🔴
packages/tracing/src/index.ts— startObservation() now leaks a started-but-never-ended OTel span whenever observation metadata exceeds 128 top-level keys. createOtelSpan() at index.ts:378 starts the span, then new LangfuseSpan/LangfuseGeneration/etc. (lines 385-446) call setObservationAttributes -> serializeObservationMetadata, which throws past 128 keys. The constructor call throws before returning, so startObservation's caller never gets the span reference to end it, and the already-started span is never exported. startActiveObservation (same file, ~line 795-888) avoids this because it wraps construction in try/catch and calls span.end() on error; startObservation has no equivalent. Fix: wrap the switch in startObservation in try/catch and end the otelSpan before rethrowing, matching startActiveObservation's behavior.Why this was flagged
Caller invokes startObservation('name', { metadata: {...129+ keys...} }) (index.ts:361-447), e.g. via observe()/direct API with large dynamic metadata. createOtelSpan (index.ts:378) calls tracer.startSpan, actually starting the span. The LangfuseSpan/LangfuseGeneration constructor (spanWrapper.ts) calls setObservationAttributes -> serializeObservationMetadata (packages/core/src/utils.ts), which throws for >128 keys. The exception propagates out of startObservation with no span reference returned, so the already-started span is never ended or exported - a resource/trace leak. Before this diff metadata serialization never threw, so no span was ever lost this way; startActiveObservation's try/catch (index.ts:877-888) does end the span on this same error, showing the gap is specific to the non-active path.
Verification: normal; acknowledged in diff: the PR note ("the underlying OTel span has already started and is never ended ... it's never exported, just dropped") understates the consequence — the span is not merely dropped, it leaves persistent state in LangfuseSpanProcessor.
Mechanism (all reachable):
- startObservation (index.ts:378) calls createOtelSpan, which calls getLangfuseTracer().startSpan(...) (index.ts:134).
The mask function now runs on each top-level observation metadata value instead of on the whole metadata JSON, so masked metadata always stays a valid JSON object with its keys. Per-key metadata attributes, such as propagated trace metadata, are now masked too. BREAKING CHANGE: mask functions are called once per top-level metadata value, and propagated trace metadata is now masked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review |
- Snapshot merged metadata per span instead of keeping the caller's object, so later mutations don't leak into subsequent updates - Share the per-span metadata registry via globalThis so ESM and CJS builds merge into the same state - End the OTel span when startObservation throws on the metadata key limit - In @langfuse/openai, drop metadata with a warning instead of rejecting a successful OpenAI call when response metadata exceeds the key limit Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
packages/client/src/experiment/ExperimentManager.ts— Bulk experiment runs can now silently lose an item's whole result (not just metadata) when merged metadata passes 128 keys, a cap that did not exist before this change. ExperimentManager.ts:456-471 calls span.update({input, output, metadata: {...itemMetadata, ...experimentMetadata, ...}}) after the task already ran; serializeObservationMetadata throws before createObservationAttributes returns, so setObservationAttributes never calls span.setAttributes at all, dropping input and output along with metadata. …Why this was flagged
…The throw propagates out of startActiveObservation and is caught at ExperimentManager.ts:227-232, which just logs and drops the item from itemResultsByIndex, discarding the already-computed output; any datasetRunItems record created around line 397 ends up pointing at a trace with no output. Fix: keep input/output set even when metadata serialization fails, and bound or truncate merged per-item+run metadata before it reaches the 128-key cap.
Trigger: an experiment item's metadata (item.metadata, generic user data from experiment.run's
dataarray) plus the run-levelmetadataoption together exceed MAX_OBSERVATION_METADATA_KEYS=128 top-level keys after ExperimentManager.ts:459-470 merges them with fixed keys like experiment_name.Verification: normal. Regression introduced by this change. In the experiment runner, after the task has already produced output (ExperimentManager.ts:441-454), ExperimentManager.ts:456-471 calls span.update({input, output, metadata: {...itemMetadata, ...experimentMetadata, experiment_name, ...}}).
|
@greptileai review |
… fail - @langfuse/otel: serialize masked metadata one value at a time, so a mask that returns a non-serializable value fully masks that value instead of dropping the whole span - @langfuse/client: set experiment item input/output before metadata and drop metadata with a warning when it exceeds the key limit, instead of losing the item's result Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
packages/openai/src/traceMethod.ts— Wrapping an OpenAI client with generationMetadata that has more than 128 top-level keys now makes every traced call throw instead of calling OpenAI at all, since startObservation(...) at traceMethod.ts:86-99 runs before the try block starting at line 101. On the base branch this metadata was flattened per-key so the limit never applied here; now serializeObservationMetadata's 128-key check (packages/core/src/utils.ts) throws synchronously from startObservation, before tracedMethod(...args) ever runs, so the real API call never happens and the user gets an exception instead of a completion. This is unprotected unlike the later generation.update() calls, which updateGeneration (traceMethod.ts:181-192) already retries without metadata. Fix: wrap the initial startObservation call (or validate/trim config.generationMetadata) so a too-large user-supplied generationMetadata still lets tracedMethod execute and the response return, instead of blocking the call outright.Why this was flagged
Trigger: a caller passes observeOpenAI(client, { generationMetadata }) where generationMetadata has >128 top-level keys (a supported, documented option at packages/openai/src/types.ts:27 and packages/openai/src/observeOpenAI.ts:83). wrapMethod builds finalMetadata = {...config.generationMetadata, response_format} (traceMethod.ts:78-84) and calls startObservation(...) at traceMethod.ts:86-99, which throws via serializeObservationMetadata's MAX_OBSERVATION_METADATA_KEYS check. That call sits before the try block at line 101, so tracedMethod(...args) at line 102 is never reached: the underlying OpenAI API request is never sent. No safeguard here catches it; updateGeneration (lines 181-192) only protects the later generation.update calls after a response, not this one. User gets a thrown error instead of the OpenAI response, for every call through this wrapper, until generationMetadata is reduced below 128 keys.
Verification: traceMethod.ts:86-99 calls startObservation with metadata from config.generationMetadata before the try block at line 101 wrapping the real OpenAI call tracedMethod(...args). startObservation's constructor chain calls serializeObservationMetadata, which throws (utils.ts:166-170) when top-level keys exceed MAX_OBSERVATION_METADATA_KEYS=128, so tracedMethod never executes. On base, metadata was flattened per-key, so OTel's limit silently dropped extras and the call still succeeded.
…ting - @langfuse/tracing: when a span has no tracked metadata, merge with the langfuse.observation.metadata attribute already on the span (e.g. written by @langfuse/vercel-ai-sdk) instead of overwriting it - @langfuse/core: don't count function- or symbol-valued keys towards the 128-key limit, since they are never written - @langfuse/openai: drop generationMetadata over the key limit with a warning instead of throwing before the OpenAI call is made Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
There was a problem hiding this comment.
Beyond the inline finding, I also checked whether applyMaskToMetadata's lack of a key-count cap could let a mask function be invoked an unbounded number of times per span export — but every SDK write path for langfuse.observation.metadata (core createObservationAttributes, the vercel-ai-sdk integration, and the openai integration) already routes through serializeObservationMetadata, which enforces the 128-key cap before the attribute is ever written, so this isn't reachable through the SDK's own APIs.
Extended reasoning...
Reviewed the latest commits (d3f9898, 48581f1) addressing prior findings: the BigInt/circular-mask crash is now fixed with a per-value try/catch in applyMaskToMetadata, and metadata snapshotting/globalThis sharing were added in attributes.ts. One new confirmed issue remains (function/symbol/undefined mask results silently drop the metadata key instead of keeping it), which is posted as an inline comment. I additionally traced all three attribute-writing call sites to confirm the 128-key cap is enforced before the masking code path runs, ruling out a separate concern about unbounded concurrent mask() invocations.
This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.
Still open from earlier reviews (5):
- 🔴
packages/core/src/utils.ts:214—Consolidating metadata into one JSON attribute means any OTEL attribute-value length truncation now drops the entire ob… - 🔴
packages/otel/src/span-processor.ts:619—Metadata masking, now live for the first time via applyMaskToMetadata, runs before MediaService.process() (span-process… - 🔴
packages/vercel-ai-sdk/src/utils.ts:45—Vercel AI SDK users who call updateActiveObservation/observation.update() inside a tool or model-call handler now lose… - Also unresolved: 2 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
A mask returning undefined, a function or a symbol for a metadata value dropped that key. Keep it with the fully-masked placeholder instead, so masked metadata always has the same keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
There was a problem hiding this comment.
This pull request has been reviewed before and this review found new issues. Where they share a root cause, one fix may close them together.
Still open from earlier reviews (4):
- 🔴
packages/core/src/utils.ts:214—Consolidating metadata into one JSON attribute means any OTEL attribute-value length truncation now drops the entire ob… - 🔴
packages/otel/src/span-processor.ts:619—Metadata masking, now live for the first time via applyMaskToMetadata, runs before MediaService.process() (span-process… - Also unresolved: 1 minor or pre-existing, 1 blocking on lines changed since (possibly already fixed).
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
packages/langchain/src/CallbackHandler.ts— LangChain traces now silently drop the entire span (not just metadata) whenever a run's merged tags+metadata exceed 128 keys, instead of just losing the excess attributes as before. joinTagsAndMetaData (CallbackHandler.ts:798) can produce >128 keys from user-supplied LangChain run metadata; gen/tool/span.update() at lines 812/825/837 then throws via setObservationAttributes (packages/tracing/src/attributes.ts:186-189). Because these calls run inside startActiveObservation with endOnExit:false (lines 818/828/840), its catch skips span.end() and rethrows; handleLLMStart's try/catch only logs at debug level, so this.runMap.set(runId, observation) (line 787) never runs. Fix: in CallbackHandler.ts, make update() calls (812, 825, 837, 874) resilient to the metadata-size throw (e.g. retry without metadata, as traceMethod.ts's updateGeneration does), so the span is always registered and ended.Why this was flagged
Trigger: a LangChain run (LLM/tool/chain) whose tags plus user-supplied metadata merge to more than 128 top-level keys via joinTagsAndMetaData at CallbackHandler.ts:798, reached through handleLLMStart/handleToolStart/handleChainStart -> startAndRegisterOtelSpan. gen.update()/tool.update()/span.update() (CallbackHandler.ts:812/825/837) now throw from setObservationAttributes (packages/tracing/src/attributes.ts:167-189), a behavior that did not exist before this PR (base silently truncated via OTel's attribute limit). startActiveObservation is called with endOnExit:false (818/828/840), so its catch skips span.end() and rethrows. handleLLMStart's own try/catch only does this.logger.debug(...), so the error is swallowed and this.runMap.set(runId, observation) at line 787 is never reached. The span is leaked unended/unexported, and nested child spans resolve parentSpanContext to undefined, so the whole trace subtree is dropped or detached, not merely missing metadata keys.
Verification: serializeObservationMetadata throws on >128 metadata keys (core/utils.ts:191-195), reached via setObservationAttributes (attributes.ts:186) from CallbackHandler.ts:812/825/837 inside startActiveObservation (endOnExit:false). Its catch (index.ts:889-900) skips span.end() and rethrows before runMap.set at CallbackHandler.ts:845, so the span is never registered or exported. Base's _flattenAndSerializeMetadata never threw, only truncated excess keys.
…tion updates - @langfuse/langchain: drop run metadata over the 128-key limit with a warning, so the span is still started, registered and ended - @langfuse/tracing: metadata updates with function or symbol values leave earlier values untouched, like null and undefined Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 4 findings from earlier reviews are still open above.
Still open from earlier reviews (4):
- 🔴
packages/core/src/utils.ts:222—Consolidating metadata into one JSON attribute means any OTEL attribute-value length truncation now drops the entire ob… - 🔴
packages/otel/src/span-processor.ts:619—Metadata masking, now live for the first time via applyMaskToMetadata, runs before MediaService.process() (span-process… - 🔴
packages/otel/src/span-processor.ts:657—A typical text-replacement mask function can now silently rename nested metadata keys, not just redact nested values, c… - Also unresolved: 1 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
…r the limit Cache the serialized `"key":value` fragment of each metadata key per span instead of a parsed copy of the merged metadata. An update now serializes only its own keys and joins the cached fragments, instead of serializing the whole merged metadata and parsing it back. Fragments are strings, so caller mutations still don't leak into later updates. Metadata over 128 top-level keys no longer throws from the tracing APIs. New keys beyond the limit are dropped with a warning, and earlier keys and overwrites of them are kept. This removes the guards that OpenAI, LangChain, the experiment runner, the Vercel AI SDK integration and startObservation needed around the throw. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@claude review 🤖 Written by Claude (an AI agent) on behalf of Niklas |
Linear: LFE-17151
Summary
Observation metadata is now written as one
langfuse.observation.metadataJSON attribute instead of onelangfuse.observation.metadata.<key>attribute per key. This matches the shape the REST API returns and removes the per-key string serialization on the SDK side. Context: the appendix of the metadata workshop primer. The server already parses the bare attribute (extractMetadatainOtelIngestionProcessor, route B), so no server change is needed.@langfuse/core: newMAX_OBSERVATION_METADATA_KEYS = 128andserializeObservationMetadata(). Each value is serialized on its own, so one value that can't be serialized (e.g. a circular reference) becomes"<failed to serialize>"instead of losing all metadata.@langfuse/tracing: merged metadata is kept per span (WeakMap<Span, …>), soobservation.update()andupdateActiveObservation()still merge by top-level key and write the full merged blob each time.@langfuse/vercel-ai-sdk: writes the same single attribute.@langfuse/openai:generationMetadataover the key limit is dropped with a warning before the call, and if response metadata pushes a generation over the limit, the update is retried without metadata. The OpenAI call always goes through and returns its response.@langfuse/langchainand the@langfuse/clientexperiment runner: metadata over the key limit is dropped with a warning, so the span and the experiment item's output are kept.langfuse.observation.metadataJSON attribute. Nothing writeslangfuse.observation.metadata.<key>anymore. Custom span processors or exporters that read the per-key attributes must parse the blob instead.{ database: { host: "localhost" } }is now stored as keydatabase.hostinstead of keydatabaseholding a JSON string. Saved filters and dashboard widgets on the top-level key (database) won't match new data, so filter ondatabase.hostinstead. Arrays are still stored as one JSON-string value.null,undefined, function and symbol values) now throws fromstartObservation,startActiveObservation,observe,observation.update()andupdateActiveObservation(). Before, OpenTelemetry's 128-attribute span limit dropped the extra keys silently. When it throws, the span's earlier metadata is left as it was. The integrations drop the metadata with a warning instead of throwing, because throwing there would break the user's model call or lose results:@langfuse/vercel-ai-sdk,@langfuse/openai(bothgenerationMetadataand response metadata),@langfuse/langchain, and the experiment runner in@langfuse/client.LangfuseSpanProcessor'smaskused to match only exact keys, so per-key metadata was never masked. Now it is called once per top-level observation metadata value, and once per propagated trace metadata attribute (langfuse.trace.metadata.<key>). Keys are never passed to the mask, so masked metadata always stays a valid JSON object with the same keys:mask: () => "REDACTED"turns every value into"REDACTED"instead of breaking the metadata. Non-string values are passed as JSON strings and parsed back if the masked result is still valid JSON, so nested objects keep their structure. This is a fix (the docs already say metadata is masked), but output changes for users with a mask function, and the mask is called more often.OTEL_ATTRIBUTE_VALUE_LENGTH_LIMITset, the limit now cuts the whole metadata JSON. The cut JSON fails to parse on the server, so all metadata on that span is dropped (before, only the one long value was cut).{"database.host": "localhost", "database.port": 5432}. Before, it came back re-nested as{"database": {"host": "localhost", "port": 5432}}. API consumers readingmetadata.database.hostneed to readmetadata["database.host"].{ "a.b": "dotted", "a": { "b": "nested" } }now storesa.btwice, and reads return only the first value ("dotted"). Before, the nested value was kept underaas a JSON string. This matches what raw OTLP clients already get.Not changed: trace metadata from
propagateAttributesstays per-key (langfuse.trace.metadata.<key>, string values ≤200 chars). On the server, per-key attributes win over the blob on key collisions.Notes for reviewers
startObservation, the already-started OTel span is ended with ERROR status before the error is rethrown, the same asstartActiveObservation. So it is exported without metadata instead of leaking.WeakMapstored onglobalThisunderSymbol.for("langfuse.tracing.spanMetadata"), so the ESM and CJS builds of@langfuse/tracingshare it when both are loaded in one process. It holds a parsed copy of the written metadata, not the caller's object, so later changes to that object don't leak into updates.Docs follow-ups (langfuse-docs)
None of the current docs become wrong for older SDKs, since the server accepts both forms, but these need updating for v6:
content/docs/observability/sdk/upgrade-path/): breaking changes 1–7, plus saved filters, dashboards, evaluator mappings and pricing-tier conditions on old nested keys.content/integrations/native/opentelemetry/index.mdx~399–479,migration-to-v4.mdx60/64/119): document the singlelangfuse.observation.metadataJSON form and that nested keys become filterable as dotted keys.features/masking.mdx:231–233,sdk/advanced-features.mdx:216): explain that metadata is masked one top-level value at a time, keys are kept, and propagated trace metadata is masked too.features/metadata.mdx): merge-on-update, the 128-key limit, dotted nested keys, the length-limit caveat.packages/vercel-ai-sdk/README.md: nested runtime context becomes dotted keys; more than 128 keys are dropped with a warning.token-and-cost-tracking.mdx:174–176) accepts dotted keys.Impacted packages
@langfuse/core,@langfuse/tracing,@langfuse/vercel-ai-sdk(indirectly, via mask behavior:@langfuse/otel)Verification
pnpm build✅pnpm lint✅pnpm typecheck✅pnpm format:check✅pnpm vitest run tests/integration tests/unit: 455/455 ✅ (includes the newtests/integration/observation-metadata.integration.test.ts, plus masking tests for whole-value replacement, nested structure and propagated trace metadata)20261007-152959-754e, local Langfuse inevents_only/directmode, ClickHouse checks on), compared with baseline run20260926-133832-9d29(SDK 5.11.1):environment=prod,retries=3,database.host=localhost,database.port=5432. Before, it wasdatabase='{"host":"localhost","port":5432}'. Route A verdicts for JS now match route B (raw OTLP).a.bcollision stored twice (breaking change 7).Observation metadata has 200 keys, which exceeds the maximum of 128.Before, OTel silently kept 123 of the 200 keys.pnpm test:e2e: not run. No Langfuse server or credentials were available (LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set). Worth running against a server to confirm nested metadata comes back deep-flattened.🤖 Written by Claude (an AI agent) on behalf of Niklas
🤖 Generated with Claude Code
The PR appears safe to merge; the previous metadata-copy issue is fixed and no new actionable issues were found.
What we checked:
wrapMethodcounts metadata keys before starting the observation. If there are too many, it drops the metadata rather than stopping the model call.applyMaskToMetadatacatches serialization errors for each value. It writes a replacement string and keeps the remaining metadata.Summary
Observation metadata is written as one JSON attribute, with a limit of 128 top-level keys.
Reviews (3) · Last reviewed commit: "fix: keep LangChain spans over the metad..." · Reviewed by Greptile