Skip to content

Commit f957e34

Browse files
Add ApplyGuardrailScope for security guardrail evaluation tracing
Introduces a new OpenTelemetry tracing scope (ApplyGuardrailScope) that captures security guardrail evaluations as spans. This enables observability into content safety, policy enforcement, and risk assessment decisions made during agent operations. Port of microsoft/opentelemetry-distro-dotnet#109 (excluding ETW support). New contracts: - GuardrailDetails: Immutable dataclass capturing guardian evaluation metadata - GuardrailFinding: Represents an individual risk finding with severity and score - GuardrailDecisionType: Enum for guardian decisions (Allow, Audit, Deny, Modify, Warn) - GuardrailRiskSeverity: Constants for risk severity levels - GuardrailTargetType: Constants for guardrail targets (LlmInput, LlmOutput, etc.) New tracing scope: - ApplyGuardrailScope: Context manager scope with record_decision(), record_content_output(), and record_finding() methods Infrastructure updates: - Added microsoft.security.* and microsoft.guardian.* attribute keys to constants - Added apply_guardrail to the exporter operation name filter set - 14 unit tests covering scope creation, finding recording, and edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 83aab3d commit f957e34

10 files changed

Lines changed: 776 additions & 0 deletions

File tree

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Microsoft Agent 365 Python SDK for OpenTelemetry tracing.
55

