diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 6fb1831f6..185d84ab6 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 2a0b7155a..5435ad389 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -130,6 +130,12 @@ class GovernRequest(BaseModel): reference_id: str | None = Field(default=None, alias="referenceId") agent_version: str | None = Field(default=None, alias="agentVersion") + # Runtime identity for governance telemetry; the server stamps these on the + # rule-denied events it emits. Optional — omitted from the wire when None. + agent_framework: str | None = Field(default=None, alias="agentFramework") + agent_type: str | None = Field(default=None, alias="agentType") + runtime_version: str | None = Field(default=None, alias="runtimeVersion") + # ---------------------------------------------------------------------- # Provider protocols diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 6bcdfc306..a1322e25d 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 0678658a9..b3743cc0b 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.28.1", "tenacity>=9.0.0", "truststore>=0.10.1", - "uipath-core>=0.5.26, <0.6.0", + "uipath-core>=0.5.28, <0.6.0", "pydantic-function-models>=0.1.11", "sqlparse>=0.5.5", ] diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py index 23f1464bb..1b336ff0e 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py @@ -12,6 +12,8 @@ from __future__ import annotations +from typing import Any + from uipath.core.governance import GovernRequest, PolicyContext, PolicyResponse from ..common._config import UiPathApiConfig @@ -79,3 +81,34 @@ def compensate(self, request: GovernRequest) -> None: async def compensate_async(self, request: GovernRequest) -> None: """Async variant of :meth:`compensate`.""" await self._service._compensate_async(request) + + # ── Custom telemetry events ────────────────────────────────────── + + def track_event( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Record a custom telemetry event — delegates to ``GovernanceService``. + + See :meth:`GovernanceService._track_event` for parameter + semantics — in particular, the ``operation_id`` → trace-id + fallback. + """ + self._service._track_event( + event_name=event_name, data=data, operation_id=operation_id + ) + + async def track_event_async( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Async variant of :meth:`track_event`.""" + await self._service._track_event_async( + event_name=event_name, data=data, operation_id=operation_id + ) diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index afd5701b7..739791a7a 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -1,6 +1,6 @@ """Service for the ``agenticgovernance_`` ingress. -Wraps the two governance backend endpoints UiPath exposes: +Wraps the governance backend endpoints UiPath exposes: - ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). @@ -8,6 +8,13 @@ governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). +A third backend endpoint — +``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — emits custom +telemetry events to App Insights. It's reached only through the +internal ``_track_event`` helper, which the runtime adapter +(:class:`UiPathPlatformGovernanceProvider`) calls; not part of the +client-facing service surface. + Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, trace context, and error enrichment come from :class:`BaseService`. """ @@ -35,8 +42,14 @@ GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" POLICY_API_PATH = "api/v1/runtime/policy" GOVERN_API_PATH = "api/v1/runtime/govern" +LOG_API_PATH = "api/v1/runtime/log" AGENT_TYPE_PARAM = "agentType" +# Caller-set correlation id that becomes the App Insights ``operation_Id`` +# stamped on every customEvent produced by the matching ``/runtime/log`` +# request — see the spec on the platform-side ``postLogHandler``. +HEADER_OPERATION_ID = "x-uipath-operation-id" + class GovernanceService(BaseService): """Service for the agenticgovernance_ ingress. @@ -306,6 +319,92 @@ def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest: return request return request.model_copy(update={"trace_id": resolved}) + # ── Custom telemetry events (internal runtime seam) ────────────── + # + # ``_track_event`` / ``_track_event_async`` are intentionally + # underscore-prefixed: they exist for the runtime adapter + # (:class:`UiPathPlatformGovernanceProvider`) to fire telemetry + # events through the platform's HTTP stack, not as a client-facing + # SDK call. Keeping them off the public surface keeps the auto- + # generated docs (``mkdocs`` + ``mkdocstrings``) focused on the + # endpoints customers consume directly (``retrieve_policy`` / + # ``compensate``). + + def _track_event( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """POST a custom telemetry event to ``/runtime/log``. + + Internal seam — the runtime adapter + (:class:`UiPathPlatformGovernanceProvider`) calls this to emit + governance audit events through the platform's HTTP stack. + The server forwards the event to App Insights as a + ``customEvents`` row; account / tenant / organization are + stamped server-side from the gateway headers and JWT. + + Args: + event_name: Non-empty event name — becomes the App Insights + row ``name``. The platform redactor runs over this before + it reaches the sink. + data: Optional properties flattened into the event. Non-dict + values are dropped server-side. + operation_id: Optional correlation id forwarded as the + ``x-uipath-operation-id`` header. When omitted, falls + back to :func:`resolve_trace_id` so events emitted from + the same agent trace share an ``operation_Id`` and are + queryable together in KQL. When neither is available, + the header is omitted and App Insights generates its + own id per event. + + Raises: + ValueError: If ``event_name`` is empty / whitespace-only, or + if ``UiPathConfig.organization_id`` / + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + """ + self._validate_event_name(event_name) + url, headers = self._build_org_scoped_request(LOG_API_PATH) + resolved_op_id = operation_id or resolve_trace_id() + if resolved_op_id: + headers[HEADER_OPERATION_ID] = resolved_op_id + payload: dict[str, Any] = {"eventName": event_name} + if data is not None: + payload["data"] = data + self.request("POST", url=url, headers=headers, json=payload) + + async def _track_event_async( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Async variant of :meth:`_track_event`. Internal seam.""" + self._validate_event_name(event_name) + url, headers = self._build_org_scoped_request(LOG_API_PATH) + resolved_op_id = operation_id or resolve_trace_id() + if resolved_op_id: + headers[HEADER_OPERATION_ID] = resolved_op_id + payload: dict[str, Any] = {"eventName": event_name} + if data is not None: + payload["data"] = data + await self.request_async("POST", url=url, headers=headers, json=payload) + + @staticmethod + def _validate_event_name(event_name: str) -> None: + """Reject empty/whitespace-only event names client-side. + + The platform's ``/runtime/log`` handler rejects these with a + 4xx; failing fast here gives the caller a clearer error and + avoids the round trip. + """ + if not event_name or not event_name.strip(): + raise ValueError("event_name must be a non-empty string.") + # ── Internals ──────────────────────────────────────────────────── def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: diff --git a/packages/uipath-platform/tests/services/test_governance_provider.py b/packages/uipath-platform/tests/services/test_governance_provider.py index 24e489f65..f7211d97a 100644 --- a/packages/uipath-platform/tests/services/test_governance_provider.py +++ b/packages/uipath-platform/tests/services/test_governance_provider.py @@ -164,3 +164,39 @@ async def test_compensate_async_delegates_to_service( requests = httpx_mock.get_requests() assert len(requests) == 1 assert requests[0].method == "POST" + + def test_track_event_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + provider.track_event(event_name="ev", data={"k": "v"}, operation_id="op-1") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == "op-1" + + async def test_track_event_async_delegates_to_service( + self, + httpx_mock: HTTPXMock, + provider: UiPathPlatformGovernanceProvider, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + await provider.track_event_async(event_name="ev", operation_id="op-2") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == "op-2" diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index 2c808b943..eb4941faf 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -646,6 +646,253 @@ def test_redirects_compensate_to_override( assert sent.method == "POST" assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + def test_redirects_track_event_to_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + httpx_mock.add_response( + url="http://localhost:8123/api/v1/runtime/log", + method="POST", + status_code=204, + ) + + service._track_event(event_name="hello", operation_id="op-1") + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert sent.headers["x-uipath-operation-id"] == "op-1" + + class TestTrackEvent: + """Test track_event (sync).""" + + def test_posts_event_name_only_payload( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="agent.started") + + request = captured["request"] + assert request.method == "POST" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert json.loads(request.content) == {"eventName": "agent.started"} + + def test_includes_data_when_provided( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event( + event_name="agent.completed", + data={"duration_ms": 1234, "outcome": "success"}, + ) + + body = json.loads(captured["request"].content) + assert body == { + "eventName": "agent.completed", + "data": {"duration_ms": 1234, "outcome": "success"}, + } + + def test_sends_caller_operation_id_header( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev", operation_id="caller-supplied") + + assert captured["request"].headers["x-uipath-operation-id"] == ( + "caller-supplied" + ) + + def test_falls_back_to_resolved_trace_id( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # When the caller omits operation_id, the header is filled + # from resolve_trace_id() so events join the agent's trace. + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev") + + assert captured["request"].headers["x-uipath-operation-id"] == TENANT_ID_HEX + + def test_caller_operation_id_overrides_trace_fallback( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev", operation_id="explicit") + + assert captured["request"].headers["x-uipath-operation-id"] == "explicit" + + def test_omits_operation_id_header_when_no_source( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(204) + + httpx_mock.add_callback( + capture, + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + ) + + service._track_event(event_name="ev") + + assert "x-uipath-operation-id" not in captured["request"].headers + + def test_raises_when_org_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService( + config=config, execution_context=execution_context + ) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service._track_event(event_name="ev") + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + status_code=400, + text="bad event", + ) + + with pytest.raises(EnrichedException): + service._track_event(event_name="ev") + + @pytest.mark.parametrize("invalid_name", ["", " ", "\t", "\n"]) + def test_raises_on_empty_event_name( + self, + service: GovernanceService, + invalid_name: str, + ) -> None: + # Fail fast client-side instead of round-tripping a backend + # 400; matches the platform's own non-empty check on + # /runtime/log. + with pytest.raises(ValueError, match="event_name"): + service._track_event(event_name=invalid_name) + + class TestTrackEventAsync: + """Test track_event_async.""" + + async def test_posts_event_and_falls_back_to_trace_id( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TRACE_ID", TENANT_ID_HEX) + httpx_mock.add_response( + url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log", + status_code=204, + ) + + await service._track_event_async(event_name="ev", data={"foo": "bar"}) + + sent = httpx_mock.get_requests()[-1] + assert sent.method == "POST" + assert sent.headers["x-uipath-operation-id"] == TENANT_ID_HEX + assert json.loads(sent.content) == { + "eventName": "ev", + "data": {"foo": "bar"}, + } + + async def test_raises_on_empty_event_name( + self, service: GovernanceService + ) -> None: + with pytest.raises(ValueError, match="event_name"): + await service._track_event_async(event_name=" ") + class TestResolveTraceId: """Test the resolve_trace_id helper.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 1535e576f..1a8ac3ee8 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 30a57c9fd..126c14982 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.27" +version = "0.5.28" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" },