Skip to content

Commit fe490f7

Browse files
viswa-uipathaditik0303claude
authored
feat(governance): add track_event for custom telemetry to /runtime/log (#1745)
Co-authored-by: Aditi Kumari <aditi.kumari@uipath.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2e8fe4 commit fe490f7

10 files changed

Lines changed: 427 additions & 6 deletions

File tree

packages/uipath-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-core"
3-
version = "0.5.27"
3+
version = "0.5.28"
44
description = "UiPath Core abstractions"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-core/src/uipath/core/governance/providers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ class GovernRequest(BaseModel):
130130
reference_id: str | None = Field(default=None, alias="referenceId")
131131
agent_version: str | None = Field(default=None, alias="agentVersion")
132132

133+
# Runtime identity for governance telemetry; the server stamps these on the
134+
# rule-denied events it emits. Optional — omitted from the wire when None.
135+
agent_framework: str | None = Field(default=None, alias="agentFramework")
136+
agent_type: str | None = Field(default=None, alias="agentType")
137+
runtime_version: str | None = Field(default=None, alias="runtimeVersion")
138+
133139

134140
# ----------------------------------------------------------------------
135141
# Provider protocols

packages/uipath-core/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ dependencies = [
88
"httpx>=0.28.1",
99
"tenacity>=9.0.0",
1010
"truststore>=0.10.1",
11-
"uipath-core>=0.5.26, <0.6.0",
11+
"uipath-core>=0.5.28, <0.6.0",
1212
"pydantic-function-models>=0.1.11",
1313
"sqlparse>=0.5.5",
1414
]

packages/uipath-platform/src/uipath/platform/governance/_governance_provider.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from __future__ import annotations
1414

15+
from typing import Any
16+
1517
from uipath.core.governance import GovernRequest, PolicyContext, PolicyResponse
1618

1719
from ..common._config import UiPathApiConfig
@@ -79,3 +81,34 @@ def compensate(self, request: GovernRequest) -> None:
7981
async def compensate_async(self, request: GovernRequest) -> None:
8082
"""Async variant of :meth:`compensate`."""
8183
await self._service._compensate_async(request)
84+
85+
# ── Custom telemetry events ──────────────────────────────────────
86+
87+
def track_event(
88+
self,
89+
*,
90+
event_name: str,
91+
data: dict[str, Any] | None = None,
92+
operation_id: str | None = None,
93+
) -> None:
94+
"""Record a custom telemetry event — delegates to ``GovernanceService``.
95+
96+
See :meth:`GovernanceService._track_event` for parameter
97+
semantics — in particular, the ``operation_id`` → trace-id
98+
fallback.
99+
"""
100+
self._service._track_event(
101+
event_name=event_name, data=data, operation_id=operation_id
102+
)
103+
104+
async def track_event_async(
105+
self,
106+
*,
107+
event_name: str,
108+
data: dict[str, Any] | None = None,
109+
operation_id: str | None = None,
110+
) -> None:
111+
"""Async variant of :meth:`track_event`."""
112+
await self._service._track_event_async(
113+
event_name=event_name, data=data, operation_id=operation_id
114+
)

packages/uipath-platform/src/uipath/platform/governance/_governance_service.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
"""Service for the ``agenticgovernance_`` ingress.
22
3-
Wraps the two governance backend endpoints UiPath exposes:
3+
Wraps the governance backend endpoints UiPath exposes:
44
55
- ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the
66
tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`).
77
- ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating
88
governance call fired when a ``guardrail_fallback`` rule matches
99
(see :meth:`GovernanceService.compensate`).
1010
11+
A third backend endpoint —
12+
``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — emits custom
13+
telemetry events to App Insights. It's reached only through the
14+
internal ``_track_event`` helper, which the runtime adapter
15+
(:class:`UiPathPlatformGovernanceProvider`) calls; not part of the
16+
client-facing service surface.
17+
1118
Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries,
1219
trace context, and error enrichment come from :class:`BaseService`.
1320
"""
@@ -35,8 +42,14 @@
3542
GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_"
3643
POLICY_API_PATH = "api/v1/runtime/policy"
3744
GOVERN_API_PATH = "api/v1/runtime/govern"
45+
LOG_API_PATH = "api/v1/runtime/log"
3846
AGENT_TYPE_PARAM = "agentType"
3947

48+
# Caller-set correlation id that becomes the App Insights ``operation_Id``
49+
# stamped on every customEvent produced by the matching ``/runtime/log``
50+
# request — see the spec on the platform-side ``postLogHandler``.
51+
HEADER_OPERATION_ID = "x-uipath-operation-id"
52+
4053

4154
class GovernanceService(BaseService):
4255
"""Service for the agenticgovernance_ ingress.
@@ -306,6 +319,92 @@ def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest:
306319
return request
307320
return request.model_copy(update={"trace_id": resolved})
308321

322+
# ── Custom telemetry events (internal runtime seam) ──────────────
323+
#
324+
# ``_track_event`` / ``_track_event_async`` are intentionally
325+
# underscore-prefixed: they exist for the runtime adapter
326+
# (:class:`UiPathPlatformGovernanceProvider`) to fire telemetry
327+
# events through the platform's HTTP stack, not as a client-facing
328+
# SDK call. Keeping them off the public surface keeps the auto-
329+
# generated docs (``mkdocs`` + ``mkdocstrings``) focused on the
330+
# endpoints customers consume directly (``retrieve_policy`` /
331+
# ``compensate``).
332+
333+
def _track_event(
334+
self,
335+
*,
336+
event_name: str,
337+
data: dict[str, Any] | None = None,
338+
operation_id: str | None = None,
339+
) -> None:
340+
"""POST a custom telemetry event to ``/runtime/log``.
341+
342+
Internal seam — the runtime adapter
343+
(:class:`UiPathPlatformGovernanceProvider`) calls this to emit
344+
governance audit events through the platform's HTTP stack.
345+
The server forwards the event to App Insights as a
346+
``customEvents`` row; account / tenant / organization are
347+
stamped server-side from the gateway headers and JWT.
348+
349+
Args:
350+
event_name: Non-empty event name — becomes the App Insights
351+
row ``name``. The platform redactor runs over this before
352+
it reaches the sink.
353+
data: Optional properties flattened into the event. Non-dict
354+
values are dropped server-side.
355+
operation_id: Optional correlation id forwarded as the
356+
``x-uipath-operation-id`` header. When omitted, falls
357+
back to :func:`resolve_trace_id` so events emitted from
358+
the same agent trace share an ``operation_Id`` and are
359+
queryable together in KQL. When neither is available,
360+
the header is omitted and App Insights generates its
361+
own id per event.
362+
363+
Raises:
364+
ValueError: If ``event_name`` is empty / whitespace-only, or
365+
if ``UiPathConfig.organization_id`` /
366+
``UiPathConfig.tenant_id`` is not set.
367+
EnrichedException: If the backend returns a non-2xx response.
368+
"""
369+
self._validate_event_name(event_name)
370+
url, headers = self._build_org_scoped_request(LOG_API_PATH)
371+
resolved_op_id = operation_id or resolve_trace_id()
372+
if resolved_op_id:
373+
headers[HEADER_OPERATION_ID] = resolved_op_id
374+
payload: dict[str, Any] = {"eventName": event_name}
375+
if data is not None:
376+
payload["data"] = data
377+
self.request("POST", url=url, headers=headers, json=payload)
378+
379+
async def _track_event_async(
380+
self,
381+
*,
382+
event_name: str,
383+
data: dict[str, Any] | None = None,
384+
operation_id: str | None = None,
385+
) -> None:
386+
"""Async variant of :meth:`_track_event`. Internal seam."""
387+
self._validate_event_name(event_name)
388+
url, headers = self._build_org_scoped_request(LOG_API_PATH)
389+
resolved_op_id = operation_id or resolve_trace_id()
390+
if resolved_op_id:
391+
headers[HEADER_OPERATION_ID] = resolved_op_id
392+
payload: dict[str, Any] = {"eventName": event_name}
393+
if data is not None:
394+
payload["data"] = data
395+
await self.request_async("POST", url=url, headers=headers, json=payload)
396+
397+
@staticmethod
398+
def _validate_event_name(event_name: str) -> None:
399+
"""Reject empty/whitespace-only event names client-side.
400+
401+
The platform's ``/runtime/log`` handler rejects these with a
402+
4xx; failing fast here gives the caller a clearer error and
403+
avoids the round trip.
404+
"""
405+
if not event_name or not event_name.strip():
406+
raise ValueError("event_name must be a non-empty string.")
407+
309408
# ── Internals ────────────────────────────────────────────────────
310409

311410
def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]:

packages/uipath-platform/tests/services/test_governance_provider.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,39 @@ async def test_compensate_async_delegates_to_service(
164164
requests = httpx_mock.get_requests()
165165
assert len(requests) == 1
166166
assert requests[0].method == "POST"
167+
168+
def test_track_event_delegates_to_service(
169+
self,
170+
httpx_mock: HTTPXMock,
171+
provider: UiPathPlatformGovernanceProvider,
172+
base_url: str,
173+
) -> None:
174+
httpx_mock.add_response(
175+
url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log",
176+
method="POST",
177+
status_code=204,
178+
)
179+
180+
provider.track_event(event_name="ev", data={"k": "v"}, operation_id="op-1")
181+
182+
sent = httpx_mock.get_requests()[-1]
183+
assert sent.method == "POST"
184+
assert sent.headers["x-uipath-operation-id"] == "op-1"
185+
186+
async def test_track_event_async_delegates_to_service(
187+
self,
188+
httpx_mock: HTTPXMock,
189+
provider: UiPathPlatformGovernanceProvider,
190+
base_url: str,
191+
) -> None:
192+
httpx_mock.add_response(
193+
url=f"{base_url}/{ORG_ID}/agenticgovernance_/api/v1/runtime/log",
194+
method="POST",
195+
status_code=204,
196+
)
197+
198+
await provider.track_event_async(event_name="ev", operation_id="op-2")
199+
200+
sent = httpx_mock.get_requests()[-1]
201+
assert sent.method == "POST"
202+
assert sent.headers["x-uipath-operation-id"] == "op-2"

0 commit comments

Comments
 (0)