|
1 | 1 | """Service for the ``agenticgovernance_`` ingress. |
2 | 2 |
|
3 | | -Wraps the two governance backend endpoints UiPath exposes: |
| 3 | +Wraps the governance backend endpoints UiPath exposes: |
4 | 4 |
|
5 | 5 | - ``GET /{org}/agenticgovernance_/api/v1/runtime/policy`` — fetch the |
6 | 6 | tenant-managed policy pack (see :meth:`GovernanceService.retrieve_policy`). |
7 | 7 | - ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating |
8 | 8 | governance call fired when a ``guardrail_fallback`` rule matches |
9 | 9 | (see :meth:`GovernanceService.compensate`). |
10 | 10 |
|
| 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 | +
|
11 | 18 | Org/tenant scoping is read from :class:`UiPathConfig`; auth, retries, |
12 | 19 | trace context, and error enrichment come from :class:`BaseService`. |
13 | 20 | """ |
|
35 | 42 | GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" |
36 | 43 | POLICY_API_PATH = "api/v1/runtime/policy" |
37 | 44 | GOVERN_API_PATH = "api/v1/runtime/govern" |
| 45 | +LOG_API_PATH = "api/v1/runtime/log" |
38 | 46 | AGENT_TYPE_PARAM = "agentType" |
39 | 47 |
|
| 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 | + |
40 | 53 |
|
41 | 54 | class GovernanceService(BaseService): |
42 | 55 | """Service for the agenticgovernance_ ingress. |
@@ -306,6 +319,92 @@ def _resolve_request_trace_id(request: GovernRequest) -> GovernRequest: |
306 | 319 | return request |
307 | 320 | return request.model_copy(update={"trace_id": resolved}) |
308 | 321 |
|
| 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 | + |
309 | 408 | # ── Internals ──────────────────────────────────────────────────── |
310 | 409 |
|
311 | 410 | def _build_org_scoped_request(self, path: str) -> tuple[str, dict[str, str]]: |
|
0 commit comments