Skip to content

Commit 082e38c

Browse files
fix: shutdown trace manager, not the executor, between jobs (#1800)
1 parent e1ceda5 commit 082e38c

11 files changed

Lines changed: 337 additions & 117 deletions

File tree

‎packages/uipath/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.13.6"
3+
version = "2.13.7"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

‎packages/uipath/src/uipath/_cli/cli_debug.py‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ async def execute_debug_runtime():
138138
with ExecutionSourceContext(ctx.execution_source), ctx:
139139
factory: UiPathRuntimeFactoryProtocol | None = None
140140
governance_bootstrap: GovernanceBootstrap | None = None
141-
live_tracking_processor: LiveTrackingSpanProcessor | None = None
142141

143142
try:
144143
trigger_poll_interval: float = 5.0
@@ -169,12 +168,11 @@ async def execute_debug_runtime():
169168

170169
if ctx.job_id:
171170
if UiPathConfig.is_tracing_enabled:
172-
live_tracking_processor = LiveTrackingSpanProcessor(
173-
LlmOpsHttpExporter(),
174-
settings=trace_settings,
175-
)
176171
trace_manager.add_span_processor(
177-
live_tracking_processor
172+
LiveTrackingSpanProcessor(
173+
LlmOpsHttpExporter(),
174+
settings=trace_settings,
175+
)
178176
)
179177
trigger_poll_interval = (
180178
0.0 # Polling disabled for production jobs
@@ -260,15 +258,13 @@ async def execute_debug_runtime():
260258
await execute_debug_runtime()
261259

262260
finally:
263-
# Drain runtime-scoped sinks before the factory
264-
# shuts down — the factory may own transports they
265-
# use. (The inner runtime already disposed above.)
266-
if live_tracking_processor is not None:
267-
live_tracking_processor.shutdown()
268-
if governance_bootstrap is not None:
269-
governance_bootstrap.dispose()
270-
if factory:
271-
await factory.dispose()
261+
try:
262+
if governance_bootstrap is not None:
263+
governance_bootstrap.dispose()
264+
if factory:
265+
await factory.dispose()
266+
finally:
267+
trace_manager.shutdown()
272268

273269
asyncio.run(execute_debug_runtime())
274270
except Exception as e:

‎packages/uipath/src/uipath/_cli/cli_run.py‎

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ async def execute() -> None:
221221
chat_runtime: UiPathRuntimeProtocol | None = None
222222
factory: UiPathRuntimeFactoryProtocol | None = None
223223
governance_bootstrap: GovernanceBootstrap | None = None
224-
live_tracking_processor: LiveTrackingSpanProcessor | None = None
225224
try:
226225
factory = UiPathRuntimeFactoryRegistry.get(context=ctx)
227226

@@ -296,12 +295,11 @@ async def execute() -> None:
296295

297296
if ctx.job_id:
298297
if UiPathConfig.is_tracing_enabled:
299-
live_tracking_processor = LiveTrackingSpanProcessor(
300-
LlmOpsHttpExporter(),
301-
settings=trace_settings,
302-
)
303298
trace_manager.add_span_processor(
304-
live_tracking_processor
299+
LiveTrackingSpanProcessor(
300+
LlmOpsHttpExporter(),
301+
settings=trace_settings,
302+
)
305303
)
306304

307305
if ctx.conversation_id and ctx.exchange_id:
@@ -318,18 +316,19 @@ async def execute() -> None:
318316
else:
319317
ctx.result = await debug_runtime(ctx, runtime)
320318
finally:
321-
if chat_runtime:
322-
await chat_runtime.dispose()
323-
if runtime is not None and runtime is not base_runtime:
324-
await runtime.dispose()
325-
if base_runtime is not None:
326-
await base_runtime.dispose()
327-
if live_tracking_processor is not None:
328-
live_tracking_processor.shutdown()
329-
if governance_bootstrap is not None:
330-
governance_bootstrap.dispose()
331-
if factory:
332-
await factory.dispose()
319+
try:
320+
if chat_runtime:
321+
await chat_runtime.dispose()
322+
if runtime is not None and runtime is not base_runtime:
323+
await runtime.dispose()
324+
if base_runtime is not None:
325+
await base_runtime.dispose()
326+
if governance_bootstrap is not None:
327+
governance_bootstrap.dispose()
328+
if factory:
329+
await factory.dispose()
330+
finally:
331+
trace_manager.shutdown()
333332

334333
asyncio.run(execute())
335334

‎packages/uipath/src/uipath/tracing/_live_tracking_processor.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ def shutdown(self) -> None:
8989
self.executor.shutdown(wait=True)
9090
except Exception as e:
9191
logger.debug(f"Executor shutdown failed: {e}")
92+
try:
93+
self.exporter.shutdown()
94+
except Exception as e:
95+
logger.debug(f"Exporter shutdown failed: {e}")
9296

9397
def force_flush(self, timeout_millis: int = 30000) -> bool:
9498
"""Force flush - no-op for live tracking."""

‎packages/uipath/src/uipath/tracing/_otel_exporters.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
176176
"""Force flush the exporter."""
177177
return True
178178

179+
def shutdown(self) -> None:
180+
"""Close the HTTP client."""
181+
self.http_client.close()
182+
179183
def upsert_span(
180184
self,
181185
span: ReadableSpan,

‎packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import threading
44
import time
55
from typing import Any
6-
from unittest.mock import Mock
6+
from unittest.mock import Mock, patch
77

88
import pytest
99
from opentelemetry import context as context_api
@@ -348,6 +348,35 @@ def test_shutdown_waits_for_pending_tasks(self, mock_exporter):
348348
# Verify executor is shutdown (calling shutdown multiple times should be safe)
349349
processor.shutdown() # Should not raise
350350

351+
def test_shutdown_closes_exporter_after_pending_tasks(self, mock_exporter):
352+
calls = []
353+
354+
def upsert(*args, **kwargs):
355+
calls.append("upsert")
356+
357+
def shutdown():
358+
calls.append("shutdown")
359+
360+
mock_exporter.upsert_span = Mock(side_effect=upsert)
361+
mock_exporter.shutdown = Mock(side_effect=shutdown)
362+
processor = LiveTrackingSpanProcessor(mock_exporter, max_workers=1)
363+
span = self.create_mock_span({"span_type": "eval"})
364+
365+
processor.on_start(span, None)
366+
processor.shutdown()
367+
368+
assert calls == ["upsert", "shutdown"]
369+
370+
def test_shutdown_logs_exporter_shutdown_failure(self, mock_exporter):
371+
mock_exporter.shutdown = Mock(side_effect=RuntimeError("close failed"))
372+
processor = LiveTrackingSpanProcessor(mock_exporter)
373+
374+
with patch("uipath.tracing._live_tracking_processor.logger.debug") as debug:
375+
processor.shutdown()
376+
377+
mock_exporter.shutdown.assert_called_once_with()
378+
debug.assert_called_once_with("Exporter shutdown failed: close failed")
379+
351380
def test_multiple_processors_independent_thread_pools(self, mock_exporter):
352381
"""Test that multiple processors have independent thread pools."""
353382
processor1 = LiveTrackingSpanProcessor(mock_exporter, max_workers=5)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import asyncio
2+
import json
3+
import threading
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
from pathlib import Path
6+
from typing import Any, Generator, cast
7+
8+
import pytest
9+
10+
from tests.cli.utils.server import (
11+
get_free_port,
12+
start_cli_server_thread,
13+
start_job_with_env,
14+
)
15+
16+
17+
class TraceServer(ThreadingHTTPServer):
18+
posts: list[bytes]
19+
20+
21+
class TraceHandler(BaseHTTPRequestHandler):
22+
def do_POST(self) -> None:
23+
length = int(self.headers.get("content-length", "0"))
24+
body = self.rfile.read(length)
25+
cast(TraceServer, self.server).posts.append(body)
26+
self.send_response(200)
27+
self.end_headers()
28+
self.wfile.write(b"OK")
29+
30+
def log_message(self, format: str, *args: object) -> None:
31+
return
32+
33+
34+
@pytest.fixture
35+
def trace_stub() -> Generator[tuple[TraceServer, str], None, None]:
36+
port = get_free_port()
37+
server = TraceServer(("127.0.0.1", port), TraceHandler)
38+
server.posts = []
39+
thread = threading.Thread(target=server.serve_forever, daemon=True)
40+
thread.start()
41+
42+
try:
43+
yield server, f"http://127.0.0.1:{port}"
44+
finally:
45+
server.shutdown()
46+
server.server_close()
47+
48+
49+
def write_project(project: Path) -> None:
50+
(project / "entrypoint.py").write_text(
51+
"""from dataclasses import dataclass
52+
from uipath.tracing import traced
53+
54+
55+
@dataclass
56+
class Input:
57+
message: str
58+
59+
60+
@dataclass
61+
class Output:
62+
message: str
63+
64+
65+
@traced(name="actual-agent-span")
66+
def main(input: Input) -> Output:
67+
return Output(message=f"ok: {input.message}")
68+
""",
69+
encoding="utf-8",
70+
)
71+
(project / "uipath.json").write_text(
72+
json.dumps({"agents": {"main": "entrypoint.py:main"}}, indent=2),
73+
encoding="utf-8",
74+
)
75+
76+
77+
async def run_traced_job(
78+
port: int,
79+
project: Path,
80+
trace_url: str,
81+
job_key: str,
82+
command: str,
83+
extra_args: list[str],
84+
) -> tuple[dict[str, Any], Path]:
85+
input_file = project / f"{job_key}-input.json"
86+
output_file = project / f"{job_key}-output.json"
87+
input_file.write_text(json.dumps({"message": job_key}), encoding="utf-8")
88+
89+
response = await start_job_with_env(
90+
port,
91+
job_key,
92+
command,
93+
[
94+
"main",
95+
"--input-file",
96+
str(input_file),
97+
"--output-file",
98+
str(output_file),
99+
*extra_args,
100+
],
101+
{
102+
"UIPATH_ACCESS_TOKEN": "fake-token",
103+
"UIPATH_JOB_KEY": job_key,
104+
"UIPATH_ORGANIZATION_ID": "org-123",
105+
"UIPATH_TENANT_ID": "tenant-123",
106+
"UIPATH_TRACE_BASE_URL": trace_url,
107+
"UIPATH_TRACING_ENABLED": "true",
108+
"LOG_LEVEL": "DEBUG",
109+
},
110+
working_directory=str(project),
111+
)
112+
return response, output_file
113+
114+
115+
def read_json(path: Path) -> Any:
116+
return json.loads(path.read_text(encoding="utf-8"))
117+
118+
119+
@pytest.mark.parametrize(
120+
("command", "extra_args"),
121+
[
122+
("run", []),
123+
("debug", ["--attach", "none"]),
124+
],
125+
)
126+
def test_server_runs_two_traced_jobs_after_trace_shutdown(
127+
tmp_path: Path,
128+
trace_stub: tuple[TraceServer, str],
129+
command: str,
130+
extra_args: list[str],
131+
) -> None:
132+
port = get_free_port()
133+
start_cli_server_thread(port)
134+
135+
trace_server, trace_url = trace_stub
136+
project = tmp_path / "project"
137+
project.mkdir()
138+
write_project(project)
139+
140+
job1_response, job1_output = asyncio.run(
141+
run_traced_job(port, project, trace_url, "job-1", command, extra_args)
142+
)
143+
job2_response, job2_output = asyncio.run(
144+
run_traced_job(port, project, trace_url, "job-2", command, extra_args)
145+
)
146+
147+
assert job1_response["success"] is True, job1_response
148+
assert job2_response["success"] is True, job2_response
149+
assert read_json(job1_output) == {"message": "ok: job-1"}
150+
assert read_json(job2_output) == {"message": "ok: job-2"}
151+
152+
result = read_json(project / "__uipath" / "output.json")
153+
assert result["status"] == "successful"
154+
assert "error" not in result
155+
assert trace_server.posts

0 commit comments

Comments
 (0)