uipath 2.1.60__py3-none-any.whl → 2.1.61__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uipath/_cli/_evals/_models/_output.py +22 -9
- uipath/_cli/_evals/_models/_sw_reporting.py +21 -0
- uipath/_cli/_evals/_progress_reporter.py +436 -0
- uipath/_cli/_evals/_runtime.py +103 -10
- uipath/_cli/_runtime/_contracts.py +11 -4
- uipath/_cli/_utils/_folders.py +30 -24
- uipath/_cli/cli_eval.py +28 -6
- uipath/_cli/cli_invoke.py +5 -2
- uipath/_cli/cli_publish.py +4 -3
- uipath/_events/__init__.py +0 -0
- uipath/_events/_event_bus.py +157 -0
- uipath/_events/_events.py +53 -0
- uipath/eval/models/models.py +1 -1
- uipath/tracing/_otel_exporters.py +95 -91
- uipath/tracing/_traced.py +16 -0
- uipath/tracing/_utils.py +9 -2
- {uipath-2.1.60.dist-info → uipath-2.1.61.dist-info}/METADATA +1 -1
- {uipath-2.1.60.dist-info → uipath-2.1.61.dist-info}/RECORD +21 -16
- {uipath-2.1.60.dist-info → uipath-2.1.61.dist-info}/WHEEL +0 -0
- {uipath-2.1.60.dist-info → uipath-2.1.61.dist-info}/entry_points.txt +0 -0
- {uipath-2.1.60.dist-info → uipath-2.1.61.dist-info}/licenses/LICENSE +0 -0
@@ -753,17 +753,24 @@ class UiPathRuntimeFactory(Generic[T, C]):
|
|
753
753
|
span_processor.force_flush()
|
754
754
|
|
755
755
|
async def execute_in_root_span(
|
756
|
-
self,
|
756
|
+
self,
|
757
|
+
context: C,
|
758
|
+
root_span: str = "root",
|
759
|
+
attributes: Optional[dict[str, str]] = None,
|
757
760
|
) -> Optional[UiPathRuntimeResult]:
|
758
761
|
"""Execute runtime with context."""
|
759
762
|
async with self.from_context(context) as runtime:
|
760
763
|
try:
|
761
764
|
tracer: Tracer = trace.get_tracer("uipath-runtime")
|
765
|
+
span_attributes = {}
|
766
|
+
if context.execution_id:
|
767
|
+
span_attributes["execution.id"] = context.execution_id
|
768
|
+
if attributes:
|
769
|
+
span_attributes.update(attributes)
|
770
|
+
|
762
771
|
with tracer.start_as_current_span(
|
763
772
|
root_span,
|
764
|
-
attributes=
|
765
|
-
if context.execution_id
|
766
|
-
else {},
|
773
|
+
attributes=span_attributes,
|
767
774
|
):
|
768
775
|
return await runtime.execute()
|
769
776
|
finally:
|
uipath/_cli/_utils/_folders.py
CHANGED
@@ -1,35 +1,41 @@
|
|
1
|
-
from typing import Optional, Tuple
|
1
|
+
from typing import Any, Optional, Tuple
|
2
2
|
|
3
|
-
import httpx
|
4
|
-
|
5
|
-
from ..._utils._ssl_context import get_httpx_client_kwargs
|
6
3
|
from ._console import ConsoleLogger
|
7
4
|
|
8
5
|
console = ConsoleLogger()
|
9
6
|
|
10
7
|
|
11
|
-
def
|
12
|
-
|
13
|
-
|
14
|
-
|
8
|
+
async def get_personal_workspace_info_async() -> Tuple[Optional[str], Optional[str]]:
|
9
|
+
response = await _get_personal_workspace_info_internal_async()
|
10
|
+
feed_id = response.get("PersonalWorskpaceFeedId")
|
11
|
+
personal_workspace = response.get("PersonalWorkspace")
|
12
|
+
|
13
|
+
if not personal_workspace or not feed_id or "Id" not in personal_workspace:
|
14
|
+
return None, None
|
15
|
+
|
16
|
+
folder_id = personal_workspace.get("Id")
|
17
|
+
return feed_id, folder_id
|
18
|
+
|
19
|
+
|
20
|
+
async def get_personal_workspace_key_async() -> Optional[str]:
|
21
|
+
response = await _get_personal_workspace_info_internal_async()
|
22
|
+
personal_workspace = response.get("PersonalWorkspace")
|
23
|
+
if not personal_workspace or "Key" not in personal_workspace:
|
24
|
+
return None
|
25
|
+
return personal_workspace["Key"]
|
26
|
+
|
15
27
|
|
16
|
-
|
17
|
-
|
18
|
-
user_url, headers={"Authorization": f"Bearer {token}"}
|
19
|
-
)
|
28
|
+
async def _get_personal_workspace_info_internal_async() -> dict[str, Any]:
|
29
|
+
from ... import UiPath
|
20
30
|
|
21
|
-
|
22
|
-
console.error(
|
23
|
-
"Error: Failed to fetch user info. Please try reauthenticating."
|
24
|
-
)
|
25
|
-
return None, None
|
31
|
+
uipath = UiPath()
|
26
32
|
|
27
|
-
|
28
|
-
|
29
|
-
|
33
|
+
response = await uipath.api_client.request_async(
|
34
|
+
method="GET",
|
35
|
+
url="/orchestrator_/odata/Users/UiPath.Server.Configuration.OData.GetCurrentUserExtended?$expand=PersonalWorkspace",
|
36
|
+
)
|
30
37
|
|
31
|
-
|
32
|
-
|
38
|
+
if response.status_code != 200:
|
39
|
+
console.error("Error: Failed to fetch user info. Please try reauthenticating.")
|
33
40
|
|
34
|
-
|
35
|
-
return feed_id, folder_id
|
41
|
+
return response.json()
|
uipath/_cli/cli_eval.py
CHANGED
@@ -2,17 +2,24 @@
|
|
2
2
|
import ast
|
3
3
|
import asyncio
|
4
4
|
import os
|
5
|
+
import uuid
|
5
6
|
from typing import List, Optional
|
6
7
|
|
7
8
|
import click
|
8
9
|
|
9
|
-
from uipath._cli._evals.
|
10
|
+
from uipath._cli._evals._progress_reporter import StudioWebProgressReporter
|
11
|
+
from uipath._cli._evals._runtime import (
|
12
|
+
UiPathEvalContext,
|
13
|
+
UiPathEvalRuntime,
|
14
|
+
)
|
10
15
|
from uipath._cli._runtime._contracts import (
|
11
16
|
UiPathRuntimeContext,
|
12
17
|
UiPathRuntimeFactory,
|
13
18
|
)
|
14
19
|
from uipath._cli._runtime._runtime import UiPathScriptRuntime
|
20
|
+
from uipath._cli._utils._folders import get_personal_workspace_key_async
|
15
21
|
from uipath._cli.middlewares import Middlewares
|
22
|
+
from uipath._events._event_bus import EventBus
|
16
23
|
from uipath.eval._helpers import auto_discover_entrypoint
|
17
24
|
from uipath.tracing import LlmOpsHttpExporter
|
18
25
|
|
@@ -72,6 +79,11 @@ def eval(
|
|
72
79
|
workers: Number of parallel workers for running evaluations
|
73
80
|
no_report: Do not report the evaluation results
|
74
81
|
"""
|
82
|
+
if not no_report and not os.getenv("UIPATH_FOLDER_KEY"):
|
83
|
+
os.environ["UIPATH_FOLDER_KEY"] = asyncio.run(
|
84
|
+
get_personal_workspace_key_async()
|
85
|
+
)
|
86
|
+
|
75
87
|
result = Middlewares.next(
|
76
88
|
"eval",
|
77
89
|
entrypoint,
|
@@ -86,14 +98,23 @@ def eval(
|
|
86
98
|
console.error(result.error_message)
|
87
99
|
|
88
100
|
if result.should_continue:
|
101
|
+
event_bus = EventBus()
|
102
|
+
|
103
|
+
if not no_report:
|
104
|
+
progress_reporter = StudioWebProgressReporter()
|
105
|
+
asyncio.run(progress_reporter.subscribe_to_eval_runtime_events(event_bus))
|
89
106
|
|
90
107
|
def generate_runtime_context(**context_kwargs) -> UiPathRuntimeContext:
|
91
108
|
runtime_context = UiPathRuntimeContext.with_defaults(**context_kwargs)
|
92
109
|
runtime_context.entrypoint = runtime_entrypoint
|
93
110
|
return runtime_context
|
94
111
|
|
112
|
+
runtime_entrypoint = entrypoint or auto_discover_entrypoint()
|
113
|
+
|
95
114
|
eval_context = UiPathEvalContext.with_defaults(
|
96
|
-
execution_output_file=output_file
|
115
|
+
execution_output_file=output_file,
|
116
|
+
entrypoint=runtime_entrypoint,
|
117
|
+
execution_id=str(uuid.uuid4()),
|
97
118
|
)
|
98
119
|
|
99
120
|
eval_context.no_report = no_report
|
@@ -101,22 +122,23 @@ def eval(
|
|
101
122
|
eval_context.eval_set = eval_set or EvalHelpers.auto_discover_eval_set()
|
102
123
|
eval_context.eval_ids = eval_ids
|
103
124
|
|
104
|
-
runtime_entrypoint = entrypoint or auto_discover_entrypoint()
|
105
|
-
|
106
125
|
try:
|
107
126
|
runtime_factory = UiPathRuntimeFactory(
|
108
127
|
UiPathScriptRuntime,
|
109
128
|
UiPathRuntimeContext,
|
110
129
|
context_generator=generate_runtime_context,
|
111
130
|
)
|
112
|
-
if
|
131
|
+
if eval_context.job_id:
|
113
132
|
runtime_factory.add_span_exporter(LlmOpsHttpExporter())
|
114
133
|
|
115
134
|
async def execute():
|
116
135
|
async with UiPathEvalRuntime.from_eval_context(
|
117
|
-
factory=runtime_factory,
|
136
|
+
factory=runtime_factory,
|
137
|
+
context=eval_context,
|
138
|
+
event_bus=event_bus,
|
118
139
|
) as eval_runtime:
|
119
140
|
await eval_runtime.execute()
|
141
|
+
await event_bus.wait_for_all(timeout=10)
|
120
142
|
|
121
143
|
asyncio.run(execute())
|
122
144
|
except Exception as e:
|
uipath/_cli/cli_invoke.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
# type: ignore
|
2
|
+
import asyncio
|
2
3
|
import logging
|
3
4
|
import os
|
4
5
|
from typing import Optional
|
@@ -16,7 +17,7 @@ except ImportError:
|
|
16
17
|
from .._utils._ssl_context import get_httpx_client_kwargs
|
17
18
|
from ..telemetry import track
|
18
19
|
from ._utils._common import get_env_vars
|
19
|
-
from ._utils._folders import
|
20
|
+
from ._utils._folders import get_personal_workspace_info_async
|
20
21
|
from ._utils._processes import get_release_info
|
21
22
|
from .middlewares import Middlewares
|
22
23
|
|
@@ -65,7 +66,9 @@ def invoke(
|
|
65
66
|
[base_url, token] = get_env_vars()
|
66
67
|
|
67
68
|
url = f"{base_url}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs"
|
68
|
-
_, personal_workspace_folder_id =
|
69
|
+
_, personal_workspace_folder_id = asyncio.run(
|
70
|
+
get_personal_workspace_info_async()
|
71
|
+
)
|
69
72
|
project_name, project_version = _read_project_details()
|
70
73
|
if not personal_workspace_folder_id:
|
71
74
|
console.error(
|
uipath/_cli/cli_publish.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
# type: ignore
|
2
|
+
import asyncio
|
2
3
|
import json
|
3
4
|
import os
|
4
5
|
|
@@ -9,7 +10,7 @@ from .._utils._ssl_context import get_httpx_client_kwargs
|
|
9
10
|
from ..telemetry import track
|
10
11
|
from ._utils._common import get_env_vars
|
11
12
|
from ._utils._console import ConsoleLogger
|
12
|
-
from ._utils._folders import
|
13
|
+
from ._utils._folders import get_personal_workspace_info_async
|
13
14
|
from ._utils._processes import get_release_info
|
14
15
|
|
15
16
|
console = ConsoleLogger()
|
@@ -106,8 +107,8 @@ def publish(feed):
|
|
106
107
|
|
107
108
|
if feed and feed != "tenant":
|
108
109
|
# Check user personal workspace
|
109
|
-
personal_workspace_feed_id, personal_workspace_folder_id = (
|
110
|
-
|
110
|
+
personal_workspace_feed_id, personal_workspace_folder_id = asyncio.run(
|
111
|
+
get_personal_workspace_info_async()
|
111
112
|
)
|
112
113
|
if feed == "personal" or feed == personal_workspace_feed_id:
|
113
114
|
is_personal_workspace = True
|
File without changes
|
@@ -0,0 +1,157 @@
|
|
1
|
+
"""Event bus implementation for evaluation events."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar
|
6
|
+
|
7
|
+
logger = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
T = TypeVar("T")
|
10
|
+
|
11
|
+
|
12
|
+
class EventBus:
|
13
|
+
"""Event bus for publishing and subscribing to events."""
|
14
|
+
|
15
|
+
def __init__(self) -> None:
|
16
|
+
"""Initialize a new EventBus instance."""
|
17
|
+
self._subscribers: Dict[str, List[Callable[[Any], Any]]] = {}
|
18
|
+
self._running_tasks: Set[asyncio.Task[Any]] = set()
|
19
|
+
|
20
|
+
def subscribe(self, topic: str, handler: Callable[[Any], Any]) -> None:
|
21
|
+
"""Subscribe a handler method/function to a topic.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
topic: The topic name to subscribe to.
|
25
|
+
handler: The async handler method/function that will handle events for this topic.
|
26
|
+
"""
|
27
|
+
if topic not in self._subscribers:
|
28
|
+
self._subscribers[topic] = []
|
29
|
+
self._subscribers[topic].append(handler)
|
30
|
+
logger.debug(f"Handler registered for topic: {topic}")
|
31
|
+
|
32
|
+
def unsubscribe(self, topic: str, handler: Callable[[Any], Any]) -> None:
|
33
|
+
"""Unsubscribe a handler from a topic.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
topic: The topic name to unsubscribe from.
|
37
|
+
handler: The handler to remove.
|
38
|
+
"""
|
39
|
+
if topic in self._subscribers:
|
40
|
+
try:
|
41
|
+
self._subscribers[topic].remove(handler)
|
42
|
+
if not self._subscribers[topic]:
|
43
|
+
del self._subscribers[topic]
|
44
|
+
logger.debug(f"Handler unregistered from topic: {topic}")
|
45
|
+
except ValueError:
|
46
|
+
logger.warning(f"Handler not found for topic: {topic}")
|
47
|
+
|
48
|
+
def _cleanup_completed_tasks(self) -> None:
|
49
|
+
completed_tasks = {task for task in self._running_tasks if task.done()}
|
50
|
+
self._running_tasks -= completed_tasks
|
51
|
+
|
52
|
+
async def publish(
|
53
|
+
self, topic: str, payload: T, wait_for_completion: bool = True
|
54
|
+
) -> None:
|
55
|
+
"""Publish an event to all handlers of a topic.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
topic: The topic name to publish to.
|
59
|
+
payload: The event payload to publish.
|
60
|
+
wait_for_completion: Whether to wait for the event to be processed.
|
61
|
+
"""
|
62
|
+
if topic not in self._subscribers:
|
63
|
+
logger.debug(f"No handlers for topic: {topic}")
|
64
|
+
return
|
65
|
+
|
66
|
+
self._cleanup_completed_tasks()
|
67
|
+
|
68
|
+
tasks = []
|
69
|
+
for subscriber in self._subscribers[topic]:
|
70
|
+
try:
|
71
|
+
task = asyncio.create_task(subscriber(payload))
|
72
|
+
tasks.append(task)
|
73
|
+
self._running_tasks.add(task)
|
74
|
+
except Exception as e:
|
75
|
+
logger.error(f"Error creating task for subscriber {subscriber}: {e}")
|
76
|
+
|
77
|
+
if tasks and wait_for_completion:
|
78
|
+
try:
|
79
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
80
|
+
except Exception as e:
|
81
|
+
logger.error(f"Error during event processing for topic {topic}: {e}")
|
82
|
+
finally:
|
83
|
+
# Clean up the tasks we just waited for
|
84
|
+
for task in tasks:
|
85
|
+
self._running_tasks.discard(task)
|
86
|
+
|
87
|
+
def get_running_tasks_count(self) -> int:
|
88
|
+
"""Get the number of currently running subscriber tasks.
|
89
|
+
|
90
|
+
Returns:
|
91
|
+
Number of running tasks.
|
92
|
+
"""
|
93
|
+
self._cleanup_completed_tasks()
|
94
|
+
return len(self._running_tasks)
|
95
|
+
|
96
|
+
async def wait_for_all(self, timeout: Optional[float] = None) -> None:
|
97
|
+
"""Wait for all currently running subscriber tasks to complete.
|
98
|
+
|
99
|
+
Args:
|
100
|
+
timeout: Maximum time to wait in seconds. If None, waits indefinitely.
|
101
|
+
"""
|
102
|
+
self._cleanup_completed_tasks()
|
103
|
+
|
104
|
+
if not self._running_tasks:
|
105
|
+
logger.debug("No running tasks to wait for")
|
106
|
+
return
|
107
|
+
|
108
|
+
logger.debug(
|
109
|
+
f"Waiting for {len(self._running_tasks)} EventBus tasks to complete..."
|
110
|
+
)
|
111
|
+
|
112
|
+
try:
|
113
|
+
tasks_to_wait = list(self._running_tasks)
|
114
|
+
|
115
|
+
if timeout:
|
116
|
+
await asyncio.wait_for(
|
117
|
+
asyncio.gather(*tasks_to_wait, return_exceptions=True),
|
118
|
+
timeout=timeout,
|
119
|
+
)
|
120
|
+
else:
|
121
|
+
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
122
|
+
|
123
|
+
logger.debug("All EventBus tasks completed")
|
124
|
+
|
125
|
+
except asyncio.TimeoutError:
|
126
|
+
logger.warning(f"Timeout waiting for EventBus tasks after {timeout}s")
|
127
|
+
for task in tasks_to_wait:
|
128
|
+
if not task.done():
|
129
|
+
task.cancel()
|
130
|
+
except Exception as e:
|
131
|
+
logger.error(f"Error waiting for EventBus tasks: {e}")
|
132
|
+
finally:
|
133
|
+
self._cleanup_completed_tasks()
|
134
|
+
|
135
|
+
def get_subscribers_count(self, topic: str) -> int:
|
136
|
+
"""Get the number of subscribers for a topic.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
topic: The topic name.
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
Number of handlers for the topic.
|
143
|
+
"""
|
144
|
+
return len(self._subscribers.get(topic, []))
|
145
|
+
|
146
|
+
def clear_subscribers(self, topic: Optional[str] = None) -> None:
|
147
|
+
"""Clear subscribers for a topic or all topics.
|
148
|
+
|
149
|
+
Args:
|
150
|
+
topic: The topic to clear. If None, clears all topics.
|
151
|
+
"""
|
152
|
+
if topic is None:
|
153
|
+
self._subscribers.clear()
|
154
|
+
logger.debug("All handlers cleared")
|
155
|
+
elif topic in self._subscribers:
|
156
|
+
del self._subscribers[topic]
|
157
|
+
logger.debug(f"Handlers cleared for topic: {topic}")
|
@@ -0,0 +1,53 @@
|
|
1
|
+
import enum
|
2
|
+
from typing import Any, List, Union
|
3
|
+
|
4
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
6
|
+
|
7
|
+
from uipath._cli._evals._models._evaluation_set import EvaluationItem
|
8
|
+
from uipath.eval.models import EvalItemResult
|
9
|
+
|
10
|
+
|
11
|
+
class EvaluationEvents(str, enum.Enum):
|
12
|
+
CREATE_EVAL_SET_RUN = "create_eval_set_run"
|
13
|
+
CREATE_EVAL_RUN = "create_eval_run"
|
14
|
+
UPDATE_EVAL_SET_RUN = "update_eval_set_run"
|
15
|
+
UPDATE_EVAL_RUN = "update_eval_run"
|
16
|
+
|
17
|
+
|
18
|
+
class EvalSetRunCreatedEvent(BaseModel):
|
19
|
+
execution_id: str
|
20
|
+
entrypoint: str
|
21
|
+
eval_set_id: str
|
22
|
+
no_of_evals: int
|
23
|
+
evaluators: List[Any]
|
24
|
+
|
25
|
+
|
26
|
+
class EvalRunCreatedEvent(BaseModel):
|
27
|
+
execution_id: str
|
28
|
+
eval_item: EvaluationItem
|
29
|
+
|
30
|
+
|
31
|
+
class EvalRunUpdatedEvent(BaseModel):
|
32
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
33
|
+
|
34
|
+
execution_id: str
|
35
|
+
eval_item: EvaluationItem
|
36
|
+
eval_results: List[EvalItemResult]
|
37
|
+
success: bool
|
38
|
+
agent_output: Any
|
39
|
+
agent_execution_time: float
|
40
|
+
spans: List[ReadableSpan]
|
41
|
+
|
42
|
+
|
43
|
+
class EvalSetRunUpdatedEvent(BaseModel):
|
44
|
+
execution_id: str
|
45
|
+
evaluator_scores: dict[str, float]
|
46
|
+
|
47
|
+
|
48
|
+
ProgressEvent = Union[
|
49
|
+
EvalSetRunCreatedEvent,
|
50
|
+
EvalRunCreatedEvent,
|
51
|
+
EvalRunUpdatedEvent,
|
52
|
+
EvalSetRunUpdatedEvent,
|
53
|
+
]
|
uipath/eval/models/models.py
CHANGED
@@ -1,91 +1,95 @@
|
|
1
|
-
import json
|
2
|
-
import logging
|
3
|
-
import os
|
4
|
-
import time
|
5
|
-
from typing import Any, Dict, Sequence
|
6
|
-
|
7
|
-
import httpx
|
8
|
-
from opentelemetry.sdk.trace import ReadableSpan
|
9
|
-
from opentelemetry.sdk.trace.export import (
|
10
|
-
SpanExporter,
|
11
|
-
SpanExportResult,
|
12
|
-
)
|
13
|
-
|
14
|
-
from uipath._utils._ssl_context import get_httpx_client_kwargs
|
15
|
-
|
16
|
-
from ._utils import _SpanUtils
|
17
|
-
|
18
|
-
logger = logging.getLogger(__name__)
|
19
|
-
|
20
|
-
|
21
|
-
class LlmOpsHttpExporter(SpanExporter):
|
22
|
-
"""An OpenTelemetry span exporter that sends spans to UiPath LLM Ops."""
|
23
|
-
|
24
|
-
def __init__(self, **client_kwargs):
|
25
|
-
"""Initialize the exporter with the base URL and authentication token."""
|
26
|
-
super().__init__(**client_kwargs)
|
27
|
-
self.base_url = self._get_base_url()
|
28
|
-
self.auth_token = os.environ.get("UIPATH_ACCESS_TOKEN")
|
29
|
-
self.headers = {
|
30
|
-
"Content-Type": "application/json",
|
31
|
-
"Authorization": f"Bearer {self.auth_token}",
|
32
|
-
}
|
33
|
-
|
34
|
-
client_kwargs = get_httpx_client_kwargs()
|
35
|
-
|
36
|
-
self.http_client = httpx.Client(**client_kwargs, headers=self.headers)
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
return
|
56
|
-
|
57
|
-
def
|
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
|
-
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
import os
|
4
|
+
import time
|
5
|
+
from typing import Any, Dict, Optional, Sequence
|
6
|
+
|
7
|
+
import httpx
|
8
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
9
|
+
from opentelemetry.sdk.trace.export import (
|
10
|
+
SpanExporter,
|
11
|
+
SpanExportResult,
|
12
|
+
)
|
13
|
+
|
14
|
+
from uipath._utils._ssl_context import get_httpx_client_kwargs
|
15
|
+
|
16
|
+
from ._utils import _SpanUtils
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
class LlmOpsHttpExporter(SpanExporter):
|
22
|
+
"""An OpenTelemetry span exporter that sends spans to UiPath LLM Ops."""
|
23
|
+
|
24
|
+
def __init__(self, trace_id: Optional[str] = None, **client_kwargs):
|
25
|
+
"""Initialize the exporter with the base URL and authentication token."""
|
26
|
+
super().__init__(**client_kwargs)
|
27
|
+
self.base_url = self._get_base_url()
|
28
|
+
self.auth_token = os.environ.get("UIPATH_ACCESS_TOKEN")
|
29
|
+
self.headers = {
|
30
|
+
"Content-Type": "application/json",
|
31
|
+
"Authorization": f"Bearer {self.auth_token}",
|
32
|
+
}
|
33
|
+
|
34
|
+
client_kwargs = get_httpx_client_kwargs()
|
35
|
+
|
36
|
+
self.http_client = httpx.Client(**client_kwargs, headers=self.headers)
|
37
|
+
self.trace_id = trace_id
|
38
|
+
|
39
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
40
|
+
"""Export spans to UiPath LLM Ops."""
|
41
|
+
logger.debug(
|
42
|
+
f"Exporting {len(spans)} spans to {self.base_url}/llmopstenant_/api/Traces/spans"
|
43
|
+
)
|
44
|
+
|
45
|
+
span_list = [
|
46
|
+
_SpanUtils.otel_span_to_uipath_span(
|
47
|
+
span, custom_trace_id=self.trace_id
|
48
|
+
).to_dict()
|
49
|
+
for span in spans
|
50
|
+
]
|
51
|
+
url = self._build_url(span_list)
|
52
|
+
|
53
|
+
logger.debug("Payload: %s", json.dumps(span_list))
|
54
|
+
|
55
|
+
return self._send_with_retries(url, span_list)
|
56
|
+
|
57
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
58
|
+
"""Force flush the exporter."""
|
59
|
+
return True
|
60
|
+
|
61
|
+
def _build_url(self, span_list: list[Dict[str, Any]]) -> str:
|
62
|
+
"""Construct the URL for the API request."""
|
63
|
+
trace_id = str(span_list[0]["TraceId"])
|
64
|
+
return f"{self.base_url}/llmopstenant_/api/Traces/spans?traceId={trace_id}&source=Robots"
|
65
|
+
|
66
|
+
def _send_with_retries(
|
67
|
+
self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4
|
68
|
+
) -> SpanExportResult:
|
69
|
+
"""Send the HTTP request with retry logic."""
|
70
|
+
for attempt in range(max_retries):
|
71
|
+
try:
|
72
|
+
response = self.http_client.post(url, json=payload)
|
73
|
+
if response.status_code == 200:
|
74
|
+
return SpanExportResult.SUCCESS
|
75
|
+
else:
|
76
|
+
logger.warning(
|
77
|
+
f"Attempt {attempt + 1} failed with status code {response.status_code}: {response.text}"
|
78
|
+
)
|
79
|
+
except Exception as e:
|
80
|
+
logger.error(f"Attempt {attempt + 1} failed with exception: {e}")
|
81
|
+
|
82
|
+
if attempt < max_retries - 1:
|
83
|
+
time.sleep(1.5**attempt) # Exponential backoff
|
84
|
+
|
85
|
+
return SpanExportResult.FAILURE
|
86
|
+
|
87
|
+
def _get_base_url(self) -> str:
|
88
|
+
uipath_url = (
|
89
|
+
os.environ.get("UIPATH_URL")
|
90
|
+
or "https://cloud.uipath.com/dummyOrg/dummyTennant/"
|
91
|
+
)
|
92
|
+
|
93
|
+
uipath_url = uipath_url.rstrip("/")
|
94
|
+
|
95
|
+
return uipath_url
|