Skip to content

Commit 47510eb

Browse files
authored
Merge pull request #155 from UiPath/feat/governance-trace-source-spantype
feat: align governance trace spans with OTEL contract
2 parents 58cc1e3 + fff0bc6 commit 47510eb

4 files changed

Lines changed: 68 additions & 75 deletions

File tree

‎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-runtime"
3-
version = "0.12.4"
3+
version = "0.12.5"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

‎src/uipath/runtime/governance/_audit/traces.py‎

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""OpenTelemetry traces audit sink for governance events.
22
3-
Emits an OpenTelemetry span per rule evaluation and per hook summary.
3+
Emits an OpenTelemetry span per rule evaluation.
44
This sink emits spans only — it does not resolve or stamp
55
job-execution metadata (organization, tenant, folder, job, trace id)
66
onto them. That resolution is owned by the platform-side OTel
@@ -39,13 +39,16 @@ def _package_version() -> str:
3939
# governance span. Local to the runtime trace contract — kept as a
4040
# string literal (not a cross-package import) so the runtime stays
4141
# self-contained.
42-
SPAN_TYPE_AGENT_RUN = "agentRun"
42+
SPAN_TYPE_GOVERNANCE = "governance"
4343

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

49+
# Value of the root ``uipath.source`` span attribute.
50+
UIPATH_SOURCE = "Governance"
51+
4952
# Shared attribute namespace for every governance span attribute.
5053
# Concatenated into each ``span.set_attribute`` call so the prefix
5154
# appears in one place and a future rename is a one-line change.
@@ -155,55 +158,9 @@ def _get_tracer(self) -> Any:
155158
return self._tracer if self._tracer else None
156159

157160
def emit(self, event: AuditEvent) -> None:
158-
"""Create a span for RULE_EVALUATION or HOOK_END events; drop others."""
161+
"""Create a span for RULE_EVALUATION events; drop others."""
159162
if event.event_type == EventType.RULE_EVALUATION:
160163
self._emit_rule_span(event)
161-
elif event.event_type == EventType.HOOK_END:
162-
self._emit_hook_span(event)
163-
164-
def _emit_hook_span(self, event: AuditEvent) -> None:
165-
"""Create a span for a hook summary (always emitted for each governance check)."""
166-
tracer = self._get_tracer()
167-
if tracer is None:
168-
return
169-
170-
try:
171-
data = event.data
172-
hook = event.hook or "unknown"
173-
span_name = f"governance.{hook.lower()}"
174-
175-
# Sink dispatch runs on the caller's thread (see
176-
# :meth:`AuditManager.emit`), so the current OTel context
177-
# is the agent's live span — the governance span attaches
178-
# as its child without any cross-thread plumbing.
179-
with tracer.start_as_current_span(span_name) as span:
180-
span.set_attribute("type", SPAN_TYPE_AGENT_RUN)
181-
span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN)
182-
span.set_attribute("uipath.custom_instrumentation", True)
183-
span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE)
184-
185-
# Mode travels on the event so parallel runtimes running
186-
# different per-instance modes don't cross-contaminate.
187-
mode = _resolve_mode(event)
188-
final_action = data.get("final_action", "allow")
189-
_, action_applied = _derive_results(
190-
matched=final_action.lower() != "allow",
191-
configured_action=final_action,
192-
mode=mode,
193-
)
194-
span.set_attribute(f"{NS}.hook", hook)
195-
span.set_attribute(f"{NS}.action_applied", action_applied)
196-
span.set_attribute(f"{NS}.mode", mode.value.upper())
197-
198-
# Hook spans are summary containers — severity lives on
199-
# the per-rule spans. Marking the hook ERROR would paint
200-
# the whole lifecycle phase as failed when only one rule
201-
# fired beneath it.
202-
203-
self._spans_created += 1
204-
205-
except Exception as e:
206-
logger.warning("Failed to create governance hook span: %s", e)
207164

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

219-
# See _emit_hook_span: sync dispatch on the caller's
220-
# thread means the current OTel context is the agent's
221-
# live span, so this rule span attaches as its child.
176+
# Sink dispatch runs on the caller's thread (see
177+
# :meth:`AuditManager.emit`), so the current OTel context
178+
# is the agent's live span — this rule span attaches as
179+
# its child without any cross-thread plumbing.
222180
with tracer.start_as_current_span(span_name) as span:
223-
span.set_attribute("type", SPAN_TYPE_AGENT_RUN)
224-
span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN)
181+
span.set_attribute("type", SPAN_TYPE_GOVERNANCE)
182+
span.set_attribute("span_type", SPAN_TYPE_GOVERNANCE)
225183
span.set_attribute("uipath.custom_instrumentation", True)
184+
span.set_attribute("uipath.source", UIPATH_SOURCE)
226185
span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE)
227186

228187
# Single source of truth for the emitted attributes

