Repository navigation
feat(langfuse)!: migrate the sdk callback to langfuse v4 - #36741
Conversation
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Eviction flushes queued observations rather than dropping them, now pinned by a test. Exposure matches the pre-existing client shutdown. @greptileai please re-review 3eca824 |
|
This race predates the PR: base already calls flush then shutdown here. v4 only widens it; skipping provider shutdown leaks threads. @greptileai re-review 3eca824 |
|
@greptileai review latest head |
3eca824 to
4342aba
Compare
PR overviewThis pull request migrates the Langfuse SDK callback integration to Langfuse v4, including updated span batching and encoding behavior. Three security issues have been addressed, with one availability concern still open. An authenticated caller can submit oversized prompt or metadata attributes that are encoded without preemptive size limits, potentially causing excessive memory allocation and service disruption. Local per-span and per-batch limits are still needed before serialization. Open issues (1)
Fixed/addressed: 3 · PR risk: 6/10 |
|
All three fixed and verified on a live proxy with a real guardrail: float timings, missing cost_details, and prompt-management client isolation. @greptileai re-review bbad301 |
|
Race predates this PR; base flushed and shut down here too. Triggering it needs the one-hour TTL or 200 credential sets. Both nits fixed. @greptileai re-review 27d8d67 |
|
Verified end to end against a real Langfuse Cloud project on langfuse 4.7.0. Streaming start times are now the model call, not the callback. Other scenariosFull metadata surface, non-uuid trace_id hashing into one trace, Two things to consider. Devin session: https://app.devin.ai/sessions/85ed7dd169f0475992a5636b8c2b51b2 |
|
update? |
27d8d67 to
8c3f373
Compare
f953893 to
b623e05
Compare
|
@greptileai please review the current head b623e05 |
|
bugbot run |
|
Paired Langfuse Cloud UI captures, same six requests through the merge base 00 Combined trace list, both legsSearch 01 Plain chat with metadata (POST /v1/chat/completions, "Reply with exactly: UI pair ok")Trace list filtered to this cell's tag. Before: root row named Trace detail. Same trace name, user id, session id, tags, version, input and output on both. Before shows the root with a nested generation, After shows the generation at the root Generation view. Both: Metadata tab. Before lists the top-level After only: the nested Sessions page for this cell's 02 Guardrail masking (POST /v1/chat/completions with the mask-post guardrail)Trace list. Before: root, generation and guardrail as three rows. After: generation root with the guardrail nested under it Trace detail. Output is Generation view. Same model, tokens, cost and masked output on both Metadata tab. Same keys apart from the dropped top-level null, guardrail information present on both Guardrail observation. Same name, mode, keyword and MASK action on both; only its parent differs 03 Prompt management (alias langfuse/gpt-5.5 with prompt_id litaudit-lf5050-greeting)Trace list. Same row shape difference as the plain cell Trace detail. Compiled prompt messages with Generation view. Both carry the prompt link Metadata tab. Prompt name and version recorded on both 04 turn_off_message_logging (guardrail request with redaction on)Trace list. Same shape difference, rows present on both Trace detail. Input and output read Generation view. Redacted bodies, real model, tokens and cost on both Metadata tab. Same keys on both, no message content Guardrail observation. Pre-existing gap visible on both legs: the guardrail's own metadata still shows the matched keyword and MASK action under redaction. Unchanged by this PR 05 Provider 401 (alias with an invalid OpenAI key)Trace list. Both legs logged the failed request; After shows a token and cost badge on the row, Before does not Trace detail. Both trees show the generation as Generation view. Neither leg sent usage. Before shows no tokens or cost, After shows 60 tokens and $0.00135: Cloud tokenizes input and output itself on the v4 ingestion route when usage is absent, the same for any v4 SDK sender. Now in the Caveats Metadata tab. Same request metadata on both, no key material in either 06 Responses endpoint (POST /v1/responses, same prompt)Trace list. Same shape difference as the chat cells Trace detail. Generation view. Metadata tab. Same Not captured in this pair: |
…line its litellm 1.83.0 wheel expects The image validates the published PyPI artifact, whose langfuse callback still reads langfuse.version, so the 4.15.2 pin broke that callback. The pins move together with the next LITELLM_VERSION bump. Also rewords the trace_version precedence test docstring: v2 carried two version fields, v4 has one per span Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@greptileai review latest head |
|
bugbot run |
There was a problem hiding this comment.
✅ 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 fdbd084. Configure here.
|
@greptileai review latest head and rescore, the Eviction retires retained tracing finding is the two resolved threads above, measured there: base loses the event at once, head keeps it for 60 s. Pre-existing and disclosed |
TLDR
Problem this solves:
langfusecallback only works with Langfuse SDK v2, which Langfuse retiredlangfuse>=4installed every request fails withmodule 'langfuse' has no attribute 'version'trace(),generation(),span(),langfuse.versionandLangfuse.base_urlHow it solves it:
langfuse.*span attributes on the v4 OTLP routeLangfuse()clientlangfuse>=4.7,<5with opentelemetry 1.33.1 inpyproject.tomlanduv.lockUser Flow
Before: a proxy admin who installs the current Langfuse SDK gets a broken proxy, since Langfuse logging fails before the model is called
langfuse>=4and setssuccess_callback: ["langfuse"]inlitellm_settingsmodule 'langfuse' has no attribute 'version'{"model":"gpt-5.5","messages":[...]}and gets HTTP 400 withInvalid request format: module 'langfuse' has no attribute 'version'. No model reply, nothing billed, nothing in LangfuseAfter: the same install works, and traces land in Langfuse with the fields the v2 integration produced
langfuse>=4.7,<5) and setssuccess_callback: ["langfuse"]inlitellm_settings{"status":"success",...}{"model":"gpt-5.5","messages":[...]}and gets HTTP 200 with the model reply and usageRelevant issues
Fixes #24123. Relates to #33383 and Pylon #6665. Supersedes #33391. Docs: BerriAI/litellm-docs#1103
Affected release
Linear ticket
Resolves LIT-5050
Resolves LIT-8021
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more09cc332every check is green exceptdocumentation(required) andcode-quality, both failing on one line oftests/documentation_tests/test_env_keys.pyuntil docs(langfuse): update for Python SDK v4 migration litellm-docs#1103 merges, andoauth, which waits on the environment approval. CircleCI integration matrix is 8 of 9 green;integration-mcpfails on anUnboundLocalErrorinside a test this PR does not touch, which fails the same way onmain@greptileaito 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
Two real proxies, no mocks in the product path. Before is the merge base
5beac4f(its own pin,langfuse==2.59.7) on port 4200, After is09cc332(langfuse==4.15.2,opentelemetry-sdk==1.33.1) on port 4100, each started withpython litellm/proxy/proxy_cli.py --config config.yaml --port <port> --num_workers 2 --use_v2_migration_resolveragainst its own PostgreSQL. The tipfdbd084on top of09cc332changes only the pip Dockerfile pins and a test docstring, no production file. Provider calls hit real OpenAIgpt-5.5and Geminigemini-3.8-flash. The destination is a real Langfuse Cloud project and every readback isGET /api/public/traces?sessionId=...thenGET /api/public/traces/<id>, matched by response id orx-litellm-call-id. For the prompt cells the proxy reaches Langfuse Cloud through a local TLS front that forwards bytes unchanged and can inject one fault. Admin UI and Langfuse UI screenshots and recordings are in #36741 (comment) and #36741 (comment); paired Before/After Langfuse UI captures of the same six requests at exactly5beac4fvs09cc332are in #36741 (comment)Every request carries
-H "Authorization: Bearer sk-litaudit-master" -H "Content-Type: application/json"and a session id inmetadata.session_id(chat) orlitellm_metadata.session_id(responses) so the readback can find itBefore (5beac4f)
Health check
curl -s -w ' http=%{http_code} wall=%{time_total}s' 'http://localhost:4200/health/services?service=langfuse'{"status":"success","message":"Mock LLM request made - check langfuse."} http=200 wall=0.677894sChat, Responses and streaming matrix
/v1/chat/completionsand/v1/responses, streamed and not, curl plus theopenaiSDK sync and async,gpt-5.5andgemini-3.8-flash, each with its own session idH17 PASS 200 time-20-47-32-595141_5Dq0avf-LNHVz7IP2ua2iAs gemini/gemini-3.8-flash 0.000414/v1/responsescells readFAIL(no generation): v2 stores them under the encrypted response id, so the plain readback cannot find them (pre-existing)Guardrail child span with trace user and session
curl -s http://localhost:4200/v1/chat/completions -d '{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}],"metadata":{"trace_user_id":"r9u-base-<run>","session_id":"r9s-base-<run>","version":"v9","trace_name":"r9-base"}}'with themask-postguardrail onname=r9-base userId=r9u-base-<run> sessionId=r9s-base-<run> version=v9, observationsGENERATION litellm-acompletionandSPAN guardrail(a sibling of the generation), both withuserId=None sessionId=NoneGET /api/public/observations?userId=r9u-base-<run>returns both the guardrail span and the generationPrompt fetch on a cold miss (captured at the previous merge base
bc6b540, samelangfuse==2.59.7)prompt_id: "litaudit-lf5050-what?"throughlangfuse/gpt-5.5:GET /api/public/v2/prompts/litaudit-lf5050-what%3F -> 200, answerQMARK-OKprompt_id: "litaudit-lf5050-r7/greeting":GET .../prompts/litaudit-lf5050-r7/greeting -> 404, caller HTTP 404 (v2 left/unescaped)litaudit-lf5050-does-not-exist: caller HTTP 404 in 0.89 s after 2 GETs, only litellm's own response headers503, 200, 200(the third GET is the logging hook's own v2 client)Retry-After: 30,/health/livelinessprobed 30 times during the request: caller HTTP 503 in 0.44 s after 2 GETs, slowest probe 0.13 sAfter (09cc332, no production change through tip fdbd084)
Health check
curl -s -w ' http=%{http_code} wall=%{time_total}s' 'http://localhost:4100/health/services?service=langfuse'{"status":"success","message":"Mock LLM request made - check langfuse."} http=200 wall=0.638937sChat, Responses and streaming matrix
H16 PASS 200 3152bcbac633e621 openai/gpt-5.5 0.00071andH17 PASS 200 fc5b51cea769f34f gemini/gemini-3.8-flash 0.00041025; the trace id is now the 16 or 32 hex form/v1/responsescells are found byx-litellm-call-idGuardrail child span with trace user and session
trace_user_id: r9u-head-<run>,session_id: r9s-head-<run>,trace_name: r9-headid=c5b84f5140a3c179ccc3ac10ea501840 name=r9-head userId=r9u-head-<run> sessionId=r9s-head-<run> version=v9, observationsGENERATION litellm-acompletion(root) andSPAN guardrailnested under it, both withuserId=None sessionId=Noneas on BeforeGET /api/public/observations?userId=r9u-head-<run>returns both the guardrail span and the generationPrompt fetch on a cold miss
prompt_id: "litaudit-lf5050-what?":GET /api/public/v2/prompts/litaudit-lf5050-what%3F -> 200, answerQMARK-OK(the tip before round 7 fetched.../whatand served the wrong prompt)prompt_id: "litaudit-lf5050-r7/greeting":GET .../prompts/litaudit-lf5050-r7%2Fgreeting -> 200, answerFOLDER-OKlitaudit-lf5050-does-not-exist: caller HTTP 404 in 0.57 s after 1 GET, response headersx-litellm-call-id,x-litellm-response-cost,x-litellm-key-spend,content-type,x-frame-options,content-security-policy,x-content-type-optionsonly, body{"error":{"message":"status_code: 404, body: {'message': \"Prompt not found: 'litaudit-lf5050-does-not-exist' with label 'production'\", ...}","code":"404"}}503,200)Retry-After: 30,/health/livelinessprobed 30 times during the request: caller HTTP 503 in 0.36 s after 2 GETs, no upstream header forwarded, slowest probe 0.04 sType
🐛 Bug Fix
Changes
litellm/integrations/langfuse/langfuse_sdk.py(new) is litellm's tracing layer for Langfuse v4:trace_attributesandobservation_attributesbuild thelangfuse.trace.*andlangfuse.observation.*span attributes from the SDK's publicLangfuseOtelSpanAttributesconstants, serialising input, output, usage, cost, model parameters, prompt and metadata the way the SDK's wire format doesresolve_trace_idandresolve_observation_idlowercase and de-dash the caller's id, keep it when it is 32 or 16 hex characters, and sha256-hash anything elsestart_generationopens the generation with the model call'sstart_timeunder a remote parent context built fromexisting_trace_idandparent_observation_id(or an empty context for a fresh trace, so a caller's active span is never adopted);start_child_spannests the guardrail and provider spans under itLangfuseTracingchannel:TracerProviderwith aTraceIdHashSamplerforLANGFUSE_SAMPLE_RATE, explicitSpanLimits, aResourcebuilt without env detectors, aBatchSpanProcessorwith a 100k queue andLANGFUSE_FLUSH_AT, and aLangfuseSpanExporterposting OTLP protobuf through litellm'sHTTPHandlerwithLANGFUSE_TIMEOUT,LANGFUSE_MAX_RETRIES(1, 2, 4 s backoff on 408, 429, 5xx and transport errors) and thex-langfuse-ingestion-version: 4header<truncated due to size exceeding limit>and is resent, up to three timesacquire_langfuse_tracingandrelease_langfuse_tracingrefcount the channel across loggers; the last release parks it on a 60 slangfuse-retiretimer, cancelled if the credentials come back, otherwise the provider is shut down and its batch thread endsLangfuseApiClientwraps the SDK's generatedLangfuseAPIREST client:auth_checkreturnsNoneor anAuthCheckFailurereason (status and body, transport error, orno project found),project_idfor the Slack link, andget_promptwith a litellm-owned 60 s stale-while-revalidate cache.projects.getandprompts.getrun withmax_retries=0; a cold prompt fetch URL-encodes the name, makes one immediate second attempt on 5xx or transport error, and raisesLangfusePromptErrorwith the status and body but never Langfuse's response headersflush_langfuse_tracingflushes every channel for the proxy shutdown hooklitellm/integrations/langfuse/langfuse.py:LangFuseLoggerrejects SDK 2.x and 3.x, acquires its channel and REST client fromlangfuse_sdk, and_log_langfuse_v2emits the generation with the trace-level attributes (name, user, session, tags, version, release, public flag,trace_metadata, trace input and output), the guardrail and provider-info spans under it, and a stringifiedgeneration_name.releasefalls back to the CI commit variables the v2 SDK read.UPSTREAM_LANGFUSE_*logs a warning and creates nothing.stop()releases the channel lease. The v1 path is deletedlitellm/integrations/langfuse/langfuse_prompt_management.pyusesbuild_langfuse_clientandacquire_langfuse_tracing.litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.pyreleases the initialized-client count on eviction and calls the evicted logger'sstop().litellm/litellm_core_utils/specialty_caches/service_trace_id_cache.pytakes the per-service trace id cache out oflitellm_logging.py.litellm/integrations/SlackAlerting/utils.pybuilds the alert's trace link from the recorded trace id and host instead of constructing aLangFuseLogger.litellm/proxy/proxy_server.pyflushes the channels on shutdown in a worker thread with a 10 s cap;/health/servicescallsauth_checkand raises with its reason.litellm/types/integrations/langfuse.pyaddsLangfuseLoggedEventTests:
tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py(new) and the mappedtest_langfuse.py,test_langfuse_prompt_management.py,test_dynamic_logging_cache.pyand health tests pin every behaviour above.tests/integration/observability/test_langfuse_delivery.py(new) runs a real proxy with Postgres and Redis against scripted provider and Langfuse wire servers, decodes the OTLP protobuf and checks the trace fields, auth, headers and the prompt fetch path; it fails both tests on the merge base.tests/logging_callback_tests/test_langfuse_e2e_test.pydecodes OTLP instead of v2 ingestion batches;tests/local_testing/test_alangfuse.pyreads back throughobservations.get_many()pyproject.tomlanduv.lockmove tolangfuse>=4.7,<5(locked 4.15.2), opentelemetry 1.33.1, instrumentation 0.54b1 and the traceloop 0.34.0 bump that pin forces.docker/build_from_pip/Dockerfile.build_from_pipis untouched: it installs the publishedlitellm==1.83.0wheel, whose callback still readslangfuse.version, so itslangfuse==2.59.7and opentelemetry 1.28.0 pins stay and move together with the nextLITELLM_VERSIONbumpCaveats (if any)
Severe
langfuse2.x and 3.x are rejected at logger construction with an upgrade message; on those SDKs the old code lost every event silently. The rejection follows litellm's callback bootstrap: withsuccess_callback = ["langfuse"]the firstcompletion()raises theImportErrorand later calls succeed without a Langfuse logger, so no event is sent; withcallbacks = ["langfuse"]the error goes to the log only. Base behaves the same way when its constructor raises (an SDK withoutlangfuse.versiongives oneAttributeError, then silence). Changing that to fail on every call means changingfunction_setupfor every callback, left out of this PRUPSTREAM_LANGFUSE_*is no longer supported; a warning is logged. v4 has no equivalentlevel: ERRORand the samestatusMessageMedium
nullare not exported (OpenTelemetry has no null attribute; the v4 SDK drops them too). v2 stored the key withnulltrace_versionand generationversionshare one v4 field (langfuse.versionon the span);trace_versionwins, at the root and underparent_observation_idalike, as the v4 SDK's propagated attributes overwrite a span's own. v2 kept both on separate eventsmodelParametersread back as strings ("stream": "false"), as with the v4 SDK; v2 kept the booleansafe_dumps(Python SDK callers only; proxy JSON always has string keys). Disclosed, not changedsafe_dumps, not the SDK'sEventSerializer, so values that are not JSON-native render differently:datetimeas2024-01-02 03:04:05+00:00(SDK:2024-01-02T03:04:05Z), anEnumasColor.RED(SDK: its value),bytesasb'hello'(SDK: decoded), a dataclass as its repr (SDK: a dict),float('nan')as a bareNaNtoken (v2:null, v4 SDK:"NaN"). Python SDK callers only; proxy JSON bodies are unaffected. Disclosed, not changedexisting_trace_idonly) reports a random, unexportedparentObservationIdinstead ofnull, as the v4 SDK does; the UI still renders it at the rootuser.idorsession.idthemselves (neither did v2's); Langfuse reads those from the traceLANGFUSE_SAMPLE_RATEoutside 0..1 or non-numeric is logged and treated as 1.0 (v2 returned HTTP 500 or exported nothing);LANGFUSE_FLUSH_INTERVALnot a whole number above 0 warns and uses 1 s;LANGFUSE_MAX_RETRIESabove 1000 is capped;LANGFUSE_TIMEOUTis a float, default 20 s, for OTLP and REST;LANGFUSE_DEBUGacceptstrueor1;LANGFUSE_BASE_URLis a fallback forLANGFUSE_HOSTLANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDSis read by the v4 SDK at import withint(); a non-integer raises a namedValueErrorbefore that importauth_check,project_idand a cold prompt fetch are synchronous on the event loop, as on v2, but with zero sleeping retries: a Langfuse 5xx or 429 fails at once (v2 slept 1 s and retried). Worst case is two attempts ofLANGFUSE_TIMEOUTwhen the destination hangsmetadata["prompt"]dict that does not validate as a typed prompt raises during attribute building and loses the trace; v2 kept a separate trace eventMAX_LANGFUSE_INITIALIZED_CLIENTSraised above the 200-entry logger cache, fast credential churn can hold up to one extra channel per evicted key for 60 s (live: 300 keys, 427 threads at burst end, 227 after 60 s; base peaked at 631 and never reclaimed). Default budget 50 is unaffectedBatchSpanProcessorthat was shut down leaves an otel-sdk at-fork hook that printsTypeError: 'NoneType' object is not callablein a child forked afterwards (SDK users who fork after logging; proxy workers fork before any channel exists)/health/servicesbuilds a fresh logger per probe without releasing its slot;DynamicLoggingCacheTTL bookkeeping is unlocked; thelru_cached REST client is not closed on eviction;/v1/messagespost-call masking reaches Langfuse unmaskedLow
LANGFUSE_MAX_RETRIESfailed sends, a killed worker loses its queue, the shutdown hook waits 10 s and logsflushedorflush incomplete. Live chaos losses at this rig: outage 5 of 30, pause 0 of 10, worker kill 1 of 20, restart 0 of 12 (v2: 28, 1, 6, 6)otelcallback stood in), retry on 501/507/599, prompt names with%, space or#, a transport error on the first prompt attempt, the eviction race (unit tests only for each)credit balance is too low; both legs logged the failure generation, last full Anthropic comparison was at an earlier tipdocumentationandcode-qualityfail ontests/documentation_tests/test_env_keys.pyuntil docs(langfuse): update for Python SDK v4 migration litellm-docs#1103 lands the three newLANGFUSE_*rows; merge that first and rerun both checksdocker/build_from_pip/Dockerfile.build_from_pipkeepslangfuse==2.59.7and opentelemetry 1.28.0 because it installs the publishedlitellm==1.83.0, whose callback readslangfuse.version; whoever bumpsLITELLM_VERSIONto a release carrying this PR bumps those pins with it (or drops the langfuse line, sincelitellm[proxy]then requireslangfuse>=4.7). Its CircleCI config has no langfuse callback, so that job would not catch a mismatchpy/import-own-moduleonfrom langfuse import ...inlangfuse.py; absolute imports resolve to the SDK package, false positiveFinal Attestation
Note
High Risk
This is a breaking observability migration: unsupported Langfuse SDK versions fail at logger construction, trace/id normalization and span nesting differ from v2, and all tracing depends on Langfuse server OTLP support and new export lifecycle on proxy shutdown.
Overview
Migrates the
langfusesuccess/failure callback from Langfuse SDK v2 ingestion to v4’s OTLP path, with dependency pins moved tolangfuse>=4.7,<5and OpenTelemetry 1.33.1.A new
langfuse_sdklayer owns trace export: generations and child spans (guardrails, provider metadata) are emitted as OTel spans with Langfuse v4 attributes, posted via a custom OTLP exporter (retries, 413 batch-split/truncation, mock-mode discarding). REST-onlyLangfuseApiClienthandles prompts,auth_check, and project id—without constructing the SDK’sLangfuse()client. Export channels are refcounted per credential set; loggers getflush()/stop()and cache eviction releases channels instead of calling v2shutdown()on every client.LangFuseLoggernow enforces supported SDK versions at init, dropsUPSTREAM_LANGFUSE_*(warn-only), normalizes hosts viaresolve_langfuse_host, and returns typedLangfuseLoggedEvent. Prompt management and Slack trace URLs use the REST client / resolved host; trace ids for alerting are cached after log events. Proxy shutdown flushes all channels (LANGFUSE_SHUTDOWN_FLUSH_TIMEOUT_MILLIS); health checks useapi_client.auth_check(). Tests and expected fixtures shift from v2 batch JSON to OTLP-shaped payloads, plus new integration coverage for delivery and prompt fetch behavior.Reviewed by Cursor Bugbot for commit fdbd084. Bugbot is set up for automated code reviews on this repo. Configure here.