66
from .agent_details import AgentDetails
7+
from .apply_guardrail_scope import ApplyGuardrailScope
78
from .config import (
89
configure,
910
get_tracer,
@@ -19,6 +20,11 @@
1920
unregister_span_enricher,
2021
)
2122
from .exporters.spectra_exporter_options import SpectraExporterOptions
23+
from .guardrail_decision_type import GuardrailDecisionType
24+
from .guardrail_details import GuardrailDetails
25+
from .guardrail_finding import GuardrailFinding
26+
from .guardrail_risk_severity import GuardrailRiskSeverity
27+
from .guardrail_target_type import GuardrailTargetType
2228
from .inference_call_details import InferenceCallDetails
2329
from .models.service_endpoint import ServiceEndpoint
2430
from .inference_operation_type import InferenceOperationType
@@ -80,6 +86,7 @@
8086
# Base scope class
8187
"OpenTelemetryScope",
8288
# Specific scope classes
89+
"ApplyGuardrailScope",
8390
"ExecuteToolScope",
8491
"InvokeAgentScope",
8592
"InferenceScope",
@@ -98,7 +105,12 @@
98105
"SpanDetails",
99106
"InferenceCallDetails",
100107
"ServiceEndpoint",
108+
"GuardrailDetails",
109+
"GuardrailFinding",
101110
# Enums
111+
"GuardrailDecisionType",
112+
"GuardrailRiskSeverity",
113+
"GuardrailTargetType",
102114
"InferenceOperationType",
103115
"ToolType",
104116
# OTEL gen-ai message format types
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from opentelemetry.trace import SpanKind
5+
6+
from .agent_details import AgentDetails
7+
from .constants import (
8+
APPLY_GUARDRAIL_OPERATION_NAME,
9+
CHANNEL_LINK_KEY,
10+
CHANNEL_NAME_KEY,
11+
GEN_AI_CONVERSATION_ID_KEY,
12+
GUARDIAN_ID_KEY,
13+
GUARDIAN_NAME_KEY,
14+
GUARDIAN_PROVIDER_NAME_KEY,
15+
GUARDIAN_VERSION_KEY,
16+
SECURITY_CONTENT_INPUT_HASH_KEY,
17+
SECURITY_CONTENT_INPUT_VALUE_KEY,
18+
SECURITY_CONTENT_MODIFIED_KEY,
19+
SECURITY_CONTENT_OUTPUT_VALUE_KEY,
20+
SECURITY_DECISION_CODE_KEY,
21+
SECURITY_DECISION_REASON_KEY,
22+
SECURITY_DECISION_TYPE_KEY,
23+
SECURITY_EXTERNAL_EVENT_ID_KEY,
24+
SECURITY_FINDING_EVENT_NAME,
25+
SECURITY_POLICY_DECISION_TYPE_KEY,
26+
SECURITY_POLICY_ID_KEY,
27+
SECURITY_POLICY_NAME_KEY,
28+
SECURITY_POLICY_VERSION_KEY,
29+
SECURITY_RISK_CATEGORY_KEY,
30+
SECURITY_RISK_METADATA_KEY,
31+
SECURITY_RISK_SCORE_KEY,
32+
SECURITY_RISK_SEVERITY_KEY,
33+
SECURITY_TARGET_ID_KEY,
34+
SECURITY_TARGET_TYPE_KEY,
35+
)
36+
from .guardrail_decision_type import GuardrailDecisionType
37+
from .guardrail_details import GuardrailDetails
38+
from .guardrail_finding import GuardrailFinding
39+
from .models.user_details import UserDetails
40+
from .opentelemetry_scope import OpenTelemetryScope
41+
from .request import Request
42+
from .span_details import SpanDetails
43+
44+
45+
class ApplyGuardrailScope(OpenTelemetryScope):
46+
"""Provides OpenTelemetry tracing scope for security guardrail evaluation operations.
47+
48+
Describes a security guardian evaluation. Multiple guardian spans MAY exist under a
49+
single operation span if multiple guardians are chained.
50+
51+
Guardian spans SHOULD be children of the operation span they are protecting
52+
(e.g., inference or execute_tool spans).
53+
"""
54+
55+
@staticmethod
56+
def start(
57+
details: GuardrailDetails,
58+
agent_details: AgentDetails,
59+
request: Request | None = None,
60+
user_details: UserDetails | None = None,
61+
span_details: SpanDetails | None = None,
62+
) -> "ApplyGuardrailScope":
63+
"""Creates and starts a new scope for guardrail evaluation tracing.
64+
65+
Args:
66+
details: Details of the guardrail evaluation (target, decision, guardian
67+
info, policy).
68+
agent_details: Information about the agent being guarded.
69+
request: Optional request details for conversation context.
70+
user_details: Optional human user details.
71+
span_details: Optional span configuration (parent context, timing, kind,
72+
span links).
73+
74+
Returns:
75+
A new ApplyGuardrailScope instance.
76+
"""
77+
return ApplyGuardrailScope(details, agent_details, request, user_details, span_details)
78+
79+
def __init__(
80+
self,
81+
details: GuardrailDetails,
82+
agent_details: AgentDetails,
83+
request: Request | None = None,
84+
user_details: UserDetails | None = None,
85+
span_details: SpanDetails | None = None,
86+
):
87+
"""Initialize the guardrail evaluation scope.
88+
89+
Args:
90+
details: Details of the guardrail evaluation.
91+
agent_details: Information about the agent being guarded.
92+
request: Optional request details for conversation context.
93+
user_details: Optional human user details.
94+
span_details: Optional span configuration.
95+
"""
96+
resolved_span_details = SpanDetails(
97+
span_kind=(
98+
span_details.span_kind
99+
if span_details and span_details.span_kind
100+
else SpanKind.INTERNAL
101+
),
102+
parent_context=span_details.parent_context if span_details else None,
103+
start_time=span_details.start_time if span_details else None,
104+
end_time=span_details.end_time if span_details else None,
105+
span_links=span_details.span_links if span_details else None,
106+
)
107+
108+
activity_name = self._build_activity_name(details)
109+
110+
super().__init__(
111+
operation_name=APPLY_GUARDRAIL_OPERATION_NAME,
112+
activity_name=activity_name,
113+
agent_details=agent_details,
114+
span_details=resolved_span_details,
115+
)
116+
117+
# Required attributes
118+
self.set_tag_maybe(SECURITY_DECISION_TYPE_KEY, details.decision_type.value)
119+
self.set_tag_maybe(SECURITY_TARGET_TYPE_KEY, details.target_type)
120+
121+
# Guardian attributes
122+
self.set_tag_maybe(GUARDIAN_ID_KEY, details.guardian_id)
123+
self.set_tag_maybe(GUARDIAN_NAME_KEY, details.guardian_name)
124+
self.set_tag_maybe(GUARDIAN_PROVIDER_NAME_KEY, details.guardian_provider_name)
125+
self.set_tag_maybe(GUARDIAN_VERSION_KEY, details.guardian_version)
126+
127+
# Target attributes
128+
self.set_tag_maybe(SECURITY_TARGET_ID_KEY, details.target_id)
129+
130+
# Decision attributes
131+
self.set_tag_maybe(SECURITY_DECISION_REASON_KEY, details.decision_reason)
132+
self.set_tag_maybe(SECURITY_DECISION_CODE_KEY, details.decision_code)
133+
134+
# Policy attributes
135+
self.set_tag_maybe(SECURITY_POLICY_ID_KEY, details.policy_id)
136+
self.set_tag_maybe(SECURITY_POLICY_NAME_KEY, details.policy_name)
137+
self.set_tag_maybe(SECURITY_POLICY_VERSION_KEY, details.policy_version)
138+
139+
# Content attributes
140+
self.set_tag_maybe(SECURITY_CONTENT_INPUT_HASH_KEY, details.content_input_hash)
141+
if details.content_modified is not None:
142+
self.set_tag_maybe(SECURITY_CONTENT_MODIFIED_KEY, details.content_modified)
143+
144+
# Correlation
145+
self.set_tag_maybe(SECURITY_EXTERNAL_EVENT_ID_KEY, details.external_event_id)
146+
147+
# Request context
148+
if request is not None:
149+
self.set_tag_maybe(SECURITY_CONTENT_INPUT_VALUE_KEY, request.content)
150+
self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id)
151+
if request.channel is not None:
152+
self.set_tag_maybe(CHANNEL_NAME_KEY, request.channel.name)
153+
self.set_tag_maybe(CHANNEL_LINK_KEY, request.channel.link)
154+
155+
def record_decision(
156+
self, decision_type: GuardrailDecisionType, reason: str | None = None
157+
) -> None:
158+
"""Records an updated decision on the guardrail span.
159+
160+
Use this when the guardrail decision is determined after span creation.
161+
162+
Args:
163+
decision_type: The decision type made by the guardian.
164+
reason: Optional human-readable explanation for the decision.
165+
"""
166+
self.set_tag_maybe(SECURITY_DECISION_TYPE_KEY, decision_type.value)
167+
self.set_tag_maybe(SECURITY_DECISION_REASON_KEY, reason)
168+
169+
def record_content_output(self, output_value: str) -> None:
170+
"""Records the output content value for the guardrail evaluation (opt-in).
171+
172+
Args:
173+
output_value: The output content after guardrail processing.
174+
"""
175+
self.set_tag_maybe(SECURITY_CONTENT_OUTPUT_VALUE_KEY, output_value)
176+
177+
def record_finding(self, finding: GuardrailFinding) -> None:
178+
"""Records a security finding event on the current span.
179+
180+
Multiple findings may be recorded per guardrail evaluation.
181+
182+
Args:
183+
finding: The security finding to record.
184+
185+
Raises:
186+
ValueError: When finding is None.
187+
"""
188+
if finding is None:
189+
raise ValueError("finding must not be None")
190+
191+
if self._span is None or not self._is_telemetry_enabled():
192+
return
193+
194+
attributes: dict[str, str | float | list[str]] = {
195+
SECURITY_RISK_CATEGORY_KEY: finding.risk_category,
196+
SECURITY_RISK_SEVERITY_KEY: finding.risk_severity,
197+
}
198+
199+
if finding.policy_decision_type is not None:
200+
attributes[SECURITY_POLICY_DECISION_TYPE_KEY] = finding.policy_decision_type
201+
202+
if finding.policy_id is not None:
203+
attributes[SECURITY_POLICY_ID_KEY] = finding.policy_id
204+
205+
if finding.policy_name is not None:
206+
attributes[SECURITY_POLICY_NAME_KEY] = finding.policy_name
207+
208+
if finding.policy_version is not None:
209+
attributes[SECURITY_POLICY_VERSION_KEY] = finding.policy_version
210+
211+
if finding.risk_score is not None:
212+
attributes[SECURITY_RISK_SCORE_KEY] = finding.risk_score
213+
214+
if finding.risk_metadata is not None:
215+
attributes[SECURITY_RISK_METADATA_KEY] = finding.risk_metadata
216+
217+
self._span.add_event(SECURITY_FINDING_EVENT_NAME, attributes=attributes)
218+
219+
@staticmethod
220+
def _build_activity_name(details: GuardrailDetails) -> str:
221+
"""Build the display name for the span."""
222+
if details.guardian_name:
223+
return f"{APPLY_GUARDRAIL_OPERATION_NAME} {details.guardian_name} {details.target_type}"
224+
return f"{APPLY_GUARDRAIL_OPERATION_NAME} {details.target_type}"

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
INVOKE_AGENT_OPERATION_NAME = "invoke_agent"
88
EXECUTE_TOOL_OPERATION_NAME = "execute_tool"
99
OUTPUT_MESSAGES_OPERATION_NAME = "output_messages"
10+
APPLY_GUARDRAIL_OPERATION_NAME = "apply_guardrail"
1011
CHAT_OPERATION_NAME = "chat"
1112

