Skip to content

fix: keep LLM tracing vendors off the global tracer provider, and pin it - #14359

Open
ogabrielluiz wants to merge 3 commits into
release-1.12.0from
test/vendor-provider-coexistence
Open

fix: keep LLM tracing vendors off the global tracer provider, and pin it#14359
ogabrielluiz wants to merge 3 commits into
release-1.12.0from
test/vendor-provider-coexistence

Conversation

@ogabrielluiz

@ogabrielluiz ogabrielluiz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

One global tracer provider per process, and set_tracer_provider is one-shot. A vendor that registers itself globally takes the slot the application observability bootstrap needs, and then receives every span on it. That is #13319: Langfuse claimed the global provider and FastAPIInstrumentor's HTTP spans started showing up in Langfuse traces.

Two parts:

A fix in langsmith.py. LANGSMITH_TRACING_MODE=otel|hybrid makes the LangSmith client build an OpenTelemetry pipeline, and a client constructed without a provider registers its own as the global one. So enabling that mode silently disabled application observability. The tracer now hands LangSmith an isolated provider, built once per process, exporting to LangSmith's own OTLP endpoint.

Coverage for the rest. Two tests per vendor across both init orders: vendor-first, where the global slot is actually contested, and bootstrap-first, the real deployment order. Each case runs in its own subprocess against loopback stubs, since the global provider and most of these clients are process-wide singletons. No credentials, no network.

The isolated provider needs its own exporter

Worth spelling out, because the obvious version is wrong and fails silently.

langfuse.py passes tracer_provider= and the Langfuse SDK calls add_span_processor on what it is given. LangSmith does not: it only takes tracers from the provider. So handing it a bare TracerProvider() stops it claiming the global one and discards every span, while the tracer still reports ready. In otel mode there is no REST fallback, so traces just stop, with no log and no exception.

Measured against a loopback stub bound to LANGCHAIN_ENDPOINT:

otel hybrid
base POST /otel, 1078 B /otel + /runs/multipart
bare provider nothing at all /runs/multipart only
this PR POST /otel, 1043 B /otel + /runs/multipart

It deliberately does not honour OTEL_EXPORTER_OTLP_ENDPOINT, which the SDK's own factory reads first. That variable points at the operator's APM, which is not where a vendor's prompt-bearing traces belong.

Why both test directions

They fail independently. Reverting the #13319 fix leaves the provider's identity untouched, because the SDK cannot replace a provider that is already set, while its exporter attaches to ours anyway and starts receiving the service's spans.

The suite also asserts the vendor still receives its own traces. Without that, a vendor that was disconnected outright passes exactly like one that is correctly isolated, since the vendor collector is empty either way. That is not hypothetical: it is how the bare-provider bug above got through a green run.

Verification

Checked against real regressions rather than only observed green:

  • Langfuse without its isolated provider: both directions fail, langfuse claimed the global provider, leaving the APM with none and application telemetry leaked to langfuse.
  • LangWatch without skip_open_telemetry_setup=True: same two failures.
  • LangSmith with a bare provider: langsmith_otel received no traces at all, so isolation is not what passed.

A vendor whose SDK is absent on the running interpreter is skipped rather than failing the ready guard, since langwatch is declared python_version < "3.14".

Not covered

Traceloop, which genuinely does not hold this property — that is #14357, with its own test file. And native, which writes to Langflow's own database and imports no OpenTelemetry, so a case for it would pass without exercising anything.

Known coarseness: the "vendor still received data" assertion is a byte count, so in hybrid mode a broken OTel leg is masked by the REST leg still posting. It catches the fully-silent case, which is the one that ships unnoticed.

…lity

There is one global tracer provider per process and set_tracer_provider is
one-shot, so a vendor that registers itself globally takes the slot the
application bootstrap needs and then receives every span on it. That is what
happened in #13319, where Langfuse claimed the provider and FastAPIInstrumentor's
HTTP spans started landing in Langfuse traces.

Cover the six vendors that are supposed to hold the property, in both orders:
vendor first, where the contest for the global slot happens, and bootstrap
first, which is the real deployment order. Each case runs in a subprocess
against loopback stubs, since the provider and most vendor clients are
process-wide singletons.

