Skip to content

fix: stop shipping application telemetry to the LLM tracing vendor - #14357

Open
ogabrielluiz wants to merge 3 commits into
release-1.12.0from
fix/traceloop-global-provider
Open

fix: stop shipping application telemetry to the LLM tracing vendor#14357
ogabrielluiz wants to merge 3 commits into
release-1.12.0from
fix/traceloop-global-provider

Conversation

@ogabrielluiz

@ogabrielluiz ogabrielluiz commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

The Traceloop SDK takes no tracer provider. It adopts whichever provider is global and attaches its exporter to that, so its exporter sees every span on that provider, including the service's own HTTP and flow spans. instrument_fastapi_app runs unconditionally at startup, so those HTTP spans exist in every install, whether or not an APM is configured. They were being shipped to api.traceloop.com alongside the LLM traces the operator actually asked for.

The fix wraps the SDK's own span processor factory for the duration of Traceloop.init, so whatever it builds is filtered: spans whose instrumentation scope is in the application allowlist are dropped, everything else passes through untouched.

Why the factory and not the provider

The factory is the one place every export path goes through.

Patching the provider's add_span_processor only covers the case where a provider already exists. It misses the more common one: with no OTLP endpoint the bootstrap installs nothing, the global provider is still a proxy, the SDK creates and registers its own concrete provider, and the proxy resolves onto it. Same leak, different route. The provider is also a shared object, so a processor another integration registered during init would have been wrapped too and had this boundary applied backwards.

Passing processor= to init is the other obvious hook and is worse. It turns off the SDK's metrics, drops the TRACELOOP_HEADERS auth the Instana integration depends on, makes disable_batch inert, and skips config/prompt sync. Wrapping the factory leaves all of that alone, because from init's point of view no processor was supplied.

Fail-closed

traceloop-sdk is depended on across >=0.43.1,<1.0.0 and the filter is installed by wrapping a function of theirs. If a release stops routing through get_default_span_processor, the filter stops applying and there is no other symptom: spans keep flowing, and the only difference is that the service's telemetry is in them. CI will not catch it either, since CI resolves the lockfile while the version that breaks it is the one a user installs.

So the integration now records whether the filter actually installed, and disables itself with an explanatory error if an init that built the SDK's pipeline did not get one. A broken LLM tracing integration is recoverable; silently shipping the operator's HTTP and flow spans to a third party is not. Happy to be argued out of the severity of that response.

Verification

Every test was checked against the failure it claims to catch, not just observed green:

  • Leak reproduced with two loopback collectors: without the filter, one flow.execute span arrives at both the APM and the vendor. With it, the APM gets flow.execute and the vendor gets only llm.call.
  • The no-APM case is covered separately, since that is the configuration the provider-patching approach missed.
  • Disabling the filter turns three of the four boundary tests red; the fourth asserts pass-through, so it correctly stays green.
  • Removing the fail-closed raise turns the fail-closed test red.
  • Instana's TRACELOOP_HEADERS still reaches the collector, metrics stay enabled, and 200 tracer constructions add one thread rather than 200.
  • 330 passed, 3 skipped in src/backend/tests/unit/services/tracing/.

Known gap

If Traceloop initialises before the bootstrap it owns the global provider and set_tracer_provider is one-shot, so the operator's APM receives nothing. That part is not fixed here and cannot be from our side in the same process. The leak direction is fixed in that ordering, because the filter is on Traceloop's own processor rather than on a provider. There is a test pinning both halves.

Note on direction

This is a workaround for one SDK's design choice. Seven of the eight tracing vendors either accept a tracer_provider= or build their own; Traceloop is the only one that adopts the global. The durable fix is upstream, and this should be deletable once it lands.

Companion: #14359 pins that the other six vendors coexist with the bootstrap.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented application telemetry from being sent to external tracing vendors.
    • Preserved vendor-specific telemetry routing while keeping application spans available to configured monitoring systems.
    • Disabled tracing integration safely when telemetry filtering cannot be installed.
  • Tests

    • Added coverage for telemetry routing, missing monitoring configuration, initialization order, and failure scenarios.

The Traceloop SDK takes no tracer provider. It adopts whichever provider is
global and adds its exporter to that, so when application observability is
enabled it attaches to ours and its exporter sees every span on it, including
the service's own HTTP and flow spans. Those are what the operator pointed at
their APM; they were also being shipped to api.traceloop.com.

Wrap the provider's add_span_processor for the duration of Traceloop.init so
whatever the SDK attaches is filtered, dropping the scopes the application
allowlist claims and passing everything else through. The SDK's own pipeline
is left intact: its endpoint scheme dispatch, TRACELOOP_HEADERS auth, metrics
and prompt sync all still run, which is not the case when a processor is
supplied to init instead.

Known limitation, pinned by a test: if Traceloop initialises before the
bootstrap it owns the global provider and there is nothing for us to wrap. In
practice the bootstrap runs at startup and Traceloop only on the first flow
run.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Traceloop now excludes Langflow application spans from vendor exports while forwarding vendor spans. Initialization verifies filter installation and fails closed when the SDK bypasses the hook. Subprocess tests cover routing and initialization-order scenarios.

Changes

Traceloop telemetry routing

Layer / File(s) Summary
Application scope filtering
src/backend/base/langflow/services/tracing/traceloop.py
The new span processor drops configured application scopes, forwards other spans, delegates lifecycle operations, and logs dropped scopes once.
Guarded Traceloop initialization
src/backend/base/langflow/services/tracing/traceloop.py
Initialization reads TRACELOOP_BASE_URL before entering a lock-protected context, verifies filter installation, restores the SDK factory, and fails closed when installation is bypassed.
Telemetry routing validation
src/backend/tests/unit/services/tracing/test_traceloop_application_telemetry.py
Subprocess tests verify application and vendor span routing, no-APM behavior, failed filter installation, and vendor-first initialization using loopback OTLP collectors.

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

Sequence Diagram(s)

sequenceDiagram
  participant Application
  participant TraceloopSetup
  participant ApplicationScopeFilter
  participant OperatorAPM
  participant TraceloopVendor
  Application->>TraceloopSetup: create application span
  TraceloopSetup->>ApplicationScopeFilter: process span
  ApplicationScopeFilter->>OperatorAPM: forward application span
  ApplicationScopeFilter-->>TraceloopVendor: withhold application span
  Application->>TraceloopSetup: create vendor span
  TraceloopSetup->>ApplicationScopeFilter: process vendor span
  ApplicationScopeFilter->>TraceloopVendor: forward vendor span
Loading

Possibly related PRs

Suggested reviewers: adam-aghili, jordanrfrazier

