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..578df2f5a 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,33 @@ 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 5ceabf479..9f664e4ca 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -1,12 +1,15 @@ """Service for the ``agenticgovernance_`` ingress. -Wraps the two governance backend endpoints UiPath exposes: +Wraps the three governance backend endpoints UiPath exposes: - ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). - ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). +- ``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — agent-emitted + custom telemetry events forwarded to App Insights + (see :meth:`GovernanceService.track_event`). Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, trace context, and error enrichment come from :class:`BaseService`. @@ -22,7 +25,7 @@ PolicyResponse, ) -from ..common._base_service import BaseService +from ..common._base_service import BaseService, resolve_trace_id from ..common._config import UiPathConfig from ..common._service_url_overrides import ( inject_routing_headers, @@ -35,18 +38,26 @@ 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. - Exposes two endpoints: + Exposes three endpoints: - :meth:`retrieve_policy` — GET the tenant-managed policy pack. - :meth:`compensate` — POST a compensating ``/runtime/govern`` call so the server can run a disabled centralized guardrail and write the per-rule LLMOps audit records itself. + - :meth:`track_event` — POST a custom telemetry event to + ``/runtime/log`` (forwarded to App Insights ``customEvents``). Org and tenant scoping come from :attr:`UiPathConfig.organization_id` and :attr:`UiPathConfig.tenant_id`; the tenant travels in the @@ -274,6 +285,85 @@ async def _compensate_async(self, request: GovernRequest) -> None: payload = self._build_govern_payload(request) await self.request_async("POST", url=url, headers=headers, json=payload) + # ── Custom telemetry events ────────────────────────────────────── + + @traced(name="governance_track_event", run_type="uipath") + 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``. + + 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) + + @traced(name="governance_track_event", run_type="uipath") + async def track_event_async( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + """Asynchronously POST a custom telemetry event to ``/runtime/log``. + + See :meth:`track_event` for parameter semantics. + """ + 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 e437fdda0..1f6b05284 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -553,6 +553,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."""