Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 6 additions & 0 deletions packages/uipath-core/src/uipath/core/governance/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
viswa-uipath marked this conversation as resolved.
agent_type: str | None = Field(default=None, alias="agentType")
runtime_version: str | None = Field(default=None, alias="runtimeVersion")


# ----------------------------------------------------------------------
# Provider protocols
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-core/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"""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`).
- ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating
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`.
"""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading