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 pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-runtime"
version = "0.12.4"
version = "0.12.5"
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
67 changes: 13 additions & 54 deletions src/uipath/runtime/governance/_audit/traces.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""OpenTelemetry traces audit sink for governance events.

Emits an OpenTelemetry span per rule evaluation and per hook summary.
Emits an OpenTelemetry span per rule evaluation.
This sink emits spans only — it does not resolve or stamp
job-execution metadata (organization, tenant, folder, job, trace id)
onto them. That resolution is owned by the platform-side OTel
Expand Down Expand Up @@ -39,13 +39,16 @@ def _package_version() -> str:
# governance span. Local to the runtime trace contract — kept as a
# string literal (not a cross-package import) so the runtime stays
# self-contained.
SPAN_TYPE_AGENT_RUN = "agentRun"
SPAN_TYPE_GOVERNANCE = "governance"

# Set as the ``source`` attribute on every governance span. Lets
# consumers identify which producer emitted a given span when more
# than one governance producer feeds the same trace backend.
GOVERNANCE_SOURCE = "governance-checker-python"

# Value of the root ``uipath.source`` span attribute.
UIPATH_SOURCE = "Governance"

# Shared attribute namespace for every governance span attribute.
# Concatenated into each ``span.set_attribute`` call so the prefix
# appears in one place and a future rename is a one-line change.
Expand Down Expand Up @@ -155,55 +158,9 @@ def _get_tracer(self) -> Any:
return self._tracer if self._tracer else None

def emit(self, event: AuditEvent) -> None:
"""Create a span for RULE_EVALUATION or HOOK_END events; drop others."""
"""Create a span for RULE_EVALUATION events; drop others."""
if event.event_type == EventType.RULE_EVALUATION:
self._emit_rule_span(event)
elif event.event_type == EventType.HOOK_END:
self._emit_hook_span(event)

def _emit_hook_span(self, event: AuditEvent) -> None:
"""Create a span for a hook summary (always emitted for each governance check)."""
tracer = self._get_tracer()
if tracer is None:
return

try:
data = event.data
hook = event.hook or "unknown"
span_name = f"governance.{hook.lower()}"

# Sink dispatch runs on the caller's thread (see
# :meth:`AuditManager.emit`), so the current OTel context
# is the agent's live span — the governance span attaches
# as its child without any cross-thread plumbing.
with tracer.start_as_current_span(span_name) as span:
span.set_attribute("type", SPAN_TYPE_AGENT_RUN)
span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN)
span.set_attribute("uipath.custom_instrumentation", True)
span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE)

# Mode travels on the event so parallel runtimes running
# different per-instance modes don't cross-contaminate.
mode = _resolve_mode(event)
final_action = data.get("final_action", "allow")
_, action_applied = _derive_results(
matched=final_action.lower() != "allow",
configured_action=final_action,
mode=mode,
)
span.set_attribute(f"{NS}.hook", hook)
span.set_attribute(f"{NS}.action_applied", action_applied)
span.set_attribute(f"{NS}.mode", mode.value.upper())

# Hook spans are summary containers — severity lives on
# the per-rule spans. Marking the hook ERROR would paint
# the whole lifecycle phase as failed when only one rule
# fired beneath it.

self._spans_created += 1

except Exception as e:
logger.warning("Failed to create governance hook span: %s", e)

def _emit_rule_span(self, event: AuditEvent) -> None:
"""Create a span for a rule evaluation."""
Expand All @@ -216,13 +173,15 @@ def _emit_rule_span(self, event: AuditEvent) -> None:
policy_id = data.get("policy_id", "unknown")
span_name = f"{NS}.rule.{policy_id}"

# See _emit_hook_span: sync dispatch on the caller's
# thread means the current OTel context is the agent's
# live span, so this rule span attaches as its child.
# Sink dispatch runs on the caller's thread (see
# :meth:`AuditManager.emit`), so the current OTel context
# is the agent's live span — this rule span attaches as
# its child without any cross-thread plumbing.
with tracer.start_as_current_span(span_name) as span:
span.set_attribute("type", SPAN_TYPE_AGENT_RUN)
span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN)
span.set_attribute("type", SPAN_TYPE_GOVERNANCE)
span.set_attribute("span_type", SPAN_TYPE_GOVERNANCE)
span.set_attribute("uipath.custom_instrumentation", True)
span.set_attribute("uipath.source", UIPATH_SOURCE)
span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE)

# Single source of truth for the emitted attributes
Expand Down
72 changes: 53 additions & 19 deletions tests/test_traces_severity.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Tests for trace-span verbosity / status semantics.

``TracesAuditSink`` emits an OpenTelemetry span for every governance
hook end and every rule evaluation. The verdict is split into
``evaluator_result`` (what the rule decided, mode-independent) and
``action_applied`` (what actually happened, derived from
evaluator_result + mode).
``TracesAuditSink`` emits an OpenTelemetry span for every rule
evaluation. HOOK_END events are dropped — no hook-summary span. The
verdict is split into ``evaluator_result`` (what the rule decided,
mode-independent) and ``action_applied`` (what actually happened,
derived from evaluator_result + mode).

Mode travels with the event (set by the emitter from its
per-instance ``EnforcementMode``) so parallel runtimes running
Expand All @@ -17,8 +17,6 @@
outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own
bucket — escalation pauses for human review, it doesn't fail the
run, so it stays Warning even in ENFORCE mode.
- Hook spans never set Status, regardless of mode or final_action.
They're summary containers; severity belongs on the per-rule span.
- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers
apply their default) and never call set_status.
"""
Expand All @@ -31,7 +29,11 @@
from uipath.core.governance import EnforcementMode

from uipath.runtime.governance._audit.base import AuditEvent, EventType
from uipath.runtime.governance._audit.traces import TracesAuditSink
from uipath.runtime.governance._audit.traces import (
SPAN_TYPE_GOVERNANCE,
UIPATH_SOURCE,
TracesAuditSink,
)


@pytest.fixture
Expand Down Expand Up @@ -89,7 +91,7 @@ def _span_attrs(span: MagicMock) -> dict[str, object]:


# ---------------------------------------------------------------------------
# Hook span — never marked ERROR
# Hook span — dropped; no span is emitted for HOOK_END
# ---------------------------------------------------------------------------


Expand All @@ -103,16 +105,46 @@ def _span_attrs(span: MagicMock) -> dict[str, object]:
("allow", EnforcementMode.AUDIT),
],
)
def test_hook_span_never_sets_error(
def test_hook_end_emits_no_span(
captured_span: MagicMock, final_action: str, mode: EnforcementMode
) -> None:
"""Hook spans are summary containers — they never carry an ERROR Status."""
"""HOOK_END events are dropped — the sink emits spans per rule only.

No span is started and the counter stays at zero regardless of the
hook's final_action or mode.
"""
sink = TracesAuditSink()
sink.emit(_hook_event(final_action=final_action, mode=mode))
assert not captured_span.set_status.called, (
f"Hook span should never set_status; called with "
f"final_action={final_action!r}, mode={mode!r}"
)

# The tracer is never asked to start a span for HOOK_END — the drop
# happens in ``emit`` before any span work.
tracer = sink._get_tracer()
tracer.start_as_current_span.assert_not_called()
assert not captured_span.set_attribute.called
assert not captured_span.set_status.called
assert sink.spans_created == 0
Comment thread
viswa-uipath marked this conversation as resolved.


# ---------------------------------------------------------------------------
# Rule span — OTEL-contract attributes (span type + source)
# ---------------------------------------------------------------------------


def test_rule_span_sets_governance_type_and_source(captured_span: MagicMock) -> None:
"""Governance rule spans carry the governance span type and source.

This is the OTEL-contract change the PR exists to make: spans must no
longer look like agent runs (``agentRun`` / CodedAgents). Guards
against a regression that recreates the span but mis-types/mis-sources
it.
"""
sink = TracesAuditSink()
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))

attrs = _span_attrs(captured_span)
assert attrs.get("type") == SPAN_TYPE_GOVERNANCE
assert attrs.get("span_type") == SPAN_TYPE_GOVERNANCE
assert attrs.get("uipath.source") == UIPATH_SOURCE


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -324,13 +356,13 @@ def test_emit_is_a_noop_when_tracer_unavailable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When ``_get_tracer`` returns None, ``emit`` short-circuits inside
both ``_emit_rule_span`` and ``_emit_hook_span`` without touching
the tracer. Regression guard for the early-return branch.
``_emit_rule_span`` without touching the tracer. Regression guard
for the early-return branch.
"""
sink = TracesAuditSink()
monkeypatch.setattr(sink, "_get_tracer", lambda: None)

# Both event types — must not raise, must not create spans.
# Rule events must not raise, must not create spans; HOOK_END is dropped.
sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
assert sink.spans_created == 0
Expand All @@ -353,6 +385,9 @@ def test_spans_created_increments_per_successful_emit(
assert sink.spans_created == 0
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
assert sink.spans_created == 1
Comment thread
viswa-uipath marked this conversation as resolved.
# A second rule increments; HOOK_END events are dropped and never do.
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
assert sink.spans_created == 2
sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
assert sink.spans_created == 2

Expand Down Expand Up @@ -466,7 +501,6 @@ def test_rule_span_exception_is_swallowed(

# Must not raise.
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))

# And no span was successfully created.
assert sink.spans_created == 0
2 changes: 1 addition & 1 deletion uv.lock

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

Loading