Skip to content

Commit efe096b

Browse files
committed
feat: database spans, and pin that sampling stays env-driven
SQLAlchemy is instrumented with an explicit tracer_provider, so a slow request can be attributed to the queries it made. Allowlisted only after probing what those spans carry: db.statement keeps bound parameters as placeholders, so chat message text stays in the database. The outbound HTTP scopes (httpx, requests, urllib3) are deliberately still absent. 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 operator's APM. Outbound provider health has a leak-safe metrics path instead. Left as its own decision rather than a side effect of this ticket. Sampling needed no change: the provider takes no explicit sampler, so OTEL_TRACES_SAMPLER already applies. Pinned end to end against a loopback collector, 0 spans at ratio 0.0 and all 20 at 1.0, so that adding a sampler later cannot silently take env control away.
1 parent c028037 commit efe096b

8 files changed

Lines changed: 185 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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Dependency spans and sampling.
2+
3+
Database spans are allowlisted for export; the LLM vendor transports deliberately are not.
4+
Sampling needs no code of ours, so the check here is that it stays that way.
5+
6+
Each case runs in a subprocess because the tracer provider is process-global.
7+
"""
8+
9+
import gzip
10+
import http.server
11+
import os
12+
import subprocess
13+
import sys
14+
import threading
15+
16+
import pytest
17+
from lfx.observability import APPLICATION_INSTRUMENTATION_SCOPES
18+
19+
20+
class _Collector(http.server.BaseHTTPRequestHandler):
21+
def do_POST(self) -> None:
22+
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
23+
if self.headers.get("Content-Encoding") == "gzip":
24+
body = gzip.decompress(body)
25+
self.server.requests.append((self.path, body))
26+
self.send_response(200)
27+
self.send_header("Content-Length", "0")
28+
self.end_headers()
29+
30+
def log_message(self, *args) -> None:
31+
pass
32+
33+
34+
@pytest.fixture
35+
def collector():
36+
server = http.server.HTTPServer(("127.0.0.1", 0), _Collector)
37+
server.requests = []
38+
thread = threading.Thread(target=server.serve_forever, daemon=True)
39+
thread.start()
40+
yield server
41+
server.shutdown()
42+
server.server_close()
43+
thread.join(timeout=5)
44+
45+
46+
def test_outbound_http_scopes_stay_out_of_the_allowlist():
47+
"""Redacting URLs does not make the LLM transports safe to export; that boundary stands."""
48+
for scope in (
49+
"opentelemetry.instrumentation.httpx",
50+
"opentelemetry.instrumentation.requests",
51+
"opentelemetry.instrumentation.urllib3",
52+
):
53+
assert scope not in APPLICATION_INSTRUMENTATION_SCOPES
54+
55+
56+
def test_database_spans_are_allowlisted():
57+
"""Verified separately to carry bound-parameter placeholders, never row values."""
58+
assert "opentelemetry.instrumentation.sqlalchemy" in APPLICATION_INSTRUMENTATION_SCOPES
59+
60+
61+
# The ticket's sampler criterion, run against the real bootstrap rather than a hand-built
62+
# provider: the same request at ratio 0.0 must export nothing and at 1.0 must export the lot.
63+
SAMPLER_PROBE = """
64+
import json
65+
from opentelemetry import trace
66+
from lfx.observability import APPLICATION_TRACER_NAME, bootstrap_application_telemetry
67+
68+
telemetry = bootstrap_application_telemetry()
69+
tracer = trace.get_tracer(APPLICATION_TRACER_NAME)
70+
for _ in range(20):
71+
with tracer.start_as_current_span("flow.execute"):
72+
pass
73+
telemetry.shutdown()
74+
print("PROBE_RESULT done")
75+
"""
76+
77+
78+
def _run_sampler_probe(endpoint: str, ratio: str) -> None:
79+
env = {k: v for k, v in os.environ.items() if not k.startswith("OTEL_")}
80+
env["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint
81+
env["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
82+
env["OTEL_TRACES_SAMPLER"] = "traceidratio"
83+
env["OTEL_TRACES_SAMPLER_ARG"] = ratio
84+
completed = subprocess.run( # noqa: S603
85+
[sys.executable, "-c", SAMPLER_PROBE],
86+
env=env,
87+
capture_output=True,
88+
text=True,
89+
timeout=300,
90+
check=False,
91+
)
92+
assert completed.returncode == 0, completed.stderr
93+
94+
95+
def _exported_span_count(requests_seen) -> int:
96+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
97+
98+
total = 0
99+
for path, body in requests_seen:
100+
if path != "/v1/traces":
101+
continue
102+
request = ExportTraceServiceRequest()
103+
request.ParseFromString(body)
104+
for rs in request.resource_spans:
105+
for ss in rs.scope_spans:
106+
total += len(ss.spans)
107+
return total
108+
109+
110+
def test_sampling_off_exports_nothing(collector):
111+
port = collector.server_address[1]
112+
_run_sampler_probe(f"http://127.0.0.1:{port}", "0.0")
113+
114+
assert _exported_span_count(collector.requests) == 0
115+
116+
117+
def test_sampling_on_exports_every_span(collector):
118+
port = collector.server_address[1]
119+
_run_sampler_probe(f"http://127.0.0.1:{port}", "1.0")
120+
121+
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

src/lfx/src/lfx/observability.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@
9696
{
9797
"opentelemetry.instrumentation.asgi",
9898
"opentelemetry.instrumentation.fastapi",
99+
# DB spans. Admitted after checking what they actually carry: db.statement keeps bound
100+
# parameters as placeholders ("INSERT INTO messagetable (text) VALUES (?)"), so chat
101+
# message text stays in the database and out of the APM. Verified by probe, because
102+
# "it probably does not log values" is how the httpx hole got opened the first time.
103+
#
104+
# The outbound HTTP scopes (httpx, requests, urllib3) remain deliberately absent. The
105+
# LLM vendor SDKs instrument them globally against whatever provider is global (ours),
106+
# so admitting them would put one span per outbound LLM call in the operator's APM.
107+
# Outbound provider health is delivered as leak-safe metrics instead.
108+
"opentelemetry.instrumentation.sqlalchemy",
99109
APPLICATION_TRACER_NAME,
100110
}
101111
)
@@ -580,6 +590,41 @@ def bootstrap_application_telemetry(*, prometheus_enabled: bool = False) -> Appl
580590
)
581591

582592

593+
def instrument_dependencies(*, engine: object | None = None) -> None:
594+
"""Instrument the database, so a slow request can be attributed to the queries it made.
595+
596+
Deliberately does NOT instrument httpx or requests. Those are the transports the LLM
597+
vendor SDKs ride on, and instrumenting them globally would put one span per outbound
598+
provider call into the operator's APM. That boundary is held elsewhere too (the export
599+
filter does not allowlist those scopes), and outbound provider health is delivered as
600+
leak-safe metrics rather than spans.
601+
602+
The instrumentor is given an explicit ``tracer_provider``. That is not decoration: a bare
603+
``instrument()`` binds to whatever provider is global, which is how vendor SDKs end up
604+
exporting through ours.
605+
606+
Optional and failure-tolerant: a missing package or a double-instrument call must not take
607+
the app down, because none of this is worth a failed boot.
608+
"""
609+
_instrument_sqlalchemy(engine)
610+
611+
612+
def _instrument_sqlalchemy(engine: object | None) -> None:
613+
try:
614+
from opentelemetry import trace
615+
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
616+
except ImportError:
617+
return
618+
try:
619+
kwargs = {"tracer_provider": trace.get_tracer_provider()}
620+
if engine is not None:
621+
# An async engine exposes the sync one the instrumentor actually patches.
622+
kwargs["engine"] = getattr(engine, "sync_engine", engine)
623+
SQLAlchemyInstrumentor().instrument(**kwargs)
624+
except Exception: # noqa: BLE001 - see above
625+
logger.debug("sqlalchemy instrumentation unavailable; DB spans will be missing", exc_info=True)
626+
627+
583628
def instrument_fastapi_app(app: FastAPI) -> None:
584629
"""Instrument an ASGI app for HTTP server telemetry under the stable conventions.
585630

uv.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)