Skip to content

fix(langfuse)!: source the emitted metadata blob from StandardLoggingPayload - #36744

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5492_langfuse_slp_payload
Aug 14, 2026
Merged

fix(langfuse)!: source the emitted metadata blob from StandardLoggingPayload#36744
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5492_langfuse_slp_payload

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

BREAKING CHANGE FOR LANGFUSE TRACE CONSUMERS

THIS PR CHANGES WHAT IS LOGGED TO LANGFUSE. READ BEFORE UPGRADING.

1. ROUGHLY 20 METADATA FIELDS NO LONGER APPEAR ON THE GENERATION. Measured live, 52 keys down to 38. Any saved Langfuse filter, dashboard or alert built on these will stop matching:
model_group, model_info, deployment, deployment_model_name, model_group_alias, model_group_size, litellm_api_version, litellm_received_at, litellm_parent_otel_span, queue_time_seconds, attempted_retries, max_retries, agent_id, caller_tags, inherited_tags, global_max_parallel_requests, user_api_key, and the remaining user_api_key_* budget and permission-id fields.
model_group and the deployment id are still recoverable from hidden_params.

2. DIRECT-SDK CALLERS LOSE FLAT CUSTOM METADATA. metadata={"my_key": "v"} no longer reaches the generation. Nest it instead, and it arrives under requester_metadata:
metadata={"metadata": {"my_key": "v"}}
Proxy callers are NOT affected; their request-body metadata already rides under requester_metadata, nesting intact.

3. debug_langfuse NOW EMITS CALLER SCALARS, NOT THE EXACT RAW METADATA. Dicts and lists are filtered out, because the raw dump was a second copy of the credential leak this PR fixes.

Docs corrected in BerriAI/litellm-docs#902.

TLDR

Problem this solves:

  • A team's own Langfuse keys arrive inside that team's own Langfuse traces
  • Request metadata carries the whole auth object, unfiltered
  • debug_langfuse ships the same credentials a second time

How it solves it:

  • Emitted blob is the StandardLoggingPayload allowlist plus enrichments
  • Nothing is copied across from raw metadata, so exclusion is structural
  • Steering keys keep reading raw metadata, so behavior is unchanged
  • Debug dump emits caller scalars, never the auth object

User Flow

Before: a team that configured its own Langfuse destination finds its Langfuse secret key sitting in the traces it can read

  1. An admin configures the team with Langfuse credentials, so its traffic is traced to the team's own Langfuse project
  2. A member of that team sends POST https://litellm-domain/v1/chat/completions with their team key, and gets a normal 200
  3. They open the trace in their Langfuse project and expand the generation's metadata
  4. Under user_api_key_auth.team_metadata.logging[0].callback_vars they can read langfuse_secret_key and langfuse_public_key in full
  5. Anyone who can read that Langfuse project, including members with no admin rights on the gateway, can now authenticate as the team's Langfuse destination

After: the same trace carries the request's identity and cost, and no credentials

  1. The admin configures the team the same way
  2. The member sends the same POST https://litellm-domain/v1/chat/completions and gets the same 200
  3. They open the trace and expand the generation's metadata
  4. user_api_key_auth is gone, along with user_api_key_team_metadata and user_api_key_metadata; key alias, team alias, spend and cost are still there, and anything they passed in metadata still shows up under requester_metadata
  5. Reading the Langfuse project no longer reveals the team's Langfuse credentials

Relevant issues

Linear ticket

Resolves LIT-5492

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxy with real Postgres, a real Gemini call, and the Langfuse destination pointed at a local sink that records the exact outbound bytes. A marker credential is planted in the team row's callback_vars, in the legacy plaintext shape that predates at-rest encryption.

Setup, once:

curl -X POST http://127.0.0.1:4483/team/new -H "Authorization: Bearer $MASTER" -H 'Content-Type: application/json' -d '{
 "team_alias": "canary",
 "metadata": {"logging": [{"callback_name":"langfuse","callback_type":"success",
   "callback_vars":{"langfuse_public_key":"pk-lf-SIBMARKER-PUBLIC-a1b2c3",
                    "langfuse_secret_key":"sk-lf-SIBMARKER-SECRET-d4e5f6",
                    "langfuse_host":"http://127.0.0.1:9483/langfuse"}}]}}'

BEFORE, at fdd72b5b23:

curl -s -X POST http://127.0.0.1:4483/v1/chat/completions -H "Authorization: Bearer $TEAMKEY" \
  -H 'Content-Type: application/json' \
  -d '{"model":"gemini-flash","messages":[{"role":"user","content":"say hi"}]}'
# then grep the captured POST /api/public/ingestion body
grep -aoc 'sk-lf-SIBMARKER-SECRET-d4e5f6' captures/*ingestion*.bin
1        # normal request
2        # same request with "metadata": {"debug_langfuse": true}

The captured body, verbatim:

"team_metadata": {"logging": [{"callback_name": "langfuse", "callback_type": "success",
"callback_vars": {"langfuse_host": "http://127.0.0.1:9483/langfuse",
"langfuse_public_key": "pk-lf-SIBMARKER-PUBLIC-a1b2c3",
"langfuse_secret_key": "sk-lf-SIBMARKER-SECRET-d4e5f6"}}]}

AFTER, at bd0f074a6a, same rig, same team row, four scenarios:

grep -aoc 'sk-lf-SIBMARKER-SECRET-d4e5f6' captures/*ingestion*.bin
0   # A  normal request, the reported scenario
0   # B  same request with "metadata": {"debug_langfuse": true}
0   # C  same request with "metadata": {"trace_name": "rca-check"}
1   # D  "metadata": {"existing_trace_id":"t-1","update_trace_keys":["user_api_key_auth"]}

Legs A, B and C on the wire:

A  generation: 38 keys | user_api_key_auth absent | user_api_key_team_metadata absent
   requester_metadata = {"my_tag": "t", "nested": {"a": 1}}      caller metadata intact, nesting preserved
B  debug dump: 20 scalar keys, auth object absent
C  trace name = "rca-check"                                       steering still reads raw metadata

Leg D is a pre-existing hole this PR does not close, disclosed rather than hidden. update_trace_keys
names a key that the existing-trace branch pops out of raw metadata straight onto trace_params, which
never passes through the allowlisted blob. The generation metadata is clean even on that leg; the auth
object lands as a top-level field on the trace object. The channel is byte-identical before and after
this commit. It belongs to LIT-5484, which owns the steering redesign.

Real Langfuse cloud, same team, same request

Both traces were produced against Langfuse cloud with the team's own credentials, stored encrypted
at rest, so the destination resolves correctly and the value that leaks is ciphertext rather than a
usable secret.

Before, the generation metadata carries 52 keys. The team's own Langfuse credentials are readable at
user_api_key_auth.team_metadata.logging[0].callback_vars, which is the path the report describes.

Before the fix

After, the same request emits 38 allowlisted fields. user_api_key_auth and the callback_vars it
carried are gone. user_api_key_auth_metadata reads as an empty object, which is the stripped copy
rather than the leaking field, and the caller's own metadata still arrives under requester_metadata.

After the fix

Emitted metadata delta, measured on the same live rig, base 52 keys to head 38:

dropped, credential carriers: user_api_key_auth, user_api_key_team_metadata, user_api_key_metadata

dropped, proxy and router internals with no allowlist entry: agent_id, attempted_retries, caller_tags, deployment, deployment_model_name, global_max_parallel_requests, inherited_tags, litellm_api_version, litellm_parent_otel_span, litellm_received_at, max_retries, model_group, model_group_alias, model_group_size, model_info, queue_time_seconds, user_api_end_user_max_budget, user_api_key, user_api_key_end_user_model_max_budget, user_api_key_model_max_budget, user_api_key_object_permission_id, user_api_key_team_object_permission_id

added: applied_guardrails, cold_storage_object_key, mcp_tool_call_metadata, prompt_management_metadata, requester_custom_headers, routing_decision, spend_logs_metadata, team_alias, team_id, usage_object, vector_store_request_metadata

model_group and model_id remain recoverable from hidden_params, and request tags still reach Langfuse as first-class trace tags rather than as a metadata key.

Note the Langfuse SDK reports the destination's own public key in its ingestion envelope under metadata.public_key. That is the SDK authenticating, not our payload, and it is unchanged by this PR.

Type

🐛 Bug Fix

Caveats (if any)

  • Proxy callers are unaffected: metadata rides under requester_metadata
  • Direct-SDK callers lose top-level custom keys from the generation blob
  • Langfuse docs promise arbitrary metadata passthrough; needs a docs update
  • _log_langfuse_v1 still ships raw metadata, unreachable on langfuse>=2
  • update_trace_keys can still name the auth object; owned by LIT-5484

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes what metadata reaches external Langfuse projects (security fix with behavior shifts for custom top-level metadata); core proxy logging path is widely used but the change is localized to Langfuse v2 assembly.

Overview
Fixes a credential leak where team Langfuse keys and other proxy auth objects were copied from raw request metadata into Langfuse generation/trace payloads.

Langfuse v2 logging now builds the emitted metadata blob from standard_logging_object["metadata"] (the StandardLoggingPayload allowlist) plus explicit enrichments (response cost, filtered hidden_params, api_base, regions, cache_hit). Raw request metadata is no longer merged into that blob, so fields like user_api_key_auth and team callback vars cannot ride along structurally.

Generation metadata is assembled as allowlisted fields (with redact_user_api_key_info) layered with enrichments; provider spans read hidden_params from enrichments instead of the old steering dict. debug_langfuse still attaches caller context to the trace, but only scalar keys from request metadata—never the full auth object. Steering controls (session_id, trace_name, deny-listed keys like headers/endpoint) still read raw metadata; shared cache_hit None→False normalization on kwargs is preserved.

Adds regression tests (canary credentials, debug dump, spoofing, missing standard_logging_object on failures).

Reviewed by Cursor Bugbot for commit 7189980. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR hardens Langfuse metadata emission by sourcing generation metadata from the normalized logging payload and retaining only explicit computed enrichments.

  • Separates raw steering metadata from the metadata sent to Langfuse.
  • Restricts debug metadata dumps to scalar values.
  • Adds regression coverage for credential exclusion, enrichment preservation, redaction, steering behavior, and missing normalized payloads.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/integrations/langfuse/langfuse.py Reworks Langfuse metadata assembly around normalized logging metadata and explicit enrichments without introducing a blocking failure.
tests/test_litellm/integrations/test_langfuse.py Adds focused mocked regression coverage for the revised metadata and debug-emission paths.

Reviews (4): Last reviewed commit: "fix(langfuse): source the emitted metada..." | Re-trigger Greptile

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +484 to +492
assembled: Final = {
**allowlisted_metadata,
**{
key: value
for key, value in steering_metadata.items()
if isinstance(value, str) and key not in allowlisted_metadata
},
**enrichments,
}

@devin-ai-integration devin-ai-integration Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Custom labels sent with a request stop appearing in Langfuse traces

Custom values a caller attaches to a request are dropped from the emitted trace details ({**allowlisted_metadata, **enrichments} at litellm/integrations/langfuse/langfuse.py:765) instead of being copied back in, so anything the caller labelled the request with disappears from Langfuse.

Impact: Users who tag requests with their own fields lose those fields in Langfuse, breaking existing dashboards, filters and searches built on them.

The promised re-injection of caller string values is missing from the assembled blob

The PR description states "Caller strings are re-injected, so custom trace metadata survives", and the test docstring at tests/test_litellm/integrations/test_langfuse.py:636-641 reasons about a "string re-injection" that the deny-list must guard. No such re-injection exists in the code: generation_params["metadata"] is built purely from standard_logging_object["metadata"] merged with the litellm-computed enrichments (litellm/integrations/langfuse/langfuse.py:764-766). clean_metadata, which is the only thing derived from the raw request metadata, is now used exclusively for steering (session_id, trace_*, generation_name, prompt, version) and never contributes to the emitted blob.

Consequently _DENIED_STEERING_KEYS (litellm/integrations/langfuse/langfuse.py:51) has no effect on what is emitted at all, contradicting the stated rationale.

For SDK callers (litellm.completion(..., metadata={"my_key": "v"})) the loss is total: StandardLoggingPayloadSetup only populates requester_metadata from a nested metadata["metadata"] key (see the _potential_requester_metadata block in litellm/litellm_core_utils/litellm_logging.py), so flat caller keys have no allowlist entry and vanish. Proxy callers retain their values only under the nested requester_metadata key, which is a different shape than before. tests/test_litellm/integrations/test_langfuse.py:600-611 asserts this drop, so the tests lock in the behavior the description says was avoided.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread litellm/integrations/langfuse/langfuse.py Outdated
Comment on lines +523 to +525
# StandardLoggingPayload is fail-open, so when it is absent the emitted
# blob is the caller strings and litellm enrichments only. Falling back
# to raw metadata here would put the UserAPIKeyAuth object on the wire

@devin-ai-integration devin-ai-integration Bot Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Explanatory comments added to the test file conflict with the repository's no-comments rule

Multi-line explanatory prose comments were added to the test fixture (# only real StandardLoggingMetadata fields: ... at tests/test_litellm/integrations/test_langfuse.py:385-387), which the repository guidelines explicitly disallow.

Impact: The change does not follow the project's documented style requirements and will need to be reworked before merge.

CLAUDE.md forbids comments outside of a narrow allowlist

CLAUDE.md states comments are only allowed when "absolutely necessary to explain some very complex business logic", when used as tool input (lint/type suppressions with a named rule), or for TODO/FIXME. The added comments are narrative explanations, not any of those categories. A second instance is at tests/test_litellm/integrations/test_langfuse.py:751-752 (# hidden_params comes off the payload, so it is omitted rather than emitted as an unserializable placeholder). Both should be removed or folded into the surrounding test docstrings.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread litellm/integrations/langfuse/langfuse.py Outdated
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5492_langfuse_slp_payload branch from 1a7f2a2 to 34df173 Compare August 13, 2026 07:32
cursor[bot]

This comment was marked as resolved.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5492_langfuse_slp_payload branch from 34df173 to 3c57c13 Compare August 13, 2026 07:32
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review latest head

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 3c57c13. Configure here.

@codspeed-hq

codspeed-hq Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5492_langfuse_slp_payload (7189980) with litellm_internal_staging (6704a10)

Open in CodSpeed

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5492_langfuse_slp_payload branch 3 times, most recently from ba01e71 to bd0f074 Compare August 13, 2026 21:34
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai review latest head

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit bd0f074. Configure here.

@yucheng-berri
yucheng-berri force-pushed the litellm_lit5492_langfuse_slp_payload branch 2 times, most recently from 58298ad to 5afda54 Compare August 14, 2026 06:41
…ayload

Request metadata carries the whole UserAPIKeyAuth object, whose team_metadata
holds the customer's own langfuse callback_vars. The only filter on the emitted
blob was a four key deny list written as a circular reference crash guard, so
those credentials reached the customer's own langfuse traces.

The emitted blob is now the StandardLoggingPayload allowlist plus the litellm
computed enrichments, and nothing is copied across from raw request metadata.
That makes the credential exclusion structural rather than a filter someone has
to keep correct. Steering keys keep reading raw metadata, matching literal_ai.

Proxy callers are unaffected: their request metadata already rides under the
allowlisted requester_metadata key, nesting intact.

debug_langfuse dumped raw request metadata into the trace as a second copy of
the same leak. It now emits caller scalars only.

When StandardLoggingPayload is absent the trace is still emitted with the
existing trace_id fallback, so failure traces survive.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit5492_langfuse_slp_payload branch from 5afda54 to 7189980 Compare August 14, 2026 06:46
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 7189980

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +633 to +639
debug_metadata: Final = {
key: value for key, value in metadata.items() if isinstance(value, (str, int, float, bool))
}
trace_params["metadata"] = {
**(trace_params.get("metadata") or _NO_METADATA),
"metadata_passed_to_litellm": debug_metadata,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Debug trace dump still emits raw caller/proxy scalar metadata without user_api_key redaction

When debug_langfuse is enabled, the trace metadata dump is built directly from the raw request metadata, keeping every scalar value (debug_metadata at litellm/integrations/langfuse/langfuse.py:633-639). Raw proxy metadata contains scalar identity fields such as user_api_key (the hashed key), user_api_key_alias, team/org ids, and any scalar the caller injected. Unlike the emitted generation blob, this dump does not pass through redact_user_api_key_info, so the litellm_settings.redact_user_api_key_info flag has no effect on it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 7189980. Configure here.

@yucheng-berri
yucheng-berri enabled auto-merge (squash) August 14, 2026 07:00
@yucheng-berri
yucheng-berri merged commit 423b791 into litellm_internal_staging Aug 14, 2026
80 of 82 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit5492_langfuse_slp_payload branch August 14, 2026 07:01
@yucheng-berri yucheng-berri changed the title fix(langfuse): source the emitted metadata blob from StandardLoggingPayload fix(langfuse)!: source the emitted metadata blob from StandardLoggingPayload Aug 15, 2026
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.

2 participants