-
Notifications
You must be signed in to change notification settings - Fork 9.9k
Expand file tree
/
Copy pathtest_flow_execution_span.py
More file actions
417 lines (339 loc) · 13.1 KB
/
Copy pathtest_flow_execution_span.py
File metadata and controls
417 lines (339 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""The tracer provider is process-global and installed once, so each case runs in a subprocess."""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from langflow.services.telemetry.opentelemetry import APPLICATION_INSTRUMENTATION_SCOPES
from lfx.observability import APPLICATION_TRACER_NAME
PROVIDER_SETUP = """
import asyncio, json
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
from lfx.components.input_output import ChatInput, ChatOutput
from lfx.graph.graph.base import Graph
def build_graph():
chat_input = ChatInput(_id="chat-input")
chat_input.set(input_value="hello operator")
chat_output = ChatOutput(_id="chat-output")
chat_output.set(input_value=chat_input.message_response)
return Graph(chat_input, chat_output, flow_id="11111111-1111-1111-1111-111111111111")
def report(result):
provider.force_flush()
result["spans"] = [
{
"name": span.name,
"scope": span.instrumentation_scope.name,
"attrs": dict(span.attributes),
"status": span.status.status_code.name,
"description": span.status.description,
"span_id": span.context.span_id,
"parent_span_id": span.parent.span_id if span.parent else None,
}
for span in exporter.get_finished_spans()
]
print("PROBE_RESULT " + json.dumps(result))
"""
ASYNC_START_PROBE = (
PROVIDER_SETUP
+ """
async def main():
graph = build_graph()
ran = []
async for step in graph.async_start():
if hasattr(step, "vertex"):
ran.append(step.vertex.id)
report({"ran": ran})
asyncio.run(main())
"""
)
ARUN_PROBE = (
PROVIDER_SETUP
+ """
async def main():
graph = build_graph()
run_outputs = await graph.arun(inputs=[{}], outputs=["chat-output"], session_id="session-abc")
text = run_outputs[0].outputs[0].results["message"].text
report({"text": text})
asyncio.run(main())
"""
)
SENTINEL = "prompt-text-that-must-not-be-exported"
FAILING_PROBE = (
PROVIDER_SETUP
+ f"""
from lfx.custom.custom_component.component import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
class Boom(Component):
display_name = "Boom"
inputs = [MessageTextInput(name="input_value", display_name="Input")]
outputs = [Output(name="message", display_name="Message", method="explode")]
def explode(self) -> Message:
raise RuntimeError({SENTINEL!r})
async def main():
chat_input = ChatInput(_id="chat-input")
chat_input.set(input_value="hello operator")
boom = Boom(_id="boom")
boom.set(input_value=chat_input.message_response)
graph = Graph(chat_input, boom, flow_id="11111111-1111-1111-1111-111111111111")
error = None
try:
await graph.arun(inputs=[{{}}], outputs=["boom"])
except Exception as exc: # noqa: BLE001
error = type(exc).__name__
report({{"error": error}})
asyncio.run(main())
"""
)
# A Loop runs its body as a subgraph, so without the guard a loop over N items would bury the
# operator's flow.execute span under N identical ones.
SUBGRAPH_PROBE = (
PROVIDER_SETUP
+ """
async def main():
graph = build_graph()
graph.prepare()
async with graph.create_subgraph({"chat-input", "chat-output"}) as subgraph:
assert subgraph._is_subgraph is True
spans_before = len(exporter.get_finished_spans())
with subgraph.flow_execution_span():
pass
opened = len(exporter.get_finished_spans()) - spans_before
report({"opened": opened})
asyncio.run(main())
"""
)
# A HITL pause is a suspend, not a failure. The resume is a separate span, opened by whichever
# runner drives Graph.process next.
PAUSED_PROBE = (
PROVIDER_SETUP
+ """
from lfx.graph.exceptions import GraphPausedException
async def main():
graph = build_graph()
raised = False
try:
with graph.flow_execution_span():
raise GraphPausedException(checkpoint_id="checkpoint-1", reason="waiting on a human")
except GraphPausedException:
raised = True
report({"raised": raised})
asyncio.run(main())
"""
)
# flow-as-tool runs a whole child Graph inside a component of the parent flow.
NESTED_PROBE = (
PROVIDER_SETUP
+ """
async def main():
parent = build_graph()
with parent.flow_execution_span():
child = build_graph()
await child.arun(inputs=[{}], outputs=["chat-output"], session_id="child-session")
report({})
asyncio.run(main())
"""
)
PROTOCOL_PROBE = (
PROVIDER_SETUP
+ """
from lfx.observability import execution_protocol
async def main():
graph = build_graph()
with execution_protocol("webhook"):
await graph.arun(inputs=[{}], outputs=["chat-output"])
report({})
asyncio.run(main())
"""
)
# Several surfaces share one driver (voice and the playground both reach the graph through the
# build loop), so the inner generic binding must not overwrite the outer one that knows how the
# request actually arrived.
NESTED_PROTOCOL_PROBE = (
PROVIDER_SETUP
+ """
from lfx.observability import execution_protocol, get_execution_protocol
async def main():
graph = build_graph()
with execution_protocol("voice"):
with execution_protocol("playground"):
inner = get_execution_protocol()
await graph.arun(inputs=[{}], outputs=["chat-output"])
after = get_execution_protocol()
report({"inner": inner, "after": after})
asyncio.run(main())
"""
)
# CancelledError is a BaseException, so an `except Exception` handler never sees it. Both a user
# pressing stop and a server-imposed ceiling arrive as this one type, and they mean opposite
# things to an operator, so the span has to tell them apart.
CANCELLED_PROBE = (
PROVIDER_SETUP
+ """
from lfx.constants import USER_CANCELLED_MESSAGE
async def main():
graph = build_graph()
raised = False
try:
with graph.flow_execution_span():
raise asyncio.CancelledError(USER_CANCELLED_MESSAGE)
except asyncio.CancelledError:
raised = True
report({"raised": raised})
asyncio.run(main())
"""
)
# A wall-clock ceiling, exactly as asyncio.wait_for delivers it: an untagged CancelledError.
ABORTED_PROBE = (
PROVIDER_SETUP
+ """
async def main():
graph = build_graph()
async def forever():
with graph.flow_execution_span():
await asyncio.sleep(10)
timed_out = False
try:
await asyncio.wait_for(forever(), timeout=0.05)
except (asyncio.TimeoutError, TimeoutError):
timed_out = True
report({"timed_out": timed_out})
asyncio.run(main())
"""
)
NO_OTEL_PROBE = """
import asyncio, json, sys
sys.modules["opentelemetry"] = None
sys.modules["opentelemetry.trace"] = None
from lfx.components.input_output import ChatInput, ChatOutput
from lfx.graph.graph.base import Graph
import lfx.graph.graph.base as graph_base
assert graph_base.otel_trace is None, "guard did not trip"
async def main():
chat_input = ChatInput(_id="chat-input")
chat_input.set(input_value="hello operator")
chat_output = ChatOutput(_id="chat-output")
chat_output.set(input_value=chat_input.message_response)
graph = Graph(chat_input, chat_output, flow_id="11111111-1111-1111-1111-111111111111")
ran = []
async for step in graph.async_start():
if hasattr(step, "vertex"):
ran.append(step.vertex.id)
print("PROBE_RESULT " + json.dumps({"ran": ran}))
asyncio.run(main())
"""
def run_probe(source: str) -> dict:
# Start from a clean slate so the developer's own OTEL_* vars cannot skew the result.
env = {k: v for k, v in os.environ.items() if not k.startswith("OTEL_")}
with tempfile.TemporaryDirectory() as tmp:
# A file rather than -c: Component.__init__ reads its own class source with inspect.
probe_path = Path(tmp) / "probe.py"
probe_path.write_text(source, encoding="utf-8")
completed = subprocess.run( # noqa: S603
[sys.executable, str(probe_path)],
env=env,
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert completed.returncode == 0, completed.stderr
line = next(ln for ln in completed.stdout.splitlines() if ln.startswith("PROBE_RESULT "))
return json.loads(line.removeprefix("PROBE_RESULT "))
def test_lfx_tracer_name_is_allowlisted_by_langflow():
"""Drift between the two constants would silently drop every application span."""
assert APPLICATION_TRACER_NAME in APPLICATION_INSTRUMENTATION_SCOPES
def test_async_start_emits_one_application_span():
result = run_probe(ASYNC_START_PROBE)
assert result["ran"] == ["chat-input", "chat-output"]
# Exactly one span is also the assertion that no component-level spans are produced.
assert len(result["spans"]) == 1
span = result["spans"][0]
assert span["name"] == "flow.execute"
assert span["scope"] == APPLICATION_TRACER_NAME
assert span["attrs"]["flow_id"] == "11111111-1111-1111-1111-111111111111"
assert span["attrs"]["run_id"]
def test_arun_emits_one_application_span():
result = run_probe(ARUN_PROBE)
assert result["text"] == "hello operator"
assert len(result["spans"]) == 1
span = result["spans"][0]
assert span["name"] == "flow.execute"
assert span["scope"] == APPLICATION_TRACER_NAME
assert set(span["attrs"]) == {"flow_id", "run_id", "session_id", "status"}
assert span["attrs"]["session_id"] == "session-abc"
assert span["attrs"]["status"] == "ok"
# No surface bound one, so the attribute is absent rather than guessed. An operator seeing a
# protocol-less flow span is looking at a genuinely unwired path.
assert "protocol" not in span["attrs"]
assert span["status"] == "UNSET"
def test_failing_flow_marks_the_span_as_an_error_without_leaking_the_message():
result = run_probe(FAILING_PROBE)
assert result["error"] == "ValueError"
assert len(result["spans"]) == 1
span = result["spans"][0]
assert span["status"] == "ERROR"
assert span["attrs"]["status"] == "error"
assert span["attrs"]["error.type"] == "ValueError"
# The wrapped message embeds component output, which must not reach the operator's APM.
assert SENTINEL not in json.dumps(span)
def test_flow_runs_with_no_opentelemetry_installed():
result = run_probe(NO_OTEL_PROBE)
assert result["ran"] == ["chat-input", "chat-output"]
def test_subgraph_does_not_open_its_own_span():
result = run_probe(SUBGRAPH_PROBE)
assert result["opened"] == 0
assert result["spans"] == []
def test_a_paused_flow_is_not_recorded_as_an_error():
result = run_probe(PAUSED_PROBE)
assert result["raised"] is True
assert len(result["spans"]) == 1
span = result["spans"][0]
# Span status stays UNSET so a pause never counts toward the error rate, but the attribute
# tells a paused run apart from a finished one, which UNSET alone cannot.
assert span["status"] == "UNSET"
assert span["attrs"]["status"] == "paused"
assert "error.type" not in span["attrs"]
def test_a_flow_run_from_inside_a_flow_nests_under_its_caller():
result = run_probe(NESTED_PROBE)
# Child ends first, so it is the one the exporter sees first.
child, parent = result["spans"]
assert child["attrs"]["session_id"] == "child-session"
assert child["parent_span_id"] == parent["span_id"]
def test_the_span_records_the_surface_the_run_arrived_through():
result = run_probe(PROTOCOL_PROBE)
assert len(result["spans"]) == 1
assert result["spans"][0]["attrs"]["protocol"] == "webhook"
def test_an_inner_binding_does_not_overwrite_the_surface_that_took_the_request():
result = run_probe(NESTED_PROTOCOL_PROBE)
assert result["inner"] == "voice", "the inner generic driver overwrote the real surface"
assert len(result["spans"]) == 1
assert result["spans"][0]["attrs"]["protocol"] == "voice"
# Reset on exit, so a worker reusing this task for the next request starts unbound.
assert result["after"] is None
def test_a_flow_a_user_stopped_is_not_recorded_as_a_successful_one():
result = run_probe(CANCELLED_PROBE)
assert result["raised"] is True
assert len(result["spans"]) == 1
span = result["spans"][0]
assert span["attrs"]["status"] == "cancelled"
# A withdrawn request is not a service fault, so it must not land on the error rate.
assert span["status"] == "UNSET"
assert "error.type" not in span["attrs"]
def test_a_flow_killed_by_a_timeout_is_recorded_as_an_error():
"""The client is served an error and the job row says FAILED, so the span must agree."""
result = run_probe(ABORTED_PROBE)
assert result["timed_out"] is True
assert len(result["spans"]) == 1
span = result["spans"][0]
assert span["attrs"]["status"] == "aborted"
assert span["status"] == "ERROR"
assert span["attrs"]["error.type"] == "CancelledError"