diff --git a/pyproject.toml b/pyproject.toml index d02f960..a7b90c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py index 1c76e43..5cb1460 100644 --- a/src/uipath/runtime/governance/_audit/traces.py +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -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 @@ -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. @@ -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.""" @@ -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 diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py index f93eab8..5b7e11a 100644 --- a/tests/test_traces_severity.py +++ b/tests/test_traces_severity.py @@ -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 @@ -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. """ @@ -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 @@ -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 # --------------------------------------------------------------------------- @@ -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 + + +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -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 @@ -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 + # 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 @@ -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 diff --git a/uv.lock b/uv.lock index 685e071..eccd2fe 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.4" +version = "0.12.5" source = { editable = "." } dependencies = [ { name = "chardet" },