1213
# OpenTelemetry semantic conventions
@@ -112,3 +113,32 @@
112113
TELEMETRY_SDK_VERSION_KEY = "telemetry.sdk.version"
113114
TELEMETRY_SDK_NAME_VALUE = "A365ObservabilitySDK"
114115
TELEMETRY_SDK_LANGUAGE_VALUE = "python"
116+
117+
# Guardian attributes
118+
GUARDIAN_ID_KEY = "microsoft.guardian.id"
119+
GUARDIAN_NAME_KEY = "microsoft.guardian.name"
120+
GUARDIAN_PROVIDER_NAME_KEY = "microsoft.guardian.provider.name"
121+
GUARDIAN_VERSION_KEY = "microsoft.guardian.version"
122+
123+
# Security attributes
124+
SECURITY_DECISION_TYPE_KEY = "microsoft.security.decision.type"
125+
SECURITY_DECISION_REASON_KEY = "microsoft.security.decision.reason"
126+
SECURITY_DECISION_CODE_KEY = "microsoft.security.decision.code"
127+
SECURITY_TARGET_TYPE_KEY = "microsoft.security.target.type"
128+
SECURITY_TARGET_ID_KEY = "microsoft.security.target.id"
129+
SECURITY_POLICY_ID_KEY = "microsoft.security.policy.id"
130+
SECURITY_POLICY_NAME_KEY = "microsoft.security.policy.name"
131+
SECURITY_POLICY_VERSION_KEY = "microsoft.security.policy.version"
132+
SECURITY_CONTENT_INPUT_HASH_KEY = "microsoft.security.content.input.hash"
133+
SECURITY_CONTENT_MODIFIED_KEY = "microsoft.security.content.modified"
134+
SECURITY_EXTERNAL_EVENT_ID_KEY = "microsoft.security.external_event_id"
135+
SECURITY_CONTENT_INPUT_VALUE_KEY = "microsoft.security.content.input.value"
136+
SECURITY_CONTENT_OUTPUT_VALUE_KEY = "microsoft.security.content.output.value"
137+
138+
# Security finding event
139+
SECURITY_FINDING_EVENT_NAME = "microsoft.security.finding"
140+
SECURITY_RISK_CATEGORY_KEY = "microsoft.security.risk.category"
141+
SECURITY_RISK_SEVERITY_KEY = "microsoft.security.risk.severity"
142+
SECURITY_RISK_SCORE_KEY = "microsoft.security.risk.score"
143+
SECURITY_RISK_METADATA_KEY = "microsoft.security.risk.metadata"
144+
SECURITY_POLICY_DECISION_TYPE_KEY = "microsoft.security.policy.decision.type"

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from opentelemetry.trace import SpanKind, StatusCode
1515

