Skip to content

Commit 6c32b6a

Browse files
committed
fix: keep the serve API key out of the operator's APM
The FastAPI server span records url.query verbatim, and lfx serve accepts its API key as a query parameter (APIKeyQuery). That scope is allowlisted for export, so any deployment whose callers pass the key that way has been shipping it to whatever OTLP backend the operator configured. A probe against the real instrumented app exports url.query = "x-api-key=<the key>". The export boundary now strips query strings and userinfo from URL attributes on the way out. Scheme, host, port and path survive, so the route an operator needs is unchanged. Done here rather than in an instrumentor hook because this is the one place that already decides what leaves. Also adds SQLAlchemy spans, so a slow request can be attributed to the queries it made. Allowlisted only after checking what they carry: db.statement keeps bound parameters as placeholders, so chat message text stays in the database. httpx and requests are deliberately NOT instrumented and NOT allowlisted, even though the ticket asks for outbound spans. They are the transports the LLM vendor SDKs ride on, and those SDKs instrument them globally against whichever provider is global, so admitting them would put one span per outbound provider call into the APM and reverse a documented decision. Outbound provider health has a leak-safe metrics path instead. Left for a separate decision. Sampling needed no change: the provider takes no explicit sampler, so OTEL_TRACES_SAMPLER already applies. Pinned with a determinism check that exports 0 spans at ratio 0.0 and all 20 at 1.0.
1 parent e2d0dc4 commit 6c32b6a

8 files changed

Lines changed: 324 additions & 3 deletions

File tree

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6696,7 +6696,7 @@
66966696
"filename": "src/lfx/src/lfx/cli/serve_app.py",
66976697
"hashed_secret": "b894b81be94cf8fa8d7536475aaec876addf05c8",
66986698
"is_verified": false,
6699-
"line_number": 70,
6699+
"line_number": 71,
67006700
"is_secret": false
67016701
}
67026702
],
@@ -7309,5 +7309,5 @@
73097309
}
73107310
]
73117311
},
7312-
"generated_at": "2026-08-04T18:09:09Z"
7312+
"generated_at": "2026-08-05T14:27:06Z"
73137313
}

src/backend/base/langflow/main.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from filelock import FileLock
2222
from lfx.interface.utils import setup_llm_caching
2323
from lfx.log.logger import configure, logger
24-
from lfx.observability import instrument_fastapi_app, start_event_loop_lag_monitor, stop_event_loop_lag_monitor
24+
from lfx.observability import (
25+
instrument_dependencies,
26+
instrument_fastapi_app,
27+
start_event_loop_lag_monitor,
28+
stop_event_loop_lag_monitor,
29+
)
2530
from pydantic import PydanticDeprecatedSince20
2631
from pydantic_core import PydanticSerializationError
2732
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
@@ -988,6 +993,9 @@ async def exception_handler(_request: Request, exc: Exception):
988993
# lazy-include route patch + instrument_app). The helper lives in lfx so lfx serve
989994
# instruments its own app the same way.
990995
instrument_fastapi_app(app)
996+
# The rest of the trace tree: DB queries and outbound HTTP made during a run. The engine is
997+
# not built yet at app-factory time, so SQLAlchemy instruments its own dispatcher globally.
998+
instrument_dependencies()
991999

9921000
add_pagination(app)
9931001