Both directions are asserted because they fail independently. Reverting the
Langfuse fix leaves the provider's identity untouched, since the SDK cannot
replace a provider that is already set, while its exporter still attaches to
ours and starts receiving the service's spans.

Traceloop is deliberately excluded: it does not hold this property and has its
own test file.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Added subprocess-based tests for six tracing vendors. The tests isolate vendor and application endpoints, verify provider ownership across initialization orders, and confirm that application spans reach only the application APM endpoint.

Changes

Tracing vendor coexistence

Layer / File(s) Summary
Vendor configuration and HTTP harness
src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py
Defines configuration for six vendors and provides separate APM and vendor HTTP stubs with request capture and Langfuse authentication responses.
Provider probes and subprocess execution
src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py
Runs vendor-first and bootstrap-first probes in isolated subprocesses. The runner removes inherited telemetry settings and parses probe results.
Coexistence assertions
src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py
Parameterized tests verify vendor initialization, global provider ownership, separate provider exposure, and application span routing.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 8 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Test Quality And Coverage ⚠️ Warning The six-vendor parametrization unconditionally asserts LangWatch is ready, but LangWatch is excluded for Python 3.14+; the supported suite will fail when the optional SDK is absent. Detect LangWatch availability and skip its cases when unavailable, as test_http_context_propagation.py does, or provide a supported test double without weakening the readiness assertion.
✅ Passed checks (8 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed The commit adds the correctly named backend test_vendor_provider_coexistence.py with 12 parametrized subprocess cases covering six real vendors, both init orders, provider ownership, and span routing.
Test File Naming And Structure ✅ Passed The backend file uses test_*.py under unit/services/tracing, parses as pytest with two parametrized tests, descriptive names, isolated subprocess setup, and positive/negative assertions across six...
Excessive Mock Usage Warning ✅ Passed The test uses no Mock or Patch APIs. It exercises real vendor constructors and telemetry bootstrap in subprocesses, with loopback HTTPServer stubs only for external endpoints.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the vendor global tracer provider behavior targeted by the coexistence tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/vendor-provider-coexistence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the test Changes to tests label Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Test Coverage Advisor

No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉

Advisory check only — never blocks merge.

@github-actions github-actions Bot added test Changes to tests and removed test Changes to tests labels Jul 31, 2026

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

🧹 Nitpick comments (1)
src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py (1)

100-139: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Fixed 1-second sleep may mask the exact regression this suite targets.

report() waits a fixed time.sleep(1) before inspecting bodies["vendor"] and bodies["apm"]. In _PROBE_BOOTSTRAP_FIRST, telemetry.tracer_provider.force_flush(5000) only forces flush on span processors registered on the application's own tracer provider. If a vendor SDK delivers spans through its own background thread or queue outside that provider's span-processor chain (a plausible path for non-OpenTelemetry vendors such as LangSmith, Opik, or Openlayer), that delivery is not covered by force_flush and depends entirely on completing within the 1-second window.

If a vendor's async flush interval exceeds 1 second, vendor_saw_application_span reads False even when application spans are actually leaking to the vendor, causing test_application_spans_do_not_reach_the_vendor to pass on a real regression. This directly undermines the property the suite says it pins for issue #13319-style bugs.

Replace the fixed sleep with either an explicit flush/shutdown call on each vendor's client (where the SDK exposes one) before reading bodies, or a bounded polling loop that only proceeds once no new vendor requests have arrived for a short quiescence window, instead of assuming 1 second is always sufficient.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py`
around lines 100 - 139, Replace the fixed time.sleep(1) in the harness report()
function with explicit vendor-client flush/shutdown calls when available, or a
bounded polling loop that waits for request activity to become quiescent before
inspecting bodies. Ensure asynchronous vendor deliveries have enough time to
reach the collectors so vendor_saw_application_span and apm_saw_application_span
accurately reflect emitted spans without relying on a fixed one-second delay.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py`:
- Around line 100-139: Replace the fixed time.sleep(1) in the harness report()
function with explicit vendor-client flush/shutdown calls when available, or a
bounded polling loop that waits for request activity to become quiescent before
inspecting bodies. Ensure asynchronous vendor deliveries have enough time to
reach the collectors so vendor_saw_application_span and apm_saw_application_span
accurately reflect emitted spans without relying on a fixed one-second delay.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dc0a7db5-b9ed-411b-b226-c6e723d7fdb0

📥 Commits

Reviewing files that changed from the base of the PR and between 2da52d0 and c7dd7d2.

📒 Files selected for processing (1)
  • src/backend/tests/unit/services/tracing/test_vendor_provider_coexistence.py

@erichare erichare left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ogabrielluiz a couple comments!

Code Review Summary

Found 2 critical issues that need to be fixed at head c7dd7d2a61:

🔴 Critical (Must Fix)

1. LangSmith’s OpenTelemetry modes are excluded from the coexistence test

FilePath: [test_vendor_provider_coexistence.py](

# The probe sets every variable it depends on. Inherited OTEL_ or vendor variables from the
# developer's shell (OTEL_TRACES_EXPORTER=none, a real LANGFUSE_HOST) would route exporters
# away from the loopback stubs or point a client at a real backend.
prefixes = ("OTEL_", "LANGFUSE_", "LANGCHAIN_", "LANGSMITH_", "LANGWATCH_", "PHOENIX_", "ARIZE_")
prefixes += ("OPENLAYER_", "OPIK_", "COMET_", "TRACELOOP_")
env = {k: v for k, v in os.environ.items() if not k.startswith(prefixes)}
) line 200

prefixes = (..., "LANGSMITH_", ...)

Explanation

The harness removes LANGSMITH_TRACING_MODE, so it tests only LangSmith’s default REST transport. Production constructs the pinned langsmith==0.10.10 client with bare Client(), which honors LANGSMITH_TRACING_MODE=otel|hybrid.

Running this exact probe with either mode produced:

ready=True
bootstrap_installed=False
bootstrap_owns_global=False

LangSmith therefore claims the global provider in vendor-first order—the exact regression this PR claims to prevent—while the committed suite remains green.

Suggested Fix

  1. Add LangSmith otel and hybrid variants.
  2. Fix the exposed production behavior by using an isolated provider, or explicitly disable/document those modes in Langflow.
  3. Verify both application and vendor spans reach their intended collectors.

2. The unconditional LangWatch cases fail on Python 3.14

FilePath: [test_vendor_provider_coexistence.py](

@pytest.mark.parametrize("vendor", sorted(_VENDORS))
def test_vendor_does_not_claim_the_global_provider(vendor: str):
"""A vendor that initialises first must still leave the global slot for the bootstrap.
Traceloop fails this one, which is why it is not in the table: it claims the global
provider, and because ``set_tracer_provider`` is one-shot the bootstrap then cannot install
and the operator's APM receives nothing.
``ready`` is asserted first and deliberately: a vendor that failed to initialise would leave
the global slot free for the trivial reason that it did nothing, and every other assertion
here would pass while pinning nothing.
"""
result = _run(vendor, _PROBE_VENDOR_FIRST)
assert result["ready"] is True, f"{vendor} did not initialise, so this case pins nothing"
assert result["bootstrap_installed"] is True, f"{vendor} claimed the global provider, leaving the APM with none"
assert result["bootstrap_owns_global"] is True, f"the global provider is not the bootstrap's after {vendor}"
if result["has_vendor_provider"]:
assert result["vendor_provider_is_separate"] is True, f"{vendor} is exporting from the global provider"
@pytest.mark.parametrize("vendor", sorted(_VENDORS))
def test_application_spans_do_not_reach_the_vendor(vendor: str):
"""The operator's application telemetry must stay out of the vendor's backend.
This is the assertion that catches #13319, and it is not implied by the one above. Reverting
that fix leaves the global provider's *identity* untouched -- the SDK cannot replace a
provider that is already set -- while its exporter still attaches to ours and starts
receiving the service's own spans. Identity and export are separate properties.
"""
result = _run(vendor, _PROBE_BOOTSTRAP_FIRST)
assert result["ready"] is True, f"{vendor} did not initialise, so this case pins nothing"
) lines 217 and 238

@pytest.mark.parametrize("vendor", sorted(_VENDORS))

Explanation

Langflow deliberately installs langwatch only on Python <3.14, but both parametrizations require tracer.ready is True.

An isolated Python 3.14 run reproduced:

10 passed, 2 failed
AssertionError: langwatch did not initialise, so this case pins nothing

Both failing cases are selected in the live Python 3.14 Group 5 CI shard; that job was still running when checked.

Suggested Fix

  1. Apply a parameter-level skip to LangWatch when the dependency is unavailable.
  2. Retain all other vendor coverage on Python 3.14.

✅ What's Good

  • All 12 default-mode cases passed under Python 3.12 in 56.79 seconds.
  • Subprocess isolation is appropriate for one-shot global providers.
  • The positive APM assertion prevents a trivial “nothing exported” pass.
  • git diff --check passed, and the head is cleanly mergeable with current release-1.12.0.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 49%
49.04% (70691/144144) 70.11% (9860/14063) 46.64% (1627/3488)

Unit Test Results

Tests Skipped Failures Errors Time
5466 0 💤 0 ❌ 0 🔥 21m 25s ⏱️

LANGSMITH_TRACING_MODE=otel|hybrid makes the LangSmith client build an
OpenTelemetry pipeline, and when it is constructed without a provider it
registers its own as the global one. set_tracer_provider is one-shot, so
whichever side runs first wins: either LangSmith swallows the service's own
HTTP and flow spans, or application observability cannot install at all.

Passing a provider to our own Client() is not enough, because the tracing path
goes through run_trees.get_cached_client, a module-level singleton built lazily
on the first post. Seed that instead, which is also the function langchain
calls, so this replaces a redundant second client rather than adding one.

Also extend the coexistence tests: LangSmith's two OpenTelemetry transports are
now covered rather than only its default REST one, and a vendor whose SDK is
absent on the running interpreter is skipped instead of failing the ready
guard. langwatch is declared python_version < "3.14", so the unconditional
parametrisation was failing the 3.14 shard.
@ogabrielluiz

Copy link
Copy Markdown
Contributor Author

Hey @erichare, thanks, both were real.

1 is worse than a test gap. I reproduced it and then found the fix I first tried doesn't work: passing otel_tracer_provider to our own Client() changes nothing, because the tracing path builds its client through run_trees.get_cached_client, a module-level singleton created lazily on the first post. Traceback confirmed the set_tracer_provider call comes from there, not from us. Seeding that instead fixes all three modes (bootstrap_installed: true for default, otel and hybrid), and since it's the same function langchain calls, it replaces a redundant second client rather than adding one.

2 fixed by skipping a vendor whose SDK isn't installed on the running interpreter, rather than only special-casing langwatch. Same outcome on 3.14, and it won't silently pass if another vendor's dependency goes missing.

Both LangSmith OTel modes are now in the table. Reverting to the bare Client() fails exactly those two cases with claimed the global provider, leaving the APM with none, and leaves the default REST case green.

341 passed, 3 skipped locally.

On CodeRabbit's point about the fixed time.sleep(1): I don't think it's wrong, but I'd rather not tune a timeout. The vendor assertion is a negative one, so a too-short wait makes it pass when it should fail. Do you have a preference between a quiescence poll and calling each vendor's flush where the SDK exposes one?

@github-actions github-actions Bot added test Changes to tests and removed test Changes to tests labels Jul 31, 2026
@erichare

Copy link
Copy Markdown
Member

Hey @erichare, thanks, both were real.

1 is worse than a test gap. I reproduced it and then found the fix I first tried doesn't work: passing otel_tracer_provider to our own Client() changes nothing, because the tracing path builds its client through run_trees.get_cached_client, a module-level singleton created lazily on the first post. Traceback confirmed the set_tracer_provider call comes from there, not from us. Seeding that instead fixes all three modes (bootstrap_installed: true for default, otel and hybrid), and since it's the same function langchain calls, it replaces a redundant second client rather than adding one.

2 fixed by skipping a vendor whose SDK isn't installed on the running interpreter, rather than only special-casing langwatch. Same outcome on 3.14, and it won't silently pass if another vendor's dependency goes missing.

Both LangSmith OTel modes are now in the table. Reverting to the bare Client() fails exactly those two cases with claimed the global provider, leaving the APM with none, and leaves the default REST case green.

341 passed, 3 skipped locally.

On CodeRabbit's point about the fixed time.sleep(1): I don't think it's wrong, but I'd rather not tune a timeout. The vendor assertion is a negative one, so a too-short wait makes it pass when it should fail. Do you have a preference between a quiescence poll and calling each vendor's flush where the SDK exposes one?

Thanks for digging into this. #1 looks resolved: get_cached_client(...) is the right seam because it is the singleton the RunTree/LangChain path consumes, and the otel + hybrid variants close the coverage gap.

I think #2 needs one small adjustment. langwatch is the only SDK here that is conditionally absent (python_version < "3.14"); the others are unconditional langflow-base dependencies. The generalized find_spec skip would turn a missing LangSmith/Langfuse/Phoenix/Opik/Openlayer dependency into a green skip, weakening the ready guard. Could you scope that skip to LangWatch on Python 3.14 and let any other missing SDK fail?

On sleep(1), I agree that tuning the timeout isn’t the answer. For the OTel-provider regression this test pins, flow.execute originates on the application provider, so any leak reaches the vendor through a processor/exporter attached to that provider. I’d assert that telemetry.tracer_provider.force_flush(5000) returns True, then inspect the collectors without the sleep. A quiescence poll can still finish before delayed work starts; if we find a concrete out-of-provider path, then add an explicit bounded flush for that vendor.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.35%. Comparing base (c87e311) to head (0e9c18f).
⚠️ Report is 16 commits behind head on release-1.12.0.

Files with missing lines Patch % Lines
...ackend/base/langflow/services/tracing/langsmith.py 0.00% 25 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           release-1.12.0   #14359      +/-   ##
==================================================
+ Coverage           54.30%   62.35%   +8.05%     
==================================================
  Files                2300     2361      +61     
  Lines              228686   239862   +11176     
  Branches            15799    35715   +19916     
==================================================
+ Hits               124188   149573   +25385     
+ Misses             102670    88452   -14218     
- Partials             1828     1837       +9     
Flag Coverage Δ
backend 69.50% <0.00%> (+1.12%) ⬆️
frontend 61.03% <ø> (+12.70%) ⬆️
lfx 59.91% <ø> (-0.28%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ackend/base/langflow/services/tracing/langsmith.py 0.00% <0.00%> (ø)

... and 991 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@erichare
erichare self-requested a review July 31, 2026 19:24
@github-actions github-actions Bot added the lgtm This PR has been approved by a maintainer label Jul 31, 2026
The previous commit handed LangSmith a bare TracerProvider(). That stops it
claiming the global provider, but it also silences it: unlike the Langfuse SDK,
which calls add_span_processor on the provider it is given, LangSmith only takes
tracers from it. A provider with no processor discards every span while the
tracer still reports ready.

Measured against a loopback stub bound to LANGCHAIN_ENDPOINT:

  base            otel -> POST /otel 1078 bytes    hybrid -> /otel + /runs/multipart
  bare provider   otel -> nothing at all           hybrid -> /runs/multipart only
  this commit     otel -> POST /otel 1043 bytes    hybrid -> /otel + /runs/multipart

In otel mode there is no REST fallback, so the operator simply stopped seeing
traces, with no log and no exception.

Build the provider the way the SDK's own get_otlp_tracer_provider does, with one
deliberate difference: it does not honour OTEL_EXPORTER_OTLP_ENDPOINT, which
points at the operator's APM and is not where a vendor's prompt-bearing traces
belong. Build it once per process, since TracerProvider registers an atexit
shutdown and setup runs per flow run.

The tests could not have caught this: nothing asserted the vendor still receives
its own traces, so a disconnected vendor passed exactly like an isolated one.
The probes now drive a real trace through each tracer and assert the vendor
collector received data. Also drop a provider-identity assertion that could not
fail, sanitise proxy variables that would swallow the loopback traffic, and wait
for the collectors to go quiet rather than sleeping a fixed second.
@ogabrielluiz ogabrielluiz changed the title test: pin that LLM tracing vendors coexist with application observability fix: keep LLM tracing vendors off the global tracer provider, and pin it Jul 31, 2026
@github-actions github-actions Bot added test Changes to tests bug Something isn't working and removed test Changes to tests labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working lgtm This PR has been approved by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants