Skip to content

Commit 6a09a37

Browse files
committed
fix: filter Traceloop's own processor instead of patching the provider
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.
1 parent a3e8073 commit 6a09a37

2 files changed

Lines changed: 59 additions & 23 deletions

File tree

src/backend/base/langflow/services/tracing/traceloop.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,32 +84,38 @@ def _application_telemetry_withheld():
8484
attaches to, and its exporter then sees every span on it -- including the service's own
8585
HTTP and flow spans, which the operator pointed at their APM and not at Traceloop.
8686
87-
Wrapping ``add_span_processor`` for the duration of ``init`` rather than passing
88-
``processor=`` leaves the SDK's own pipeline alone. Supplying a processor also turns off
89-
its metrics, drops the ``TRACELOOP_HEADERS`` auth the Instana integration depends on,
90-
makes ``disable_batch`` inert and skips prompt sync, none of which this needs to touch.
91-
It also fails the right way: a span can only reach the vendor via a processor added to
92-
our provider, and that is the one call intercepted here.
93-
94-
Serialised because flows run concurrently and the patch is on a process-wide object: two
87+
When no OTLP endpoint is configured the bootstrap installs nothing and the global provider
88+
is still a proxy, which does not make the problem go away: the SDK then creates a concrete
89+
provider and registers it globally, the proxy resolves onto it, and the service's spans
90+
reach the vendor by the same route. So the filter has to apply in both cases.
91+
92+
It is applied to the SDK's own processor factory rather than to the provider. Patching the
93+
provider's ``add_span_processor`` only covers the case where a provider already exists, and
94+
it is a shared object -- a processor another integration registers while ``init`` is running
95+
would be wrapped too, and would then have this filter's boundary applied backwards.
96+
97+
Passing ``processor=`` to ``init`` is the other obvious hook and is worse: it turns off the
98+
SDK's metrics, drops the ``TRACELOOP_HEADERS`` auth the Instana integration depends on,
99+
makes ``disable_batch`` inert and skips prompt sync. Wrapping the factory leaves all of
100+
that alone, because from ``init``'s point of view no processor was supplied.
101+
102+
Serialised because flows run concurrently and the patch is on a module attribute: two
95103
tracers initialising at once would otherwise have the first one's restore run while the
96104
second is still inside init, leaving that run's processor unwrapped.
97105
"""
106+
from traceloop.sdk.tracing import tracing as traceloop_tracing
107+
98108
with _INIT_LOCK:
99-
provider = trace.get_tracer_provider()
100-
if not hasattr(provider, "add_span_processor"):
101-
# A proxy provider: the SDK builds its own, which carries nothing of ours.
102-
yield
103-
return
109+
original = traceloop_tracing.get_default_span_processor
104110

105-
def add_span_processor(processor: SpanProcessor) -> None:
106-
type(provider).add_span_processor(provider, _ApplicationScopeFilter(processor))
111+
def get_default_span_processor(*args, **kwargs) -> SpanProcessor:
112+
return _ApplicationScopeFilter(original(*args, **kwargs))
107113

108-
provider.add_span_processor = add_span_processor
114+
traceloop_tracing.get_default_span_processor = get_default_span_processor
109115
try:
110116
yield
111117
finally:
112-
del provider.add_span_processor
118+
traceloop_tracing.get_default_span_processor = original
113119

114120

115121
class TraceloopTracer(BaseTracer):

src/backend/tests/unit/services/tracing/test_traceloop_application_telemetry.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ def test_application_spans_do_not_reach_the_llm_vendor():
106106
assert result["traceloop"] == [], "application telemetry leaked to the LLM vendor"
107107

108108

109+
def test_application_spans_do_not_reach_the_vendor_without_an_apm():
110+
"""The filter must also apply when no APM is configured, which is the common setup.
111+
112+
With no OTLP endpoint the bootstrap installs nothing and the global provider is still a
113+
proxy. That does not mean there is nothing to filter: Traceloop then creates the concrete
114+
provider and registers it globally, the proxy resolves onto it, and the service's own spans
115+
reach the vendor by exactly the same route.
116+
"""
117+
result = _run("""
118+
from lfx.observability import bootstrap_application_telemetry, APPLICATION_TRACER_NAME
119+
telemetry = bootstrap_application_telemetry(prometheus_enabled=False)
120+
121+
from langflow.services.tracing.traceloop import TraceloopTracer
122+
tracer = TraceloopTracer(
123+
trace_name="probe", trace_type="chain", project_name="probe", trace_id=uuid.uuid4()
124+
)
125+
126+
from opentelemetry import trace
127+
trace.get_tracer(APPLICATION_TRACER_NAME).start_span("flow.execute").end()
128+
report(ready=tracer._ready, installed=telemetry.tracer_provider is not None)
129+
""")
130+
131+
assert result["installed"] is False, "no OTLP endpoint means the bootstrap installs no provider"
132+
assert result["ready"] is True, "the vendor integration must still initialise"
133+
assert result["traceloop"] == [], "application telemetry leaked to the LLM vendor"
134+
135+
109136
def test_vendor_spans_still_reach_the_vendor():
110137
"""Filtering our telemetry out must not cost Traceloop its own spans.
111138
@@ -135,16 +162,19 @@ def test_vendor_spans_still_reach_the_vendor():
135162

136163

137164
def test_vendor_first_leaves_the_operator_without_application_telemetry():
138-
"""Characterises the reverse order, which this change does not fix.
165+
"""Characterises the reverse order: no leak, but no application telemetry either.
139166
140167
When Traceloop initialises before the bootstrap it claims the global provider, and OTel's
141168
set_tracer_provider is one-shot, so enabling OTLP afterwards in the same process cannot
142-
install ours. The filter goes on our provider, so with Traceloop owning the global one
143-
there is nothing to put it on and application spans still reach the vendor.
169+
install ours. The operator's APM therefore receives nothing, and that part is a real
170+
limitation rather than something this change fixes.
171+
172+
What it does fix is the direction that matters: the filter is on Traceloop's own processor,
173+
so it applies whichever provider Traceloop ended up with. The application span is dropped
174+
rather than shipped to the vendor.
144175
145176
In practice the bootstrap runs at app startup and Traceloop only on the first flow run, so
146-
our provider wins. This pins the limitation rather than asserting it is acceptable: if the
147-
ordering is ever made robust, traceloop becomes empty and this test should say so.
177+
this ordering does not arise in a deployed process.
148178
"""
149179
result = _run("""
150180
from langflow.services.tracing.traceloop import TraceloopTracer
@@ -162,4 +192,4 @@ def test_vendor_first_leaves_the_operator_without_application_telemetry():
162192

163193
assert result["installed"] is False, "the bootstrap must decline rather than fight for the global"
164194
assert result["apm"] == [], "known limitation: the APM receives nothing when the vendor initialises first"
165-
assert result["traceloop"] == ["flow.execute"], "known limitation: the vendor receives it instead"
195+
assert result["traceloop"] == [], "but application telemetry must still not reach the vendor"

0 commit comments

Comments
 (0)