src/backend/base/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ dependencies = [
6969
"opentelemetry-exporter-prometheus>=0.50b0,<1.0.0",
7070
"opentelemetry-exporter-otlp>=1.30.0,<2.0.0",
7171
"opentelemetry-instrumentation-fastapi>=0.50b0,<1.0.0",
72+
"opentelemetry-instrumentation-sqlalchemy>=0.50b0,<1.0.0",
7273
"opentelemetry-instrumentation-requests>=0.50b0,<1.0.0",
7374
"opentelemetry-instrumentation-urllib3>=0.50b0,<1.0.0",
7475
# Process CPU, memory, GC and thread counts. Without it the only metrics the APM gets are
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Dependency spans must not carry credentials out of the process.
2+
3+
This is the reason ``requests`` and ``urllib3`` were pulled from the export allowlist once
4+
before: their spans write the full request URL, and providers put API keys in the query
5+
string (Gemini's ``?key=``, among others). httpx is the transport the OpenAI and Anthropic
6+
SDKs ride on, so admitting these scopes is only safe while the export boundary redacts URLs.
7+
8+
Each case runs in a subprocess because the tracer provider is process-global.
9+
"""
10+
11+
import gzip
12+
import http.server
13+
import json
14+
import os
15+
import subprocess
16+
import sys
17+
import tempfile
18+
import threading
19+
from pathlib import Path
20+
21+
import pytest
22+
from lfx.observability import APPLICATION_INSTRUMENTATION_SCOPES
23+
24+
25+
class _Collector(http.server.BaseHTTPRequestHandler):
26+
def do_POST(self) -> None:
27+
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
28+
if self.headers.get("Content-Encoding") == "gzip":
29+
body = gzip.decompress(body)
30+
self.server.requests.append((self.path, body))
31+
self.send_response(200)
32+
self.send_header("Content-Length", "0")
33+
self.end_headers()
34+
35+
def log_message(self, *args) -> None:
36+
pass
37+
38+
39+
@pytest.fixture
40+
def collector():
41+
server = http.server.HTTPServer(("127.0.0.1", 0), _Collector)
42+
server.requests = []
43+
thread = threading.Thread(target=server.serve_forever, daemon=True)
44+
thread.start()
45+
yield server
46+
server.shutdown()
47+
server.server_close()
48+
thread.join(timeout=5)
49+
50+
51+
SECRET = "lfx-serve-api-key-NOT-REAL" # noqa: S105 # pragma: allowlist secret
52+
53+
# The server span is already allowlisted, and ``lfx serve`` accepts its API key as a query
54+
# parameter, so this is a live path today rather than a hypothetical one.
55+
SERVER_SPAN_PROBE = f"""
56+
import asyncio, json
57+
58+
from opentelemetry import trace
59+
from opentelemetry.sdk.trace import TracerProvider
60+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
61+
62+
from lfx.observability import ApplicationOnlySpanProcessor, instrument_fastapi_app
63+
64+
exporter = InMemorySpanExporter()
65+
provider = TracerProvider()
66+
provider.add_span_processor(ApplicationOnlySpanProcessor(exporter))
67+
trace.set_tracer_provider(provider)
68+
69+
from fastapi import FastAPI
70+
from httpx import ASGITransport, AsyncClient
71+
72+
app = FastAPI()
73+
74+
75+
@app.get("/flows/run")
76+
async def run():
77+
return {{"ok": True}}
78+
79+
80+
instrument_fastapi_app(app)
81+
SECRET = {SECRET!r}
82+
83+
84+
async def main():
85+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://probe") as client:
86+
await client.get(f"/flows/run?x-api-key={{SECRET}}&flow=abc")
87+
provider.force_flush()
88+
spans = [
89+
{{"name": s.name, "attrs": dict(s.attributes)}}
90+
for s in exporter.get_finished_spans()
91+
if s.attributes and "url.path" in s.attributes
92+
]
93+
print("PROBE_RESULT " + json.dumps({{"spans": spans}}))
94+
95+
96+
asyncio.run(main())
97+
"""
98+
99+
100+
def run_probe(source: str) -> dict:
101+
env = {k: v for k, v in os.environ.items() if not k.startswith("OTEL_")}
102+
with tempfile.TemporaryDirectory() as tmp:
103+
probe_path = Path(tmp) / "probe.py"
104+
probe_path.write_text(source, encoding="utf-8")
105+
completed = subprocess.run( # noqa: S603
106+
[sys.executable, str(probe_path)],
107+
env=env,
108+
capture_output=True,
109+
text=True,
110+
timeout=300,
111+
check=False,
112+
)
113+
assert completed.returncode == 0, completed.stderr
114+
line = next(ln for ln in completed.stdout.splitlines() if ln.startswith("PROBE_RESULT "))
115+
return json.loads(line.removeprefix("PROBE_RESULT "))
116+
117+
118+
def test_the_serve_api_key_never_reaches_the_apm():
119+
"""Regression for a live leak: the key is accepted as a query param and the span kept it."""
120+
result = run_probe(SERVER_SPAN_PROBE)
121+
122+
assert result["spans"], "expected a server span carrying url.path"
123+
blob = json.dumps(result["spans"])
124+
assert SECRET not in blob, f"serve API key reached the APM: {blob}"
125+
126+
127+
def test_redaction_keeps_the_route_an_operator_needs():
128+
"""Blanking everything would be safe and useless; path and method must survive."""
129+
result = run_probe(SERVER_SPAN_PROBE)
130+
attrs = result["spans"][0]["attrs"]
131+
132+
assert attrs["url.path"] == "/flows/run"
133+
assert attrs["url.query"] == ""
134+
assert attrs["http.request.method"] == "GET"
135+
136+
137+
def test_outbound_http_scopes_stay_out_of_the_allowlist():
138+
"""Redacting URLs does not make the LLM transports safe to export; that boundary stands."""
139+
for scope in (
140+
"opentelemetry.instrumentation.httpx",
141+
"opentelemetry.instrumentation.requests",
142+
"opentelemetry.instrumentation.urllib3",
143+
):
144+
assert scope not in APPLICATION_INSTRUMENTATION_SCOPES
145+
146+
147+
def test_database_spans_are_allowlisted():
148+
"""Verified separately to carry bound-parameter placeholders, never row values."""
149+
assert "opentelemetry.instrumentation.sqlalchemy" in APPLICATION_INSTRUMENTATION_SCOPES
150+
151+
152+
# The ticket's sampler criterion, run against the real bootstrap rather than a hand-built
153+
# provider: the same request at ratio 0.0 must export nothing and at 1.0 must export the lot.
154+
SAMPLER_PROBE = """
155+
import json
156+
from opentelemetry import trace
157+
from lfx.observability import APPLICATION_TRACER_NAME, bootstrap_application_telemetry
158+
159+
telemetry = bootstrap_application_telemetry()
160+
tracer = trace.get_tracer(APPLICATION_TRACER_NAME)
161+
for _ in range(20):
162+
with tracer.start_as_current_span("flow.execute"):
163+
pass
164+
telemetry.shutdown()
165+
print("PROBE_RESULT done")
166+
"""
167+
168+
169+
def _run_sampler_probe(endpoint: str, ratio: str) -> None:
170+
env = {k: v for k, v in os.environ.items() if not k.startswith("OTEL_")}
171+
env["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
172+
env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
173+
env["OTEL_TRACES_SAMPLER"] = "traceidratio"
174+
env["OTEL_TRACES_SAMPLER_ARG"] = ratio
175+
completed = subprocess.run( # noqa: S603
176+
[sys.executable, "-c", SAMPLER_PROBE],
177+
env=env,
178+
capture_output=True,
179+
text=True,
180+
timeout=300,
181+
check=False,
182+
)
183+
assert completed.returncode == 0, completed.stderr
184+
185+
186+
def _exported_span_count(requests_seen) -> int:
187+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
188+
189+
total = 0
190+
for path, body in requests_seen:
191+
if path != "/v1/traces":
192+
continue
193+
request = ExportTraceServiceRequest()
194+
request.ParseFromString(body)
195+
for rs in request.resource_spans:
196+
for ss in rs.scope_spans:
197+
total += len(ss.spans)
198+
return total
199+
200+
201+
def test_sampling_off_exports_nothing(collector):
202+
port = collector.server_address[1]
203+
_run_sampler_probe(f"http://127.0.0.1:{port}", "0.0")
204+
205+
assert _exported_span_count(collector.requests) == 0
206+
207+
208+
def test_sampling_on_exports_every_span(collector):
209+
port = collector.server_address[1]
210+
_run_sampler_probe(f"http://127.0.0.1:{port}", "1.0")
211+
212+
assert _exported_span_count(collector.requests) == 20

src/lfx/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ otel = [
9090
"opentelemetry-exporter-otlp>=1.30.0,<2.0.0",
9191
"opentelemetry-exporter-prometheus>=0.50b0,<1.0.0",
9292
"opentelemetry-instrumentation-fastapi>=0.50b0,<1.0.0",
93+
"opentelemetry-instrumentation-sqlalchemy>=0.50b0,<1.0.0",
9394
"opentelemetry-instrumentation-system-metrics>=0.50b0,<1.0.0",
9495
]
9596
# Engine-only by default: ``pip install lfx`` ships NO bundles. This optional

src/lfx/src/lfx/cli/serve_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from lfx.log.logger import logger
4848
from lfx.observability import (
4949
bootstrap_application_telemetry,
50+
instrument_dependencies,
5051
instrument_fastapi_app,
5152
start_event_loop_lag_monitor,
5253
stop_event_loop_lag_monitor,
@@ -707,6 +708,7 @@ async def _lifespan(_app: FastAPI):
707708
)
708709

709710
instrument_fastapi_app(app)
711+
instrument_dependencies()
710712

711713
app.state.registry = registry
712714
# Snapshot the API key once so per-request auth (verify_api_key, run on a threadpool

0 commit comments

Comments
 (0)