Skip to content

feat(tracing)!: send observation metadata as one JSON attribute and cap it at 128 keys - #994

Open
niklassemmler wants to merge 8 commits into
mainfrom
feat/single-observation-metadata-attribute
Open

niklassemmler wants to merge 8 commits into
mainfrom
feat/single-observation-metadata-attribute

Conversation

@niklassemmler

@niklassemmler niklassemmler commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Linear: LFE-17151

Summary

Observation metadata is now written as one langfuse.observation.metadata JSON attribute instead of one langfuse.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 (extractMetadata in OtelIngestionProcessor, route B), so no server change is needed.

  • @langfuse/core: new MAX_OBSERVATION_METADATA_KEYS = 128 and serializeObservationMetadata(). 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, …>), so observation.update() and updateActiveObservation() 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: generationMetadata over 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/langchain and the @langfuse/client experiment runner: metadata over the key limit is dropped with a warning, so the span and the experiment item's output are kept.

⚠️ Breaking changes

  1. Wire format. Observation metadata is sent as a single langfuse.observation.metadata JSON attribute. Nothing writes langfuse.observation.metadata.<key> anymore. Custom span processors or exporters that read the per-key attributes must parse the blob instead.
  2. Stored shape of nested metadata. The server deep-flattens the blob, so { database: { host: "localhost" } } is now stored as key database.host instead of key database holding a JSON string. Saved filters and dashboard widgets on the top-level key (database) won't match new data, so filter on database.host instead. Arrays are still stored as one JSON-string value.
  3. Limit of 128 metadata keys. More than 128 top-level keys on one observation (counted after merging updates, ignoring null, undefined, function and symbol values) now throws from startObservation, startActiveObservation, observe, observation.update() and updateActiveObservation(). 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 (both generationMetadata and response metadata), @langfuse/langchain, and the experiment runner in @langfuse/client.
  4. The mask function now applies to metadata, one top-level value at a time. LangfuseSpanProcessor's mask used 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.
  5. Length limits now apply to the whole metadata object. With OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT set, 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).
  6. Read shape of nested metadata. Because storage is flat, the REST API and the UI return nested metadata as dotted keys, {"database.host": "localhost", "database.port": 5432}. Before, it came back re-nested as {"database": {"host": "localhost", "port": 5432}}. API consumers reading metadata.database.host need to read metadata["database.host"].
  7. Key collisions are stored twice. { "a.b": "dotted", "a": { "b": "nested" } } now stores a.b twice, and reads return only the first value ("dotted"). Before, the nested value was kept under a as a JSON string. This matches what raw OTLP clients already get.

Not changed: trace metadata from propagateAttributes stays 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

  • When the 128-key check throws in startObservation, the already-started OTel span is ended with ERROR status before the error is rethrown, the same as startActiveObservation. So it is exported without metadata instead of leaking.
  • The merge state is a WeakMap stored on globalThis under Symbol.for("langfuse.tracing.spanMetadata"), so the ESM and CJS builds of @langfuse/tracing share 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.
  • langfuse-python should make the same change in its next major. Otherwise JS and Python keep storing nested metadata differently (issue [5] in the primer).

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:

  • JS v5→v6 upgrade guide (content/docs/observability/sdk/upgrade-path/): breaking changes 1–7, plus saved filters, dashboards, evaluator mappings and pricing-tier conditions on old nested keys.
  • OpenTelemetry attribute table and callouts (content/integrations/native/opentelemetry/index.mdx ~399–479, migration-to-v4.mdx 60/64/119): document the single langfuse.observation.metadata JSON form and that nested keys become filterable as dotted keys.
  • Masking (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.
  • Metadata feature page (features/metadata.mdx): merge-on-update, the 128-key limit, dotted nested keys, the length-limit caveat.
  • Vercel AI SDK integration doc and packages/vercel-ai-sdk/README.md: nested runtime context becomes dotted keys; more than 128 keys are dropped with a warning.
  • Check whether pricing-tier "top-level metadata key" matching (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 new tests/integration/observation-metadata.integration.test.ts, plus masking tests for whole-value replacement, nested structure and propagated trace metadata)
  • Ingestion experiment (ingestion-experiment, run 20261007-152959-754e, local Langfuse in events_only / direct mode, ClickHouse checks on), compared with baseline run 20260926-133832-9d29 (SDK 5.11.1):
    • JS primer case: stored environment=prod, retries=3, database.host=localhost, database.port=5432. Before, it was database='{"host":"localhost","port":5432}'. Route A verdicts for JS now match route B (raw OTLP).
    • Edge case: numbers, booleans, unicode, padded keys and the 300-char value are stored as before. Arrays stay one JSON-string value. a.b collision stored twice (breaking change 7).
    • 200-key case: the SDK throws Observation metadata has 200 keys, which exceeds the maximum of 128. Before, OTel silently kept 123 of the 200 keys.
    • Non-dict metadata (string/number/list), propagated trace metadata, and all Python SDK and raw OTLP control cases: unchanged from the baseline.
  • 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

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previous metadata-copy issue is fixed and no new actionable issues were found.

What we checked:

  • Large metadata blocks OpenAI: wrapMethod counts metadata keys before starting the observation. If there are too many, it drops the metadata rather than stopping the model call.
  • One masked value breaks metadata: applyMaskToMetadata catches 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.

  • Updates merge metadata by top-level key.
  • OpenAI, LangChain, and experiment runs keep working when metadata exceeds the limit.
  • Metadata masking preserves keys and safely replaces values that cannot be serialized.
  • The previous finding about later caller changes affecting saved metadata is fixed.

Reviews (3) · Last reviewed commit: "fix: keep LangChain spans over the metad..." · Reviewed by Greptile

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>
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
langfuse-js Ready Ready Preview Oct 8, 2026 7:15am UTC

Request Review

@niklassemmler
niklassemmler marked this pull request as ready for review October 7, 2026 13:04
@github-actions

github-actions Bot commented Oct 7, 2026

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.

@niklassemmler niklassemmler changed the title feat(tracing)!: write observation metadata as a single JSON attribute feat(tracing)!: send observation metadata as one JSON attribute and cap it at 128 keys Oct 7, 2026
@niklassemmler
niklassemmler marked this pull request as draft October 7, 2026 13:07
Comment thread packages/tracing/src/attributes.ts Outdated

@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.

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).

Comment thread packages/core/src/utils.ts Outdated
Comment thread packages/tracing/src/attributes.ts Outdated
Comment thread packages/tracing/src/attributes.ts Outdated
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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@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.

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 data array) plus the run-level metadata option 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, ...}}).

Comment thread packages/otel/src/span-processor.ts
Comment thread packages/otel/src/span-processor.ts Outdated
Comment thread packages/tracing/src/attributes.ts Outdated
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@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.

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.

Comment thread packages/vercel-ai-sdk/src/utils.ts Outdated
Comment thread packages/core/src/utils.ts Outdated
…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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@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.

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.

Comment thread packages/otel/src/span-processor.ts Outdated
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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@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.

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.

Comment thread packages/otel/src/span-processor.ts
Comment thread packages/tracing/src/attributes.ts Outdated
…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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

@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.

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.

@niklassemmler
niklassemmler marked this pull request as ready for review October 7, 2026 17:16

@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.

Code review found no issues

No high-confidence issues detected in this change.

…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>
@niklassemmler

Copy link
Copy Markdown
Contributor Author

@claude review

🤖 Written by Claude (an AI agent) on behalf of Niklas

This branch was successfully deployed

1 active deployment
Preview — ede5392d Deployed Oct 8, 2026 by vercel[bot]
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.

1 participant