1616
from ..constants import (
17+
APPLY_GUARDRAIL_OPERATION_NAME,
1718
CHAT_OPERATION_NAME,
1819
ENABLE_A365_OBSERVABILITY_EXPORTER,
1920
EXECUTE_TOOL_OPERATION_NAME,
@@ -38,6 +39,7 @@
3839
INVOKE_AGENT_OPERATION_NAME,
3940
EXECUTE_TOOL_OPERATION_NAME,
4041
OUTPUT_MESSAGES_OPERATION_NAME,
42+
APPLY_GUARDRAIL_OPERATION_NAME,
4143
CHAT_OPERATION_NAME,
4244
InferenceOperationType.CHAT.value,
4345
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from enum import Enum
5+
6+
7+
class GuardrailDecisionType(Enum):
8+
"""The decision made by a security guardian during guardrail evaluation."""
9+
10+
ALLOW = "allow"
11+
"""Content or action is allowed to proceed."""
12+
13+
AUDIT = "audit"
14+
"""Content or action is logged for review but allowed to proceed."""
15+
16+
DENY = "deny"
17+
"""Content or action is denied/blocked."""
18+
19+
MODIFY = "modify"
20+
"""Content was modified (e.g., redacted, sanitized, rewritten)."""
21+
22+
WARN = "warn"
23+
"""Content or action triggered a warning but is allowed to proceed."""
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from dataclasses import dataclass
5+
6+
from .guardrail_decision_type import GuardrailDecisionType
7+
8+
9+
@dataclass(frozen=True)
10+
class GuardrailDetails:
11+
"""Details of a guardrail evaluation for security operations tracing.
12+
13+
Args:
14+
target_type: The type of content or action the guardrail is applied to (required).
15+
See GuardrailTargetType for well-known values.
16+
decision_type: The decision made by the guardian (required).
17+
guardian_name: Human-readable name of the guardian.
18+
guardian_id: Unique identifier of the guardian.
19+
guardian_provider_name: Provider of the guardian service
20+
(e.g., azure.ai.content_safety).
21+
guardian_version: Version of the guardian.
22+
target_id: Identifier of the target being guarded.
23+
decision_reason: Human-readable explanation for the decision.
24+
decision_code: Machine-readable decision code.
25+
policy_id: Identifier of the policy that triggered the decision.
26+
policy_name: Human-readable name of the policy.
27+
policy_version: Version of the policy.
28+
content_input_hash: Hash of the input content for forensic correlation.
29+
content_modified: Whether content was modified by the guardrail.
30+
external_event_id: External correlation identifier for SIEM systems.
31+
"""
32+
33+
target_type: str
34+
decision_type: GuardrailDecisionType
35+
guardian_name: str | None = None
36+
guardian_id: str | None = None
37+
guardian_provider_name: str | None = None
38+
guardian_version: str | None = None
39+
target_id: str | None = None
40+
decision_reason: str | None = None
41+
decision_code: str | None = None
42+
policy_id: str | None = None
43+
policy_name: str | None = None
44+
policy_version: str | None = None
45+
content_input_hash: str | None = None
46+
content_modified: bool | None = None
47+
external_event_id: str | None = None

0 commit comments

Comments
 (0)