From 09263a6cbf8eb763ab387528255f40b715947269 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sun, 2 Nov 2025 15:13:43 +0200 Subject: [PATCH] fix: remove tracing, decouple executor --- pyproject.toml | 5 +- src/uipath/runtime/__init__.py | 5 +- src/uipath/runtime/base.py | 47 +--- src/uipath/runtime/context.py | 4 +- src/uipath/runtime/events/base.py | 1 + src/uipath/runtime/factory.py | 194 ++++++++------- src/uipath/runtime/result.py | 20 +- src/uipath/runtime/schema.py | 74 +----- src/uipath/runtime/tracing/__init__.py | 16 +- src/uipath/runtime/tracing/_utils.py | 291 ----------------------- src/uipath/runtime/tracing/context.py | 23 -- src/uipath/runtime/tracing/decorators.py | 255 -------------------- src/uipath/runtime/tracing/exporters.py | 45 ++++ src/uipath/runtime/tracing/manager.py | 71 ------ src/uipath/runtime/tracing/processors.py | 3 - src/uipath/runtime/tracing/span.py | 73 ------ tests/test_executor.py | 180 ++++++++++++++ tests/test_placeholder.py | 6 - uv.lock | 16 ++ 19 files changed, 371 insertions(+), 958 deletions(-) delete mode 100644 src/uipath/runtime/tracing/_utils.py delete mode 100644 src/uipath/runtime/tracing/context.py delete mode 100644 src/uipath/runtime/tracing/decorators.py create mode 100644 src/uipath/runtime/tracing/exporters.py delete mode 100644 src/uipath/runtime/tracing/manager.py delete mode 100644 src/uipath/runtime/tracing/span.py create mode 100644 tests/test_executor.py delete mode 100644 tests/test_placeholder.py diff --git a/pyproject.toml b/pyproject.toml index 63cc70c..d625d86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.0.1" +version = "0.0.2" description = "UiPath Runtime abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -8,6 +8,7 @@ dependencies = [ "opentelemetry-sdk>=1.38.0", "opentelemetry-instrumentation>=0.59b0", "pydantic>=2.12.3", + "uipath-core>=0.0.1", ] classifiers = [ "Intended Audience :: Developers", @@ -96,7 +97,7 @@ warn_required_dynamic_aliases = true [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" -addopts = "-ra -q --cov" +addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function" asyncio_mode = "auto" diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index deb73ff..fb5b01d 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -3,7 +3,7 @@ from uipath.runtime.base import UiPathBaseRuntime from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.events import UiPathRuntimeEvent -from uipath.runtime.factory import UiPathRuntimeFactory +from uipath.runtime.factory import UiPathRuntimeExecutor, UiPathRuntimeFactory from uipath.runtime.result import ( UiPathApiTrigger, UiPathBreakpointResult, @@ -16,10 +16,11 @@ "UiPathRuntimeContext", "UiPathBaseRuntime", "UiPathRuntimeFactory", + "UiPathRuntimeExecutor", "UiPathRuntimeResult", "UiPathRuntimeEvent", + "UiPathBreakpointResult", "UiPathApiTrigger", "UiPathResumeTrigger", "UiPathResumeTriggerType", - "UiPathBreakpointResult", ] diff --git a/src/uipath/runtime/base.py b/src/uipath/runtime/base.py index 1da54f3..5792c61 100644 --- a/src/uipath/runtime/base.py +++ b/src/uipath/runtime/base.py @@ -4,14 +4,7 @@ import logging import os from abc import ABC, abstractmethod -from typing import ( - AsyncGenerator, - List, - Optional, - Union, -) - -from pydantic import BaseModel +from typing import AsyncGenerator from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.errors import ( @@ -26,8 +19,7 @@ from uipath.runtime.logging._interceptor import UiPathRuntimeLogsInterceptor from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus from uipath.runtime.schema import ( - UiPathRuntimeBindingResource, - UiPathRuntimeEntrypoint, + UiPathRuntimeSchema, ) logger = logging.getLogger(__name__) @@ -49,28 +41,8 @@ def __init__(self, context: UiPathRuntimeContext): """Initialize the runtime with the provided context.""" self.context = context - @classmethod - def from_context(cls, context: UiPathRuntimeContext): - """Factory method to create a runtime instance from a context. - - Args: - context: The runtime context with configuration - - Returns: - An initialized Runtime instance - """ - runtime = cls(context) - return runtime - - async def get_binding_resources(self) -> List[UiPathRuntimeBindingResource]: - """Get binding resources for this runtime. - - Returns: A list of binding resources. - """ - raise NotImplementedError() - - async def get_entrypoint(self) -> UiPathRuntimeEntrypoint: - """Get entrypoint for this runtime. + async def get_schema(self) -> UiPathRuntimeSchema: + """Get schema for this runtime. Returns: A entrypoint for this runtime. """ @@ -130,7 +102,7 @@ async def __aenter__(self): return self @abstractmethod - async def execute(self) -> Optional[UiPathRuntimeResult]: + async def execute(self) -> UiPathRuntimeResult: """Execute with the provided context. Returns: @@ -143,7 +115,7 @@ async def execute(self) -> Optional[UiPathRuntimeResult]: async def stream( self, - ) -> AsyncGenerator[Union[UiPathRuntimeEvent, UiPathRuntimeResult], None]: + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: """Stream execution events in real-time. This is an optional method that runtimes can implement to support streaming. @@ -233,12 +205,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): # Write the execution output to file if requested if self.context.output_file: with open(self.context.output_file, "w") as f: - if isinstance(execution_result.output, BaseModel): - f.write(execution_result.output.model_dump()) - else: - json.dump( - execution_result.output or {}, f, indent=2, default=str - ) + f.write(content.get("output", "{}")) # Don't suppress exceptions return False diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 6b33b01..31c5b96 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -14,9 +14,9 @@ from uuid import uuid4 from pydantic import BaseModel +from uipath.core.tracing.context import UiPathTraceContext -from .result import UiPathRuntimeResult -from .tracing.context import UiPathTraceContext +from uipath.runtime.result import UiPathRuntimeResult C = TypeVar("C", bound="UiPathRuntimeContext") diff --git a/src/uipath/runtime/events/base.py b/src/uipath/runtime/events/base.py index a203e6a..286e1c0 100644 --- a/src/uipath/runtime/events/base.py +++ b/src/uipath/runtime/events/base.py @@ -12,6 +12,7 @@ class UiPathRuntimeEventType(str, Enum): RUNTIME_MESSAGE = "runtime_message" RUNTIME_STATE = "runtime_state" RUNTIME_ERROR = "runtime_error" + RUNTIME_RESULT = "runtime_result" class UiPathRuntimeEvent(BaseModel): diff --git a/src/uipath/runtime/factory.py b/src/uipath/runtime/factory.py index 8164b66..248dbde 100644 --- a/src/uipath/runtime/factory.py +++ b/src/uipath/runtime/factory.py @@ -9,16 +9,14 @@ Optional, Type, TypeVar, - Union, ) from opentelemetry import trace from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore -from opentelemetry.sdk.trace import SpanProcessor, TracerProvider -from opentelemetry.sdk.trace.export import ( - SpanExporter, -) +from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import Tracer +from uipath.core.tracing import UiPathTracingManager from uipath.runtime.base import UiPathBaseRuntime from uipath.runtime.context import UiPathRuntimeContext @@ -27,22 +25,19 @@ from uipath.runtime.tracing import ( UiPathExecutionBatchTraceProcessor, UiPathExecutionSimpleTraceProcessor, - UiPathRuntimeTracingManager, + UiPathRuntimeExecutionSpanExporter, ) T = TypeVar("T", bound="UiPathBaseRuntime") -C = TypeVar("C", bound="UiPathRuntimeContext") -class UiPathRuntimeFactory(Generic[T, C]): +class UiPathRuntimeFactory(Generic[T]): """Generic factory for UiPath runtime classes.""" def __init__( self, runtime_class: Type[T], - context_class: Type[C], - runtime_generator: Optional[Callable[[C], T]] = None, - context_generator: Optional[Callable[..., C]] = None, + runtime_generator: Optional[Callable[[UiPathRuntimeContext], T]] = None, ): """Initialize the UiPathRuntimeFactory.""" if not issubclass(runtime_class, UiPathBaseRuntime): @@ -50,25 +45,37 @@ def __init__( f"runtime_class {runtime_class.__name__} must inherit from UiPathBaseRuntime" ) - if not issubclass(context_class, UiPathRuntimeContext): - raise TypeError( - f"context_class {context_class.__name__} must inherit from UiPathRuntimeContext" - ) - self.runtime_class = runtime_class - self.context_class = context_class self.runtime_generator = runtime_generator - self.context_generator = context_generator + + def new_runtime(self, **kwargs) -> T: + """Create a new runtime instance.""" + context = UiPathRuntimeContext(**kwargs) + return self.from_context(context) + + def from_context(self, context: UiPathRuntimeContext) -> T: + """Create runtime instance from context.""" + if self.runtime_generator: + return self.runtime_generator(context) + return self.runtime_class(context) + + +class UiPathRuntimeExecutor: + """Handles runtime execution with tracing/telemetry.""" + + def __init__(self): + """Initialize the executor.""" self.tracer_provider: TracerProvider = TracerProvider() - self.tracer_span_processors: List[SpanProcessor] = [] - self.logs_exporter: Optional[Any] = None trace.set_tracer_provider(self.tracer_provider) + self.tracer_span_processors: List[SpanProcessor] = [] + self.execution_span_exporter = UiPathRuntimeExecutionSpanExporter() + self.add_span_exporter(self.execution_span_exporter) def add_span_exporter( self, span_exporter: SpanExporter, batch: bool = True, - ) -> "UiPathRuntimeFactory[T, C]": + ) -> "UiPathRuntimeExecutor": """Add a span processor to the tracer provider.""" span_processor: SpanProcessor if batch: @@ -83,48 +90,26 @@ def add_instrumentor( self, instrumentor_class: Type[BaseInstrumentor], get_current_span_func: Callable[[], Any], - ) -> "UiPathRuntimeFactory[T, C]": + ) -> "UiPathRuntimeExecutor": """Add and instrument immediately.""" instrumentor_class().instrument(tracer_provider=self.tracer_provider) - UiPathRuntimeTracingManager.register_current_span_provider( - get_current_span_func - ) + UiPathTracingManager.register_current_span_provider(get_current_span_func) return self - def new_context(self, **kwargs) -> C: - """Create a new context instance.""" - if self.context_generator: - return self.context_generator(**kwargs) - return self.context_class(**kwargs) - - def new_runtime(self, **kwargs) -> T: - """Create a new runtime instance.""" - context = self.new_context(**kwargs) - if self.runtime_generator: - return self.runtime_generator(context) - return self.runtime_class.from_context(context) - - def from_context(self, context: C) -> T: - """Create runtime instance from context.""" - if self.runtime_generator: - return self.runtime_generator(context) - return self.runtime_class.from_context(context) - - async def execute(self, context: C) -> Optional[UiPathRuntimeResult]: + async def execute(self, runtime: UiPathBaseRuntime) -> UiPathRuntimeResult: """Execute runtime with context.""" - async with self.from_context(context) as runtime: - try: - return await runtime.execute() - finally: - for span_processor in self.tracer_span_processors: - span_processor.force_flush() + try: + return await runtime.execute() + finally: + self._flush_spans() async def stream( - self, context: C - ) -> AsyncGenerator[Union[UiPathRuntimeEvent, UiPathRuntimeResult], None]: + self, runtime: UiPathBaseRuntime + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: """Stream runtime execution with context. Args: + runtime: The runtime instance context: The runtime context Yields: @@ -133,48 +118,47 @@ async def stream( Raises: UiPathRuntimeStreamNotSupportedError: If the runtime doesn't support streaming """ - async with self.from_context(context) as runtime: - try: - async for event in runtime.stream(): - yield event - finally: - for span_processor in self.tracer_span_processors: - span_processor.force_flush() + try: + async for event in runtime.stream(): + yield event + finally: + self._flush_spans() async def execute_in_root_span( self, - context: C, + runtime: UiPathBaseRuntime, + execution_id: Optional[str] = None, root_span: str = "root", attributes: Optional[dict[str, str]] = None, - ) -> Optional[UiPathRuntimeResult]: - """Execute runtime with context.""" - async with self.from_context(context) as runtime: - try: - tracer: Tracer = trace.get_tracer("uipath-runtime") - span_attributes = {} - if context.execution_id: - span_attributes["execution.id"] = context.execution_id - if attributes: - span_attributes.update(attributes) - - with tracer.start_as_current_span( - root_span, - attributes=span_attributes, - ): - return await runtime.execute() - finally: - for span_processor in self.tracer_span_processors: - span_processor.force_flush() + ) -> UiPathRuntimeResult: + """Execute runtime with context in a root span.""" + try: + tracer: Tracer = trace.get_tracer("uipath-runtime") + span_attributes = {} + if execution_id: + span_attributes["execution.id"] = execution_id + if attributes: + span_attributes.update(attributes) + + with tracer.start_as_current_span( + root_span, + attributes=span_attributes, + ): + return await runtime.execute() + finally: + self._flush_spans() async def stream_in_root_span( self, - context: C, + runtime: UiPathBaseRuntime, + execution_id: Optional[str] = None, root_span: str = "root", attributes: Optional[dict[str, str]] = None, - ) -> AsyncGenerator[Union[UiPathRuntimeEvent, UiPathRuntimeResult], None]: + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: """Stream runtime execution with context in a root span. Args: + runtime: The runtime instance context: The runtime context root_span: Name of the root span attributes: Optional attributes to add to the span @@ -185,21 +169,31 @@ async def stream_in_root_span( Raises: UiPathRuntimeStreamNotSupportedError: If the runtime doesn't support streaming """ - async with self.from_context(context) as runtime: - try: - tracer: Tracer = trace.get_tracer("uipath-runtime") - span_attributes = {} - if context.execution_id: - span_attributes["execution.id"] = context.execution_id - if attributes: - span_attributes.update(attributes) - - with tracer.start_as_current_span( - root_span, - attributes=span_attributes, - ): - async for event in runtime.stream(): - yield event - finally: - for span_processor in self.tracer_span_processors: - span_processor.force_flush() + try: + tracer: Tracer = trace.get_tracer("uipath-runtime") + span_attributes = {} + if execution_id: + span_attributes["execution.id"] = execution_id + if attributes: + span_attributes.update(attributes) + + with tracer.start_as_current_span( + root_span, + attributes=span_attributes, + ): + async for event in runtime.stream(): + yield event + finally: + self._flush_spans() + + def get_execution_spans( + self, + execution_id: str, + ) -> List[ReadableSpan]: + """Retrieve spans for a given execution id.""" + return self.execution_span_exporter.get_spans(execution_id) + + def _flush_spans(self) -> None: + """Flush all span processors.""" + for span_processor in self.tracer_span_processors: + span_processor.force_flush() diff --git a/src/uipath/runtime/result.py b/src/uipath/runtime/result.py index 7a1af59..3f5f0f6 100644 --- a/src/uipath/runtime/result.py +++ b/src/uipath/runtime/result.py @@ -1,11 +1,12 @@ """Result of an execution with status and optional error information.""" from enum import Enum -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field -from uipath.runtime.errors.contract import UiPathErrorContract +from uipath.runtime.errors import UiPathErrorContract +from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeEventType class UiPathRuntimeStatus(str, Enum): @@ -52,19 +53,26 @@ class UiPathResumeTrigger(BaseModel): model_config = {"populate_by_name": True} -class UiPathRuntimeResult(BaseModel): +class UiPathRuntimeResult(UiPathRuntimeEvent): """Result of an execution with status and optional error information.""" - output: Optional[Dict[str, Any]] = None + output: Optional[Union[Dict[str, Any], BaseModel]] = None status: UiPathRuntimeStatus = UiPathRuntimeStatus.SUCCESSFUL resume: Optional[UiPathResumeTrigger] = None error: Optional[UiPathErrorContract] = None + event_type: UiPathRuntimeEventType = Field( + default=UiPathRuntimeEventType.RUNTIME_RESULT, frozen=True + ) + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary format for output.""" - output_data = self.output or {} - if isinstance(self.output, BaseModel): + if self.output is None: + output_data = {} + elif isinstance(self.output, BaseModel): output_data = self.output.model_dump() + else: + output_data = self.output result = { "output": output_data, diff --git a/src/uipath/runtime/schema.py b/src/uipath/runtime/schema.py index 2f21ec8..897e2c3 100644 --- a/src/uipath/runtime/schema.py +++ b/src/uipath/runtime/schema.py @@ -1,6 +1,6 @@ """UiPath Runtime Schema Definitions.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict from pydantic import BaseModel, ConfigDict, Field @@ -13,8 +13,8 @@ ) -class UiPathRuntimeEntrypoint(BaseModel): - """Represents an entrypoint in the UiPath runtime schema.""" +class UiPathRuntimeSchema(BaseModel): + """Represents the UiPath runtime schema.""" file_path: str = Field(..., alias="filePath") unique_id: str = Field(..., alias="uniqueId") @@ -25,74 +25,6 @@ class UiPathRuntimeEntrypoint(BaseModel): model_config = COMMON_MODEL_SCHEMA -class UiPathRuntimeBindingValue(BaseModel): - """Represents a binding value in the UiPath runtime schema.""" - - default_value: str = Field(..., alias="defaultValue") - is_expression: bool = Field(..., alias="isExpression") - display_name: str = Field(..., alias="displayName") - - model_config = COMMON_MODEL_SCHEMA - - -# TODO: create stronger binding resource definition with discriminator based on resource enum. -class UiPathRuntimeBindingResource(BaseModel): - """Represents a binding resource in the UiPath runtime schema.""" - - resource: str = Field(..., alias="resource") - key: str = Field(..., alias="key") - value: dict[str, UiPathRuntimeBindingValue] = Field(..., alias="value") - metadata: Any = Field(..., alias="metadata") - - model_config = COMMON_MODEL_SCHEMA - - -class UiPathRuntimeBindings(BaseModel): - """Represents the bindings section in the UiPath runtime schema.""" - - version: str = Field(..., alias="version") - resources: List[UiPathRuntimeBindingResource] = Field(..., alias="resources") - - model_config = COMMON_MODEL_SCHEMA - - -class UiPathRuntimeInternalArguments(BaseModel): - """Represents internal runtime arguments in the UiPath runtime schema.""" - - resource_overwrites: dict[str, Any] = Field(..., alias="resourceOverwrites") - - model_config = COMMON_MODEL_SCHEMA - - -class UiPathRuntimeArguments(BaseModel): - """Represents runtime arguments in the UiPath runtime schema.""" - - internal_arguments: Optional[UiPathRuntimeInternalArguments] = Field( - default=None, alias="internalArguments" - ) - - model_config = COMMON_MODEL_SCHEMA - - -class UiPathRuntimeSchema(BaseModel): - """Represents the overall UiPath runtime schema.""" - - runtime: Optional[UiPathRuntimeArguments] = Field(default=None, alias="runtime") - entrypoints: List[UiPathRuntimeEntrypoint] = Field(..., alias="entryPoints") - bindings: UiPathRuntimeBindings = Field( - default=UiPathRuntimeBindings(version="2.0", resources=[]), alias="bindings" - ) - settings: Optional[Dict[str, Any]] = Field(default=None, alias="setting") - - model_config = COMMON_MODEL_SCHEMA - - __all__ = [ "UiPathRuntimeSchema", - "UiPathRuntimeEntrypoint", - "UiPathRuntimeBindings", - "UiPathRuntimeBindingResource", - "UiPathRuntimeBindingValue", - "UiPathRuntimeArguments", - "UiPathRuntimeInternalArguments", ] diff --git a/src/uipath/runtime/tracing/__init__.py b/src/uipath/runtime/tracing/__init__.py index 156f6db..2cd4788 100644 --- a/src/uipath/runtime/tracing/__init__.py +++ b/src/uipath/runtime/tracing/__init__.py @@ -4,24 +4,14 @@ with OpenTelemetry tracing, including custom processors for UiPath execution tracking. """ -from ._utils import ( - otel_span_to_uipath_span, -) -from .context import UiPathTraceContext -from .decorators import traced -from .manager import UiPathRuntimeTracingManager -from .processors import ( +from uipath.runtime.tracing.exporters import UiPathRuntimeExecutionSpanExporter +from uipath.runtime.tracing.processors import ( UiPathExecutionBatchTraceProcessor, UiPathExecutionSimpleTraceProcessor, ) -from .span import UiPathRuntimeSpan __all__ = [ - "traced", - "UiPathTraceContext", - "UiPathRuntimeSpan", - "UiPathRuntimeTracingManager", "UiPathExecutionBatchTraceProcessor", "UiPathExecutionSimpleTraceProcessor", - "otel_span_to_uipath_span", + "UiPathRuntimeExecutionSpanExporter", ] diff --git a/src/uipath/runtime/tracing/_utils.py b/src/uipath/runtime/tracing/_utils.py deleted file mode 100644 index 90b45a1..0000000 --- a/src/uipath/runtime/tracing/_utils.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Helper utilities for the tracing module.""" - -import inspect -import json -import os -import random -import uuid -from collections.abc import Callable -from dataclasses import asdict, is_dataclass -from datetime import datetime, timezone -from enum import Enum -from os import environ as env -from typing import Any, Dict, Mapping, Optional -from zoneinfo import ZoneInfo - -from opentelemetry.sdk.trace import ReadableSpan -from opentelemetry.trace import StatusCode -from pydantic import BaseModel - -from uipath.runtime.tracing.span import UiPathRuntimeSpan - - -def get_supported_params( - tracer_impl: Callable[..., Any], - params: Mapping[str, Any], -) -> Dict[str, Any]: - """Extract the parameters supported by the tracer implementation.""" - try: - sig = inspect.signature(tracer_impl) - except (TypeError, ValueError): - # If we can't inspect, pass all parameters and let the function handle it - return dict(params) - - supported: Dict[str, Any] = {} - for name, value in params.items(): - if value is not None and name in sig.parameters: - supported[name] = value - return supported - - -def _simple_serialize_defaults(obj): - # Handle Pydantic BaseModel instances - if hasattr(obj, "model_dump") and not isinstance(obj, type): - return obj.model_dump(exclude_none=True, mode="json") - - # Handle classes - convert to schema representation - if isinstance(obj, type) and issubclass(obj, BaseModel): - return { - "__class__": obj.__name__, - "__module__": obj.__module__, - "schema": obj.model_json_schema(), - } - if hasattr(obj, "dict") and not isinstance(obj, type): - return obj.dict() - if hasattr(obj, "to_dict") and not isinstance(obj, type): - return obj.to_dict() - - # Handle dataclasses - if is_dataclass(obj) and not isinstance(obj, type): - return asdict(obj) - - # Handle enums - if isinstance(obj, Enum): - return _simple_serialize_defaults(obj.value) - - if isinstance(obj, (set, tuple)): - if hasattr(obj, "_asdict") and callable(obj._asdict): - return obj._asdict() - return list(obj) - - if isinstance(obj, datetime): - return obj.isoformat() - - if isinstance(obj, (timezone, ZoneInfo)): - return obj.tzname(None) - - # Allow JSON-serializable primitives to pass through unchanged - if obj is None or isinstance(obj, (bool, int, float, str)): - return obj - - return str(obj) - - -def span_id_to_uuid4(span_id: int) -> uuid.UUID: - """Convert a 64-bit span ID to a valid UUID4 format. - - Creates a UUID where: - - The 64 least significant bits contain the span ID - - The UUID version (bits 48-51) is set to 4 - - The UUID variant (bits 64-65) is set to binary 10 - """ - # Generate deterministic high bits using the span_id as seed - temp_random = random.Random(span_id) - high_bits = temp_random.getrandbits(64) - - # Combine high bits and span ID into a 128-bit integer - combined = (high_bits << 64) | span_id - - # Set version to 4 (UUID4) - combined = (combined & ~(0xF << 76)) | (0x4 << 76) - - # Set variant to binary 10 - combined = (combined & ~(0x3 << 62)) | (2 << 62) - - # Convert to hex string in UUID format - hex_str = format(combined, "032x") - return uuid.UUID(hex_str) - - -def trace_id_to_uuid4(trace_id: int) -> uuid.UUID: - """Convert a 128-bit trace ID to a valid UUID4 format. - - Modifies the trace ID to conform to UUID4 requirements: - - The UUID version (bits 48-51) is set to 4 - - The UUID variant (bits 64-65) is set to binary 10 - """ - # Set version to 4 (UUID4) - uuid_int = (trace_id & ~(0xF << 76)) | (0x4 << 76) - - # Set variant to binary 10 - uuid_int = (uuid_int & ~(0x3 << 62)) | (2 << 62) - - # Convert to hex string in UUID format - hex_str = format(uuid_int, "032x") - return uuid.UUID(hex_str) - - -def otel_span_to_uipath_span( - otel_span: ReadableSpan, custom_trace_id: Optional[str] = None -) -> UiPathRuntimeSpan: - """Convert an OpenTelemetry span to a UiPathRuntimeSpan.""" - # Extract the context information from the OTel span - span_context = otel_span.get_span_context() - - # OTel uses hexadecimal strings, we need to convert to UUID - trace_id = trace_id_to_uuid4(span_context.trace_id) - span_id = span_id_to_uuid4(span_context.span_id) - - trace_id_str = custom_trace_id or os.environ.get("UIPATH_TRACE_ID") - if trace_id_str: - trace_id = uuid.UUID(trace_id_str) - - # Get parent span ID if it exists - parent_id = None - if otel_span.parent is not None: - parent_id = span_id_to_uuid4(otel_span.parent.span_id) - - parent_span_id_str = env.get("UIPATH_PARENT_SPAN_ID") - - if parent_span_id_str: - parent_id = uuid.UUID(parent_span_id_str) - - # Convert attributes to a format compatible with UiPathSpan - attributes_dict: dict[str, Any] = ( - dict(otel_span.attributes) if otel_span.attributes else {} - ) - - # Map status - status = 1 # Default to OK - if otel_span.status.status_code == StatusCode.ERROR: - status = 2 # Error - attributes_dict["error"] = otel_span.status.description - - original_inputs = attributes_dict.get("input", None) - original_outputs = attributes_dict.get("output", None) - - if original_inputs: - try: - if isinstance(original_inputs, str): - json_inputs = json.loads(original_inputs) - attributes_dict["input.value"] = json_inputs - attributes_dict["input.mime_type"] = "application/json" - else: - attributes_dict["input.value"] = original_inputs - except Exception: - attributes_dict["input.value"] = str(original_inputs) - - if original_outputs: - try: - if isinstance(original_outputs, str): - json_outputs = json.loads(original_outputs) - attributes_dict["output.value"] = json_outputs - attributes_dict["output.mime_type"] = "application/json" - else: - attributes_dict["output.value"] = original_outputs - except Exception: - attributes_dict["output.value"] = str(original_outputs) - - # Add events as additional attributes if they exist - if otel_span.events: - events_list = [ - { - "name": event.name, - "timestamp": event.timestamp, - "attributes": dict(event.attributes) if event.attributes else {}, - } - for event in otel_span.events - ] - attributes_dict["events"] = events_list - - # Add links as additional attributes if they exist - if hasattr(otel_span, "links") and otel_span.links: - links_list = [ - { - "trace_id": link.context.trace_id, - "span_id": link.context.span_id, - "attributes": dict(link.attributes) if link.attributes else {}, - } - for link in otel_span.links - ] - attributes_dict["links"] = links_list - - span_type_value = attributes_dict.get("span_type", "OpenTelemetry") - span_type = str(span_type_value) - - # Create UiPathSpan from OpenTelemetry span - start_time = datetime.fromtimestamp((otel_span.start_time or 0) / 1e9).isoformat() - - end_time_str = None - if otel_span.end_time is not None: - end_time_str = datetime.fromtimestamp( - (otel_span.end_time or 0) / 1e9 - ).isoformat() - else: - end_time_str = datetime.now().isoformat() - - return UiPathRuntimeSpan( - id=span_id, - trace_id=trace_id, - parent_id=parent_id, - name=otel_span.name, - attributes=json.dumps(attributes_dict), - start_time=start_time, - end_time=end_time_str, - status=status, - span_type=span_type, - ) - - -def format_args_for_trace_json( - signature: inspect.Signature, *args: Any, **kwargs: Any -) -> str: - """Return a JSON string of inputs from the function signature.""" - result = format_args_for_trace(signature, *args, **kwargs) - return json.dumps(result, default=_simple_serialize_defaults) - - -def format_object_for_trace_json( - input_object: Any, -) -> str: - """Return a JSON string of inputs from the function signature.""" - return json.dumps(input_object, default=_simple_serialize_defaults) - - -def format_args_for_trace( - signature: inspect.Signature, *args: Any, **kwargs: Any -) -> Dict[str, Any]: - try: - """Return a dictionary of inputs from the function signature.""" - # Create a parameter mapping by partially binding the arguments - - parameter_binding = signature.bind_partial(*args, **kwargs) - - # Fill in default values for any unspecified parameters - parameter_binding.apply_defaults() - - # Extract the input parameters, skipping special Python parameters - result = {} - for name, value in parameter_binding.arguments.items(): - # Skip class and instance references - if name in ("self", "cls"): - continue - - # Handle **kwargs parameters specially - param_info = signature.parameters.get(name) - if param_info and param_info.kind == inspect.Parameter.VAR_KEYWORD: - # Flatten nested kwargs directly into the result - if isinstance(value, dict): - result.update(value) - else: - # Regular parameter - result[name] = value - - return result - except Exception: - return {"args": args, "kwargs": kwargs} - - -__all__ = [ - "otel_span_to_uipath_span", -] diff --git a/src/uipath/runtime/tracing/context.py b/src/uipath/runtime/tracing/context.py deleted file mode 100644 index de99727..0000000 --- a/src/uipath/runtime/tracing/context.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Trace context information for tracing and debugging.""" - -from typing import Optional, Union - -from pydantic import BaseModel - - -class UiPathTraceContext(BaseModel): - """Trace context information for tracing and debugging.""" - - trace_id: Optional[str] = None - parent_span_id: Optional[str] = None - root_span_id: Optional[str] = None - org_id: Optional[str] = None - tenant_id: Optional[str] = None - job_id: Optional[str] = None - folder_key: Optional[str] = None - process_key: Optional[str] = None - enabled: Union[bool, str] = False - reference_id: Optional[str] = None - - -__all__ = ["UiPathTraceContext"] diff --git a/src/uipath/runtime/tracing/decorators.py b/src/uipath/runtime/tracing/decorators.py deleted file mode 100644 index c6cb3c7..0000000 --- a/src/uipath/runtime/tracing/decorators.py +++ /dev/null @@ -1,255 +0,0 @@ -"""OpenTelemetry tracing decorators for function instrumentation.""" - -import inspect -import json -import logging -from functools import wraps -from typing import Any, Callable, Optional - -from opentelemetry import trace -from opentelemetry.trace.status import StatusCode - -from uipath.runtime.tracing._utils import ( - format_args_for_trace_json, - format_object_for_trace_json, - get_supported_params, -) -from uipath.runtime.tracing.manager import UiPathRuntimeTracingManager - -logger = logging.getLogger(__name__) - - -def _default_input_processor() -> dict[str, Any]: - """Default input processor that doesn't log any actual input data.""" - return {"redacted": "Input data not logged for privacy/security"} - - -def _default_output_processor() -> dict[str, Any]: - """Default output processor that doesn't log any actual output data.""" - return {"redacted": "Output data not logged for privacy/security"} - - -def _opentelemetry_traced( - name: Optional[str] = None, - run_type: Optional[str] = None, - span_type: Optional[str] = None, - input_processor: Optional[Callable[..., Any]] = None, - output_processor: Optional[Callable[..., Any]] = None, -): - """Default tracer implementation using OpenTelemetry. - - Args: - name: Optional name for the trace span - run_type: Optional string to categorize the run type - span_type: Optional string to categorize the span type - input_processor: Optional function to process function inputs before recording - output_processor: Optional function to process function outputs before recording - """ - - def decorator(func): - trace_name = name or func.__name__ - tracer = trace.get_tracer(__name__) - - # --------- Sync wrapper --------- - @wraps(func) - def sync_wrapper(*args, **kwargs): - ctx = UiPathRuntimeTracingManager.get_parent_context() - with tracer.start_as_current_span(trace_name, context=ctx) as span: - span.set_attribute("span_type", span_type or "function_call_sync") - if run_type is not None: - span.set_attribute("run_type", run_type) - - inputs = format_args_for_trace_json( - inspect.signature(func), *args, **kwargs - ) - if input_processor: - processed_inputs = input_processor(json.loads(inputs)) - inputs = json.dumps(processed_inputs, default=str) - span.set_attribute("input.mime_type", "application/json") - span.set_attribute("input.value", inputs) - - try: - result = func(*args, **kwargs) - output = output_processor(result) if output_processor else result - span.set_attribute( - "output.value", format_object_for_trace_json(output) - ) - span.set_attribute("output.mime_type", "application/json") - return result - except Exception as e: - span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) - raise - - # --------- Async wrapper --------- - @wraps(func) - async def async_wrapper(*args, **kwargs): - ctx = UiPathRuntimeTracingManager.get_parent_context() - with tracer.start_as_current_span(trace_name, context=ctx) as span: - span.set_attribute("span_type", span_type or "function_call_async") - if run_type is not None: - span.set_attribute("run_type", run_type) - - inputs = format_args_for_trace_json( - inspect.signature(func), *args, **kwargs - ) - if input_processor: - processed_inputs = input_processor(json.loads(inputs)) - inputs = json.dumps(processed_inputs, default=str) - span.set_attribute("input.mime_type", "application/json") - span.set_attribute("input.value", inputs) - - try: - result = await func(*args, **kwargs) - output = output_processor(result) if output_processor else result - span.set_attribute( - "output.value", format_object_for_trace_json(output) - ) - span.set_attribute("output.mime_type", "application/json") - return result - except Exception as e: - span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) - raise - - # --------- Generator wrapper --------- - @wraps(func) - def generator_wrapper(*args, **kwargs): - ctx = UiPathRuntimeTracingManager.get_parent_context() - with tracer.start_as_current_span(trace_name, context=ctx) as span: - span.set_attribute( - "span_type", span_type or "function_call_generator_sync" - ) - if run_type is not None: - span.set_attribute("run_type", run_type) - - inputs = format_args_for_trace_json( - inspect.signature(func), *args, **kwargs - ) - if input_processor: - processed_inputs = input_processor(json.loads(inputs)) - inputs = json.dumps(processed_inputs, default=str) - span.set_attribute("input.mime_type", "application/json") - span.set_attribute("input.value", inputs) - - try: - outputs = [] - for item in func(*args, **kwargs): - outputs.append(item) - span.add_event(f"Yielded: {item}") - yield item - output = output_processor(outputs) if output_processor else outputs - span.set_attribute( - "output.value", format_object_for_trace_json(output) - ) - span.set_attribute("output.mime_type", "application/json") - except Exception as e: - span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) - raise - - # --------- Async generator wrapper --------- - @wraps(func) - async def async_generator_wrapper(*args, **kwargs): - ctx = UiPathRuntimeTracingManager.get_parent_context() - with tracer.start_as_current_span(trace_name, context=ctx) as span: - span.set_attribute( - "span_type", span_type or "function_call_generator_async" - ) - if run_type is not None: - span.set_attribute("run_type", run_type) - - inputs = format_args_for_trace_json( - inspect.signature(func), *args, **kwargs - ) - if input_processor: - processed_inputs = input_processor(json.loads(inputs)) - inputs = json.dumps(processed_inputs, default=str) - span.set_attribute("input.mime_type", "application/json") - span.set_attribute("input.value", inputs) - - try: - outputs = [] - async for item in func(*args, **kwargs): - outputs.append(item) - span.add_event(f"Yielded: {item}") - yield item - output = output_processor(outputs) if output_processor else outputs - span.set_attribute( - "output.value", format_object_for_trace_json(output) - ) - span.set_attribute("output.mime_type", "application/json") - except Exception as e: - span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) - raise - - if inspect.iscoroutinefunction(func): - return async_wrapper - elif inspect.isgeneratorfunction(func): - return generator_wrapper - elif inspect.isasyncgenfunction(func): - return async_generator_wrapper - else: - return sync_wrapper - - return decorator - - -def traced( - name: Optional[str] = None, - run_type: Optional[str] = None, - span_type: Optional[str] = None, - input_processor: Optional[Callable[..., Any]] = None, - output_processor: Optional[Callable[..., Any]] = None, - hide_input: bool = False, - hide_output: bool = False, -): - """Decorator that will trace function invocations. - - Args: - name: Optional name for the trace span - run_type: Optional string to categorize the run type - span_type: Optional string to categorize the span type - input_processor: Optional function to process function inputs before recording - Should accept a dictionary of inputs and return a processed dictionary - output_processor: Optional function to process function outputs before recording - Should accept the function output and return a processed value - hide_input: If True, don't log any input data - hide_output: If True, don't log any output data - """ - # Apply default processors selectively based on hide flags - if hide_input: - input_processor = _default_input_processor - if hide_output: - output_processor = _default_output_processor - - # Store the parameters for later reapplication - params = { - "name": name, - "run_type": run_type, - "span_type": span_type, - "input_processor": input_processor, - "output_processor": output_processor, - } - - # Check for custom implementation first - tracer_impl = _opentelemetry_traced - - def decorator(func): - # Check which parameters are supported by the tracer_impl - supported_params = get_supported_params(tracer_impl, params) - - # Decorate the function with only supported parameters - decorated_func = tracer_impl(**supported_params)(func) - - # Register both original and decorated function with parameters - UiPathRuntimeTracingManager.register_traced_function( - func, decorated_func, params - ) - return decorated_func - - return decorator - - -__all__ = ["traced"] diff --git a/src/uipath/runtime/tracing/exporters.py b/src/uipath/runtime/tracing/exporters.py new file mode 100644 index 0000000..21d8ba4 --- /dev/null +++ b/src/uipath/runtime/tracing/exporters.py @@ -0,0 +1,45 @@ +"""Custom OpenTelemetry Span Exporter for UiPath Runtime executions.""" + +from collections import defaultdict +from typing import Dict, List, Optional, Sequence + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + +class UiPathRuntimeExecutionSpanExporter(SpanExporter): + """Custom exporter that stores spans grouped by execution ids.""" + + def __init__(self): + """Initialize the exporter.""" + self._spans: Dict[str, List[ReadableSpan]] = defaultdict(list) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Export spans, grouping them by execution id.""" + for span in spans: + if span.attributes is not None: + exec_id = span.attributes.get("execution.id") + if exec_id is not None and isinstance(exec_id, str): + self._spans[exec_id].append(span) + + return SpanExportResult.SUCCESS + + def get_spans(self, execution_id: str) -> List[ReadableSpan]: + """Retrieve spans for a given execution id.""" + return self._spans.get(execution_id, []) + + def clear(self, execution_id: Optional[str] = None) -> None: + """Clear stored spans for one or all executions.""" + if execution_id: + self._spans.pop(execution_id, None) + else: + self._spans.clear() + + def shutdown(self) -> None: + """Shutdown the exporter and clear all stored spans.""" + self.clear() + + +__all__ = [ + "UiPathRuntimeExecutionSpanExporter", +] diff --git a/src/uipath/runtime/tracing/manager.py b/src/uipath/runtime/tracing/manager.py deleted file mode 100644 index b4c9ab6..0000000 --- a/src/uipath/runtime/tracing/manager.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tracing manager for handling tracer implementations and function registry.""" - -import logging -from typing import Any, Callable, List, Optional, Tuple - -from opentelemetry import context, trace -from opentelemetry.trace import set_span_in_context - -logger = logging.getLogger(__name__) - - -class UiPathRuntimeTracingManager: - """Static utility class to manage tracing implementations and decorated functions.""" - - # Registry to track original functions, decorated functions, and their parameters - # Each entry is (original_func, decorated_func, params) - _traced_registry: List[Tuple[Callable[..., Any], Callable[..., Any], Any]] = [] - - _current_span_provider: Optional[Callable[[], Any]] = None - - @classmethod - def register_current_span_provider( - cls, current_span_provider: Optional[Callable[[], Any]] - ): - """Register a custom current span provider function. - - Args: - current_span_provider: A function that returns the current span from an external - tracing framework. If None, no custom span parenting will be used. - """ - cls._current_span_provider = current_span_provider - - @staticmethod - def get_parent_context(): - """Get the parent context for span creation. - - Prioritizes: - 1. Currently active OTel span (for recursion/children) - 2. External span provider (for top-level calls) - 3. Current context as fallback - """ - # Always use the currently active OTel span if valid (recursion / children) - current_span = trace.get_current_span() - if current_span is not None and current_span.get_span_context().is_valid: - return set_span_in_context(current_span) - - # Only for the very top-level call, fallback to external span provider - if UiPathRuntimeTracingManager._current_span_provider is not None: - try: - external_span = UiPathRuntimeTracingManager._current_span_provider() - if external_span is not None: - return set_span_in_context(external_span) - except Exception as e: - logger.warning(f"Error getting current span from provider: {e}") - - # Last fallback - return context.get_current() - - @classmethod - def register_traced_function(cls, original_func, decorated_func, params): - """Register a function decorated with @traced and its parameters. - - Args: - original_func: The original function before decoration - decorated_func: The function after decoration - params: The parameters used for tracing - """ - cls._traced_registry.append((original_func, decorated_func, params)) - - -__all__ = ["UiPathRuntimeTracingManager"] diff --git a/src/uipath/runtime/tracing/processors.py b/src/uipath/runtime/tracing/processors.py index 22b6764..8f0509e 100644 --- a/src/uipath/runtime/tracing/processors.py +++ b/src/uipath/runtime/tracing/processors.py @@ -26,9 +26,6 @@ def on_start( execution_id = parent_span.attributes.get("execution.id") if execution_id: span.set_attribute("execution.id", execution_id) - evaluation_id = parent_span.attributes.get("evaluation.id") - if evaluation_id: - span.set_attribute("evaluation.id", evaluation_id) class UiPathExecutionBatchTraceProcessor( diff --git a/src/uipath/runtime/tracing/span.py b/src/uipath/runtime/tracing/span.py deleted file mode 100644 index b5f9807..0000000 --- a/src/uipath/runtime/tracing/span.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Defines the UiPathSpan model for tracing spans in UiPath.""" - -from datetime import datetime -from os import environ as env -from typing import Any, Optional -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field - - -class UiPathRuntimeSpan(BaseModel): - """Represents a span in the UiPath tracing system.""" - - model_config = ConfigDict(populate_by_name=True) - - id: UUID = Field(serialization_alias="Id") - trace_id: UUID = Field(serialization_alias="TraceId") - name: str = Field(serialization_alias="Name") - attributes: str = Field(serialization_alias="Attributes") - parent_id: Optional[UUID] = Field(None, serialization_alias="ParentId") - start_time: str = Field( - default_factory=lambda: datetime.now().isoformat(), - serialization_alias="StartTime", - ) - end_time: str = Field( - default_factory=lambda: datetime.now().isoformat(), - serialization_alias="EndTime", - ) - status: int = Field(1, serialization_alias="Status") - created_at: str = Field( - default_factory=lambda: datetime.now().isoformat() + "Z", - serialization_alias="CreatedAt", - ) - updated_at: str = Field( - default_factory=lambda: datetime.now().isoformat() + "Z", - serialization_alias="UpdatedAt", - ) - organization_id: Optional[str] = Field( - default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", ""), - serialization_alias="OrganizationId", - ) - tenant_id: Optional[str] = Field( - default_factory=lambda: env.get("UIPATH_TENANT_ID", ""), - serialization_alias="TenantId", - ) - expiry_time_utc: Optional[str] = Field(None, serialization_alias="ExpiryTimeUtc") - folder_key: Optional[str] = Field( - default_factory=lambda: env.get("UIPATH_FOLDER_KEY", ""), - serialization_alias="FolderKey", - ) - source: Optional[str] = Field(None, serialization_alias="Source") - span_type: str = Field("Coded Agents", serialization_alias="SpanType") - process_key: Optional[str] = Field( - default_factory=lambda: env.get("UIPATH_PROCESS_UUID"), - serialization_alias="ProcessKey", - ) - reference_id: Optional[str] = Field( - default_factory=lambda: env.get("TRACE_REFERENCE_ID"), - serialization_alias="ReferenceId", - ) - job_key: Optional[str] = Field( - default_factory=lambda: env.get("UIPATH_JOB_KEY"), serialization_alias="JobKey" - ) - - def to_dict(self) -> dict[str, Any]: - """Convert the Span to a dictionary suitable for JSON serialization. - - Returns a dict with PascalCase keys for UiPath API compatibility. - """ - return self.model_dump(by_alias=True, exclude_none=False, mode="json") - - -__all__ = ["UiPathRuntimeSpan"] diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..85cf797 --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,180 @@ +"""Simple test for runtime factory and executor span capture.""" + +import pytest +from opentelemetry import trace + +from uipath.runtime.base import UiPathBaseRuntime +from uipath.runtime.context import UiPathRuntimeContext +from uipath.runtime.factory import UiPathRuntimeExecutor, UiPathRuntimeFactory +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus + + +class MockRuntimeA(UiPathBaseRuntime): + """Mock runtime A for testing.""" + + async def validate(self): + pass + + async def cleanup(self): + pass + + async def execute(self) -> UiPathRuntimeResult: + return UiPathRuntimeResult( + output={"runtime": "A"}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + +class MockRuntimeB(UiPathBaseRuntime): + """Mock runtime B for testing.""" + + async def validate(self): + pass + + async def cleanup(self): + pass + + async def execute(self) -> UiPathRuntimeResult: + return UiPathRuntimeResult( + output={"runtime": "B"}, status=UiPathRuntimeStatus.SUCCESSFUL + ) + + +class MockRuntimeC(UiPathBaseRuntime): + """Mock runtime C that emits custom spans.""" + + async def validate(self): + pass + + async def cleanup(self): + pass + + async def execute(self) -> UiPathRuntimeResult: + tracer = trace.get_tracer("test-runtime-c") + + # Create a child span + with tracer.start_as_current_span( + "custom-child-span", attributes={"operation": "child", "step": "1"} + ): + # Simulate some work + pass + + # Create a sibling span + with tracer.start_as_current_span( + "custom-sibling-span", attributes={"operation": "sibling", "step": "2"} + ): + # Simulate more work + pass + + # Create nested spans + with tracer.start_as_current_span( + "parent-operation", attributes={"operation": "parent"} + ): + with tracer.start_as_current_span( + "nested-child-operation", attributes={"operation": "nested"} + ): + pass + + return UiPathRuntimeResult( + output={"runtime": "C", "spans_created": 4}, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + + +@pytest.mark.asyncio +async def test_multiple_factories_same_executor(): + """Test two factories using same executor, verify spans are captured correctly.""" + + # Create two factories for different runtimes + factory_a = UiPathRuntimeFactory(MockRuntimeA) + factory_b = UiPathRuntimeFactory(MockRuntimeB) + factory_c = UiPathRuntimeFactory(MockRuntimeC) + + # Create single executor + executor = UiPathRuntimeExecutor() + + # Execute runtime A + runtime_a = factory_a.from_context(UiPathRuntimeContext()) + async with runtime_a: + result_a = await executor.execute_in_root_span( + runtime_a, execution_id="exec-a", root_span="runtime-a-span" + ) + + # Execute runtime B + runtime_b = factory_b.from_context(UiPathRuntimeContext()) + async with runtime_b: + result_b = await executor.execute_in_root_span( + runtime_b, execution_id="exec-b", root_span="runtime-b-span" + ) + + # Execute runtime C with custom spans + runtime_c = factory_c.from_context(UiPathRuntimeContext()) + async with runtime_c: + result_c = await executor.execute_in_root_span( + runtime_c, execution_id="exec-c", root_span="runtime-c-span" + ) + + # Verify results + assert result_a.status == UiPathRuntimeStatus.SUCCESSFUL + assert result_a.output == {"runtime": "A"} + assert result_b.status == UiPathRuntimeStatus.SUCCESSFUL + assert result_b.output == {"runtime": "B"} + assert result_c.status == UiPathRuntimeStatus.SUCCESSFUL + assert result_c.output == {"runtime": "C", "spans_created": 4} + + # Verify spans for execution A + spans_a = executor.get_execution_spans("exec-a") + assert len(spans_a) > 0 + span_names_a = [s.name for s in spans_a] + assert "runtime-a-span" in span_names_a + + # Verify spans for execution B + spans_b = executor.get_execution_spans("exec-b") + assert len(spans_b) > 0 + span_names_b = [s.name for s in spans_b] + assert "runtime-b-span" in span_names_b + + # Verify spans for execution C (should include custom spans) + spans_c = executor.get_execution_spans("exec-c") + assert len(spans_c) > 0 + span_names_c = [s.name for s in spans_c] + + # Verify root span exists + assert "runtime-c-span" in span_names_c + + # Verify custom child and sibling spans exist + assert "custom-child-span" in span_names_c + assert "custom-sibling-span" in span_names_c + assert "parent-operation" in span_names_c + assert "nested-child-operation" in span_names_c + + # Verify span hierarchy by checking parent relationships + root_span_c = next(s for s in spans_c if s.name == "runtime-c-span") + child_span = next(s for s in spans_c if s.name == "custom-child-span") + sibling_span = next(s for s in spans_c if s.name == "custom-sibling-span") + parent_op = next(s for s in spans_c if s.name == "parent-operation") + nested_op = next(s for s in spans_c if s.name == "nested-child-operation") + + # Child and sibling should have root as parent + assert child_span.parent is not None + assert sibling_span.parent is not None + assert child_span.parent.span_id == root_span_c.context.span_id + assert sibling_span.parent.span_id == root_span_c.context.span_id + + # Nested operation should have parent operation as parent + assert nested_op.parent is not None + assert parent_op.parent is not None + assert nested_op.parent.span_id == parent_op.context.span_id + assert parent_op.parent.span_id == root_span_c.context.span_id + + # Verify spans are isolated by execution_id + for span in spans_a: + assert span.attributes is not None + assert span.attributes.get("execution.id") == "exec-a" + + for span in spans_b: + assert span.attributes is not None + assert span.attributes.get("execution.id") == "exec-b" + + for span in spans_c: + assert span.attributes is not None + assert span.attributes.get("execution.id") == "exec-c" diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index a3f43c4..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Placeholder test to prevent pytest from failing.""" - - -def test_placeholder(): - """Placeholder test that always passes.""" - assert True diff --git a/uv.lock b/uv.lock index ba2d77e..b5c80fa 100644 --- a/uv.lock +++ b/uv.lock @@ -922,6 +922,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uipath-core" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/78/be12be013d4f456e154be2062b114cf489a8bb2e1e223cc0a1a88f0117b9/uipath_core-0.0.1.tar.gz", hash = "sha256:14e1d37dd747cb6afbfbb56d9d61853b97defa7751f9b38197436fff1ab9d6cd", size = 67396, upload-time = "2025-11-02T10:47:51.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/78/0ff155c18610da4af82479e28d8e1e61e571c17d912ecf2ac143b8979953/uipath_core-0.0.1-py3-none-any.whl", hash = "sha256:0a919e8d29b5f79eade9bdd5bca588f0e70b3ce3a88d610c2c94ce8780412aa3", size = 8530, upload-time = "2025-11-02T10:47:50.443Z" }, +] + [[package]] name = "uipath-runtime" version = "0.0.1" @@ -930,6 +944,7 @@ dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, + { name = "uipath-core" }, ] [package.dev-dependencies] @@ -952,6 +967,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation", specifier = ">=0.59b0" }, { name = "opentelemetry-sdk", specifier = ">=1.38.0" }, { name = "pydantic", specifier = ">=2.12.3" }, + { name = "uipath-core", specifier = ">=0.0.1" }, ] [package.metadata.requires-dev]