🚥 Pre-merge checks | ✅ 7 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Test File Naming And Structure ⚠️ Warning The backend file uses valid test_*.py pytest functions, but its subprocess/HTTP collector integration tests are under tests/unit and lack an integration marker; collectors also lack explicit teardown. Move these tests to src/backend/tests/integration/services/tracing or mark them as integration, and add fixture cleanup with server.shutdown() and server.server_close().
Test Quality And Coverage ❓ Inconclusive Investigation in progress; test behavior and project patterns require cross-checking. Inspect the subprocess probes, scope definitions, bootstrap behavior, and related tests before deciding.
✅ Passed checks (7 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing application telemetry from reaching the LLM tracing vendor.
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 new Traceloop filtering and fail-closed behavior has a matching test_traceloop_application_telemetry.py with five subprocess collector tests covering leakage, pass-through, ordering, and failure.
Excessive Mock Usage Warning ✅ Passed The changed tests use real subprocesses, loopback HTTP collectors, OpenTelemetry, and Traceloop. They contain no Mock, MagicMock, patch, or monkeypatch usage.
✨ 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 fix/traceloop-global-provider

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 bug Something isn't working label Jul 31, 2026

@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 looks great, just a couple things, can you take a look?

Code Review Summary

Found 2 critical issues need to be fixed:

🔴 Critical (Must Fix)

1. Proxy-provider path still exports application spans to Traceloop

FilePath: [src/backend/base/langflow/services/tracing/traceloop.py](

provider = trace.get_tracer_provider()
if not hasattr(provider, "add_span_processor"):
# A proxy provider: the SDK builds its own, which carries nothing of ours.
yield
return
) line 99

if not hasattr(provider, "add_span_processor"):
    yield
    return

Explanation

When startup has no OTLP trace endpoint, bootstrap_application_telemetry() leaves ProxyTracerProvider installed. This branch skips filtering, after which Traceloop installs its own concrete global provider. Existing proxy tracers and future FastAPI, ASGI, and langflow.observability tracers then delegate to that unfiltered provider.

A bootstrap-first/no-endpoint loopback probe on head a3e8073d produced:

apm=[] traceloop=['flow.execute']

That is a normal Traceloop-only configuration, not merely the documented vendor-before-bootstrap edge case.

Suggested Fix

  1. Apply the filter to Traceloop’s processor/exporter construction regardless of the initial provider type.
  2. Add a regression test that bootstraps without an OTLP endpoint, initializes Traceloop, and requires flow.execute to remain absent from the vendor collector.

2. The process-wide patch can capture unrelated processors

FilePath: [src/backend/base/langflow/services/tracing/traceloop.py](

def add_span_processor(processor: SpanProcessor) -> None:
type(provider).add_span_processor(provider, _ApplicationScopeFilter(processor))
provider.add_span_processor = add_span_processor
try:
yield
finally:
del provider.add_span_processor
) line 105

provider.add_span_processor = add_span_processor

Explanation

_INIT_LOCK coordinates only callers of this helper. Every other thread sees the replaced method during Traceloop.init. If another integration registers a processor then, it is permanently wrapped with _ApplicationScopeFilter.

A barrier-based reproduction showed the unrelated processor dropped langflow.observability while accepting the vendor span—the exact inverse of its intended boundary. An unrelated APM could therefore lose application telemetry and receive LLM/vendor spans instead.

Suggested Fix

  1. Avoid patching the shared provider method; intercept only Traceloop’s own processor factory or exporter.
  2. Add a concurrent-registration test proving an unrelated processor remains untouched.

Found 1 suggestion for improvement:

🟡 Suggestions (Should Consider)

1. Preserve existing provider hooks exactly

FilePath: [src/backend/base/langflow/services/tracing/traceloop.py](

def add_span_processor(processor: SpanProcessor) -> None:
type(provider).add_span_processor(provider, _ApplicationScopeFilter(processor))
provider.add_span_processor = add_span_processor
try:
yield
finally:
del provider.add_span_processor
) line 105

Explanation

Calling type(provider).add_span_processor(...) bypasses instance-level provider hooks, while unconditional del provider.add_span_processor removes any hook that existed before entry. Dynamic providers can also pass hasattr() but lack a class-level method, causing initialization to fail.

Suggested Fix

  • If interception remains, capture the original bound callable and prior instance-attribute state, delegate through that callable, and restore the exact state in finally.

✅ What's Good

  • Current-head validation passed: 328 passed, 3 skipped, plus targeted Ruff, format, and git diff --check.
  • The configured-APM happy-path test correctly demonstrates the original leak and verifies vendor spans still export.
  • GitHub reports the PR mergeable. The backend CI job was skipped because this is a draft; one accessibility scan remains pending.

Two problems with patching the global provider's add_span_processor.

When no OTLP endpoint is configured the bootstrap installs nothing and the
global provider is still a proxy, so there was nothing to patch and the filter
was skipped. Traceloop then registers its own concrete provider, the proxy
resolves onto it, and the service's spans reach the vendor by the same route.
That is the ordinary Traceloop-without-an-APM setup, not an edge case.

The provider is also shared: a processor another integration registered while
init was running would have been wrapped too, and would then have had this
filter's boundary applied backwards.

Wrap the SDK's get_default_span_processor for the duration of init instead. It
applies whichever provider Traceloop ends up with, and touches nothing outside
the SDK. As a side effect the vendor-first ordering no longer leaks either: the
APM still receives nothing, but the span is dropped rather than exported.
@ogabrielluiz

Copy link
Copy Markdown
Contributor Author

Hey @erichare, thanks for the review.

1 is real and my comment on that branch was wrong. Reproduced it with no OTLP endpoint: the proxy resolves onto traceloop's provider once it registers, and the app span lands at the vendor.

Fixed both by moving the filter off the provider. It now wraps traceloop's own get_default_span_processor for the duration of init, so it applies whichever provider traceloop ends up with, and no shared object gets patched. That makes 2 impossible by construction and 3 moot.

Added test_application_spans_do_not_reach_the_vendor_without_an_apm for the no-endpoint case. It fails without the fix.

Side effect: this also fixes the vendor-first ordering I'd written up as a known limitation. The APM still receives nothing there (set_tracer_provider is one-shot) but the span is dropped instead of exported, so I updated that test.

On the concurrent-registration test, with the provider patch gone I'm not sure what it would pin. Do you still want one?

@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Jul 31, 2026
@erichare
erichare self-requested a review July 31, 2026 16:14

@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 Thanks, that addresses my concerns. No concurrent-registration test needed now; it was specific to the shared provider patch. The no-APM regression and updated vendor-first test cover the important paths.

@github-actions github-actions Bot added the lgtm This PR has been approved by a maintainer label Jul 31, 2026
The filter is installed by wrapping a function of the SDK's, and traceloop-sdk
is depended on across >=0.43.1,<1.0.0. If a release stops routing its exporter
through get_default_span_processor the filter stops applying and nothing else
changes: spans keep flowing, and the only difference is that the service's own
telemetry is in them. CI cannot catch that, because CI resolves the lockfile
while the version that breaks it is the one a user installs.

Record whether the filter actually installed, and disable the integration with
an explanatory error when an init that builds the SDK's pipeline did not get
one. A broken LLM tracing integration is recoverable; silently shipping the
operator's HTTP and flow spans to a third party is not.

Also note in the module why this matters in every install rather than only
where an APM is configured: instrument_fastapi_app runs unconditionally at
startup, so HTTP server spans are always being produced.
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Jul 31, 2026
@ogabrielluiz
ogabrielluiz marked this pull request as ready for review July 31, 2026 16:36
@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 bug Something isn't working and removed bug Something isn't working labels Jul 31, 2026
@ogabrielluiz ogabrielluiz added the test Changes to tests label 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 (3)
src/backend/base/langflow/services/tracing/traceloop.py (1)

140-151: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Confirm the intended visibility of the fail-closed error.

_application_telemetry_withheld raises RuntimeError. The constructor at Line 202 catches every exception and logs at debug level. The logger.error call at Line 150 is therefore the only operator-visible signal. That works, but the pairing of logger.error plus raise inside the same block also produces a debug traceback for the same event. Consider raising without the logger.error call and letting the caller log at error level for this specific failure, so the pinned-version guidance is not duplicated.

🤖 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/base/langflow/services/tracing/traceloop.py` around lines 140 -
151, The fail-closed path in _application_telemetry_withheld currently logs the
same RuntimeError at error and debug levels. Remove the local logger.error call
and preserve the RuntimeError message, then update the constructor’s exception
handling to recognize this specific failure and log it at error level while
retaining debug logging for other exceptions.
src/backend/tests/unit/services/tracing/test_traceloop_application_telemetry.py (2)

48-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the fixed sleep with polling to reduce flake risk.

report waits exactly one second, then reads the collector bodies once. On a loaded CI machine the HTTP handlers can still be in flight. The assertions then report an empty list, which reads as a filtering regression rather than a timing problem. Poll for the expected span names until a deadline instead.

Note that a negative assertion, for example result["traceloop"] == [], still needs a full wait, so keep a floor on the wait time.

♻️ Proposed polling helper
-def report(**extra):
-    time.sleep(1)
-    seen = {}
-    for target in ("apm", "traceloop"):
-        seen[target] = sorted(
-            name.decode()
-            for name in (b"flow.execute", b"llm.call")
-            if any(name in body for body in bodies[target])
-        )
-    print("RESULT " + json.dumps({**seen, **extra}))
+def _seen():
+    seen = {}
+    for target in ("apm", "traceloop"):
+        seen[target] = sorted(
+            name.decode()
+            for name in (b"flow.execute", b"llm.call")
+            if any(name in body for body in bodies[target])
+        )
+    return seen
+
+def report(expected=0, **extra):
+    # Settle floor: negative assertions need the full wait, so never return early below it.
+    deadline = time.monotonic() + 5
+    time.sleep(1)
+    seen = _seen()
+    while time.monotonic() < deadline and sum(len(v) for v in seen.values()) < expected:
+        time.sleep(0.1)
+        seen = _seen()
+    print("RESULT " + json.dumps({**seen, **extra}))

Each probe then passes the number of span names it expects, for example report(expected=1, ready=tracer._ready).

🤖 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_traceloop_application_telemetry.py`
around lines 48 - 57, Update report to replace the fixed time.sleep(1) with
deadline-based polling that repeatedly checks the collector bodies for the
expected span names and supports the proposed expected/ready inputs. Preserve a
minimum wait duration so negative assertions still observe the full interval,
and update callers to provide the expected span count and tracer readiness
signal.

101-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the public ready property.

These probes read tracer._ready. The probe at Line 170 reads tracer.ready. ready is a public property that returns the same value. Use it in all three probes for consistency.

♻️ Proposed change
-        report(ready=tracer._ready)
+        report(ready=tracer.ready)
-        report(ready=tracer._ready, installed=telemetry.tracer_provider is not None)
+        report(ready=tracer.ready, installed=telemetry.tracer_provider is not None)

Also applies to: 128-128

🤖 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_traceloop_application_telemetry.py`
at line 101, Update all three telemetry probes in the tracing test to read the
public tracer.ready property instead of the private tracer._ready attribute,
including the report call and the probe referenced at line 128, while preserving
the existing report behavior.
🤖 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/base/langflow/services/tracing/traceloop.py`:
- Around line 140-151: The fail-closed path in _application_telemetry_withheld
currently logs the same RuntimeError at error and debug levels. Remove the local
logger.error call and preserve the RuntimeError message, then update the
constructor’s exception handling to recognize this specific failure and log it
at error level while retaining debug logging for other exceptions.

In
`@src/backend/tests/unit/services/tracing/test_traceloop_application_telemetry.py`:
- Around line 48-57: Update report to replace the fixed time.sleep(1) with
deadline-based polling that repeatedly checks the collector bodies for the
expected span names and supports the proposed expected/ready inputs. Preserve a
minimum wait duration so negative assertions still observe the full interval,
and update callers to provide the expected span count and tracer readiness
signal.
- Line 101: Update all three telemetry probes in the tracing test to read the
public tracer.ready property instead of the private tracer._ready attribute,
including the report call and the probe referenced at line 128, while preserving
the existing report behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18e1fb7c-28e9-423e-9a63-9eda7625ee04

📥 Commits

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

📒 Files selected for processing (2)
  • src/backend/base/langflow/services/tracing/traceloop.py
  • src/backend/tests/unit/services/tracing/test_traceloop_application_telemetry.py

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Unit Test Coverage Report

Coverage Summary

Lines Statements Branches Functions
Coverage: 48%
48.3% (69319/143488) 69.88% (9691/13868) 46.23% (1595/3450)

Unit Test Results

Tests Skipped Failures Errors Time
5449 0 💤 0 ❌ 0 🔥 21m 20s ⏱️

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...ackend/base/langflow/services/tracing/traceloop.py 0.00% 51 Missing ⚠️

❌ Your patch check has failed because the patch coverage (0.00%) is below the target coverage (40.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##           release-1.12.0   #14357      +/-   ##
==================================================
+ Coverage           54.30%   61.96%   +7.65%     
==================================================
  Files                2300     2339      +39     
  Lines              228686   239148   +10462     
  Branches            15799    33561   +17762     
==================================================
+ Hits               124188   148182   +23994     
+ Misses             102670    89129   -13541     
- Partials             1828     1837       +9     
Flag Coverage Δ
backend 68.54% <0.00%> (+0.16%) ⬆️
frontend 60.68% <ø> (+12.35%) ⬆️
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/traceloop.py 0.00% <0.00%> (ø)

... and 1011 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.

@ogabrielluiz ogabrielluiz removed the test Changes to tests label 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