‎tests/test_traces_severity.py‎

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Tests for trace-span verbosity / status semantics.
22
3-
``TracesAuditSink`` emits an OpenTelemetry span for every governance
4-
hook end and every rule evaluation. The verdict is split into
5-
``evaluator_result`` (what the rule decided, mode-independent) and
6-
``action_applied`` (what actually happened, derived from
7-
evaluator_result + mode).
3+
``TracesAuditSink`` emits an OpenTelemetry span for every rule
4+
evaluation. HOOK_END events are dropped — no hook-summary span. The
5+
verdict is split into ``evaluator_result`` (what the rule decided,
6+
mode-independent) and ``action_applied`` (what actually happened,
7+
derived from evaluator_result + mode).
88
99
Mode travels with the event (set by the emitter from its
1010
per-instance ``EnforcementMode``) so parallel runtimes running
@@ -17,8 +17,6 @@
1717
outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own
1818
bucket — escalation pauses for human review, it doesn't fail the
1919
run, so it stays Warning even in ENFORCE mode.
20-
- Hook spans never set Status, regardless of mode or final_action.
21-
They're summary containers; severity belongs on the per-rule span.
2220
- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers
2321
apply their default) and never call set_status.
2422
"""
@@ -31,7 +29,11 @@
3129
from uipath.core.governance import EnforcementMode
3230

3331
from uipath.runtime.governance._audit.base import AuditEvent, EventType
34-
from uipath.runtime.governance._audit.traces import TracesAuditSink
32+
from uipath.runtime.governance._audit.traces import (
33+
SPAN_TYPE_GOVERNANCE,
34+
UIPATH_SOURCE,
35+
TracesAuditSink,
36+
)
3537

3638

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

9092

9193
# ---------------------------------------------------------------------------
92-
# Hook span — never marked ERROR
94+
# Hook span — dropped; no span is emitted for HOOK_END
9395
# ---------------------------------------------------------------------------
9496

9597

@@ -103,16 +105,46 @@ def _span_attrs(span: MagicMock) -> dict[str, object]:
103105
("allow", EnforcementMode.AUDIT),
104106
],
105107
)
106-
def test_hook_span_never_sets_error(
108+
def test_hook_end_emits_no_span(
107109
captured_span: MagicMock, final_action: str, mode: EnforcementMode
108110
) -> None:
109-
"""Hook spans are summary containers — they never carry an ERROR Status."""
111+
"""HOOK_END events are dropped — the sink emits spans per rule only.
112+
113+
No span is started and the counter stays at zero regardless of the
114+
hook's final_action or mode.
115+
"""
110116
sink = TracesAuditSink()
111117
sink.emit(_hook_event(final_action=final_action, mode=mode))
112-
assert not captured_span.set_status.called, (
113-
f"Hook span should never set_status; called with "
114-
f"final_action={final_action!r}, mode={mode!r}"
115-
)
118+
119+
# The tracer is never asked to start a span for HOOK_END — the drop
120+
# happens in ``emit`` before any span work.
121+
tracer = sink._get_tracer()
122+
tracer.start_as_current_span.assert_not_called()
123+
assert not captured_span.set_attribute.called
124+
assert not captured_span.set_status.called
125+
assert sink.spans_created == 0
126+
127+
128+
# ---------------------------------------------------------------------------
129+
# Rule span — OTEL-contract attributes (span type + source)
130+
# ---------------------------------------------------------------------------
131+
132+
133+
def test_rule_span_sets_governance_type_and_source(captured_span: MagicMock) -> None:
134+
"""Governance rule spans carry the governance span type and source.
135+
136+
This is the OTEL-contract change the PR exists to make: spans must no
137+
longer look like agent runs (``agentRun`` / CodedAgents). Guards
138+
against a regression that recreates the span but mis-types/mis-sources
139+
it.
140+
"""
141+
sink = TracesAuditSink()
142+
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
143+
144+
attrs = _span_attrs(captured_span)
145+
assert attrs.get("type") == SPAN_TYPE_GOVERNANCE
146+
assert attrs.get("span_type") == SPAN_TYPE_GOVERNANCE
147+
assert attrs.get("uipath.source") == UIPATH_SOURCE
116148

117149

118150
# ---------------------------------------------------------------------------
@@ -324,13 +356,13 @@ def test_emit_is_a_noop_when_tracer_unavailable(
324356
monkeypatch: pytest.MonkeyPatch,
325357
) -> None:
326358
"""When ``_get_tracer`` returns None, ``emit`` short-circuits inside
327-
both ``_emit_rule_span`` and ``_emit_hook_span`` without touching
328-
the tracer. Regression guard for the early-return branch.
359+
``_emit_rule_span`` without touching the tracer. Regression guard
360+
for the early-return branch.
329361
"""
330362
sink = TracesAuditSink()
331363
monkeypatch.setattr(sink, "_get_tracer", lambda: None)
332364

333-
# Both event types — must not raise, must not create spans.
365+
# Rule events must not raise, must not create spans; HOOK_END is dropped.
334366
sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
335367
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
336368
assert sink.spans_created == 0
@@ -353,6 +385,9 @@ def test_spans_created_increments_per_successful_emit(
353385
assert sink.spans_created == 0
354386
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
355387
assert sink.spans_created == 1
388+
# A second rule increments; HOOK_END events are dropped and never do.
389+
sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE))
390+
assert sink.spans_created == 2
356391
sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE))
357392
assert sink.spans_created == 2
358393

@@ -466,7 +501,6 @@ def test_rule_span_exception_is_swallowed(
466501

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

471505
# And no span was successfully created.
472506
assert sink.spans_created == 0

‎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.

0 commit comments

Comments
 (0)