agentrust-telemetry 0.1.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. agentrust_telemetry/__init__.py +80 -0
  2. agentrust_telemetry/adapters/__init__.py +28 -0
  3. agentrust_telemetry/adapters/agt.py +207 -0
  4. agentrust_telemetry/adapters/agt_approval.py +235 -0
  5. agentrust_telemetry/adapters/agt_audit.py +177 -0
  6. agentrust_telemetry/adapters/agt_data.py +105 -0
  7. agentrust_telemetry/adapters/base.py +90 -0
  8. agentrust_telemetry/adapters/cedar.py +66 -0
  9. agentrust_telemetry/adapters/opa.py +102 -0
  10. agentrust_telemetry/client.py +113 -0
  11. agentrust_telemetry/context.py +30 -0
  12. agentrust_telemetry/data_flow.py +112 -0
  13. agentrust_telemetry/errors.py +26 -0
  14. agentrust_telemetry/evidence.py +204 -0
  15. agentrust_telemetry/otel.py +135 -0
  16. agentrust_telemetry/projection.py +49 -0
  17. agentrust_telemetry/propagation.py +102 -0
  18. agentrust_telemetry/py.typed +1 -0
  19. agentrust_telemetry/schemas/action.schema.json +39 -0
  20. agentrust_telemetry/schemas/approval.schema.json +27 -0
  21. agentrust_telemetry/schemas/common.schema.json +36 -0
  22. agentrust_telemetry/schemas/data-flow.schema.json +46 -0
  23. agentrust_telemetry/schemas/envelope.schema.json +21 -0
  24. agentrust_telemetry/schemas/evidence.schema.json +20 -0
  25. agentrust_telemetry/schemas/policy-decision.schema.json +33 -0
  26. agentrust_telemetry/schemas/usage.schema.json +80 -0
  27. agentrust_telemetry/trace_adapter.py +263 -0
  28. agentrust_telemetry/usage.py +190 -0
  29. agentrust_telemetry/validation.py +102 -0
  30. agentrust_telemetry-0.1.0a3.dist-info/METADATA +180 -0
  31. agentrust_telemetry-0.1.0a3.dist-info/RECORD +35 -0
  32. agentrust_telemetry-0.1.0a3.dist-info/WHEEL +5 -0
  33. agentrust_telemetry-0.1.0a3.dist-info/licenses/LICENSE +21 -0
  34. agentrust_telemetry-0.1.0a3.dist-info/licenses/NOTICE +4 -0
  35. agentrust_telemetry-0.1.0a3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,177 @@
1
+ """Strict adapters for Agent Mesh audit entries shipped by AGT core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from .base import EventFactory
10
+
11
+
12
+ _AUDIT_NAMESPACE = uuid.UUID("398428fd-2730-498f-8ed8-6b4290668171")
13
+ _POLICY_EVENTS = {"policy_evaluation", "policy_violation"}
14
+ _ACTION_EVENTS = {"tool_invocation", "tool_blocked", "action"}
15
+
16
+
17
+ def agt_audit_policy_decision(
18
+ factory: EventFactory,
19
+ source: Any,
20
+ *,
21
+ run_id: str,
22
+ policy_engine_version: str,
23
+ bundle_digest: dict[str, str],
24
+ resource_type: str,
25
+ evaluation_duration_ns: int = 0,
26
+ enforcement_mode: str = "enforce",
27
+ ) -> dict[str, Any]:
28
+ """Map a policy-oriented Agent Mesh AuditEntry without copying its data."""
29
+ event_type = _required_string(_field(source, "event_type"), "event_type")
30
+ if event_type not in _POLICY_EVENTS:
31
+ raise ValueError(f"AGT audit event is not a policy event: {event_type!r}")
32
+ decision = _policy_decision(_field(source, "policy_decision"))
33
+ entry_id = _required_string(_field(source, "entry_id"), "entry_id")
34
+ policy: dict[str, Any] = {
35
+ "engine": "agt",
36
+ "engine_version": _required_string(policy_engine_version, "policy_engine_version"),
37
+ "bundle_digest": bundle_digest,
38
+ }
39
+ matched_rule = _field(source, "matched_rule")
40
+ if matched_rule is not None:
41
+ policy["policy_id"] = _required_string(matched_rule, "matched_rule")
42
+ return factory.build(
43
+ "policy.decision",
44
+ run_id=run_id,
45
+ agent_id=_required_string(_field(source, "agent_did"), "agent_did"),
46
+ event_id=_source_event_id("policy", entry_id),
47
+ time_unix_nano=_datetime_ns(_field(source, "timestamp"), "timestamp"),
48
+ trace_id=_optional_string(_field(source, "trace_id"), "trace_id"),
49
+ decision=decision,
50
+ policy=policy,
51
+ action_type=_required_string(_field(source, "action"), "action"),
52
+ resource_type=_required_string(resource_type, "resource_type"),
53
+ enforcement_mode=enforcement_mode,
54
+ evaluation_duration_ns=evaluation_duration_ns,
55
+ reason_codes=[f"agt.audit:{event_type}"],
56
+ )
57
+
58
+
59
+ def agt_audit_action(
60
+ factory: EventFactory,
61
+ source: Any,
62
+ *,
63
+ run_id: str,
64
+ action_digest: dict[str, str],
65
+ action_kind: str,
66
+ operation: str,
67
+ duration_ns: int | None = None,
68
+ ) -> dict[str, Any]:
69
+ """Map an action audit row using a caller-computed full action digest."""
70
+ event_type = _required_string(_field(source, "event_type"), "event_type")
71
+ if event_type not in _ACTION_EVENTS:
72
+ raise ValueError(f"AGT audit event is not an action event: {event_type!r}")
73
+ entry_id = _required_string(_field(source, "entry_id"), "entry_id")
74
+ outcome = "denied" if event_type == "tool_blocked" else _action_outcome(
75
+ _field(source, "outcome")
76
+ )
77
+ resolved_duration = duration_ns
78
+ if resolved_duration is None:
79
+ resolved_duration = _audit_duration(source)
80
+ target: dict[str, str] | None = None
81
+ target_did = _field(source, "target_did")
82
+ if target_did is not None:
83
+ target = {"kind": "agent", "id": _required_string(target_did, "target_did")}
84
+ return factory.build(
85
+ "action.executed",
86
+ run_id=run_id,
87
+ agent_id=_required_string(_field(source, "agent_did"), "agent_did"),
88
+ event_id=_source_event_id("action", entry_id),
89
+ time_unix_nano=_datetime_ns(_field(source, "timestamp"), "timestamp"),
90
+ trace_id=_optional_string(_field(source, "trace_id"), "trace_id"),
91
+ action_id=entry_id,
92
+ action_kind=action_kind,
93
+ action_name=_required_string(_field(source, "action"), "action"),
94
+ operation=_required_string(operation, "operation"),
95
+ outcome=outcome,
96
+ duration_ns=resolved_duration,
97
+ action_digest=action_digest,
98
+ **({"target": target} if target else {}),
99
+ )
100
+
101
+
102
+ def _policy_decision(value: Any) -> str:
103
+ mapping = {
104
+ "allow": "allow",
105
+ "allowed": "allow",
106
+ "deny": "deny",
107
+ "denied": "deny",
108
+ "require_approval": "challenge",
109
+ "requires_approval": "challenge",
110
+ "review": "challenge",
111
+ "not_applicable": "not_applicable",
112
+ "error": "error",
113
+ }
114
+ if value not in mapping:
115
+ raise ValueError(f"unsupported AGT audit policy decision: {value!r}")
116
+ return mapping[value]
117
+
118
+
119
+ def _action_outcome(value: Any) -> str:
120
+ mapping = {
121
+ "success": "success",
122
+ "failure": "error",
123
+ "error": "error",
124
+ "denied": "denied",
125
+ "cancelled": "cancelled",
126
+ "timeout": "timeout",
127
+ }
128
+ if value not in mapping:
129
+ raise ValueError(f"unsupported AGT audit action outcome: {value!r}")
130
+ return mapping[value]
131
+
132
+
133
+ def _audit_duration(source: Any) -> int:
134
+ issued = _field(source, "issued_at")
135
+ completed = _field(source, "completed_at")
136
+ if issued is None or completed is None:
137
+ raise ValueError(
138
+ "AGT action audit requires duration_ns or both issued_at and completed_at"
139
+ )
140
+ issued_ns = _datetime_ns(issued, "issued_at")
141
+ completed_ns = _datetime_ns(completed, "completed_at")
142
+ if completed_ns < issued_ns:
143
+ raise ValueError("AGT completed_at cannot predate issued_at")
144
+ return completed_ns - issued_ns
145
+
146
+
147
+ def _source_event_id(kind: str, source_id: str) -> str:
148
+ return str(uuid.uuid5(_AUDIT_NAMESPACE, f"{kind}:{source_id}"))
149
+
150
+
151
+ def _field(source: Any, name: str) -> Any:
152
+ if isinstance(source, dict):
153
+ return source.get(name)
154
+ return getattr(source, name, None)
155
+
156
+
157
+ def _required_string(value: Any, field: str) -> str:
158
+ if not isinstance(value, str) or not value:
159
+ raise ValueError(f"AGT audit {field} must be a non-empty string")
160
+ return value
161
+
162
+
163
+ def _optional_string(value: Any, field: str) -> str | None:
164
+ return None if value is None else _required_string(value, field)
165
+
166
+
167
+ def _datetime_ns(value: Any, field: str) -> int:
168
+ if not isinstance(value, datetime) or value.tzinfo is None:
169
+ raise ValueError(f"AGT audit {field} must be a timezone-aware datetime")
170
+ utc = value.astimezone(timezone.utc)
171
+ epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
172
+ delta = utc - epoch
173
+ return (
174
+ delta.days * 86_400_000_000_000
175
+ + delta.seconds * 1_000_000_000
176
+ + delta.microseconds * 1_000
177
+ )
@@ -0,0 +1,105 @@
1
+ """AGT DataLabel and DataAccessDecision adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from ..data_flow import (
9
+ ClassificationResult,
10
+ DataEndpoint,
11
+ classified_data_flow,
12
+ )
13
+ from .base import EventFactory
14
+
15
+
16
+ _LEVELS = {
17
+ 0: "public",
18
+ 1: "internal",
19
+ 2: "confidential",
20
+ 3: "restricted",
21
+ 4: "top_secret",
22
+ }
23
+
24
+
25
+ def agt_data_classification(source: Any) -> ClassificationResult:
26
+ """Map only AGT's ordered sensitivity tier; omit auxiliary label content."""
27
+ classification = _field(source, "classification")
28
+ raw = classification.value if isinstance(classification, Enum) else classification
29
+ if isinstance(raw, bool) or raw not in _LEVELS:
30
+ raise ValueError(f"unsupported AGT data classification: {raw!r}")
31
+ return ClassificationResult(
32
+ taxonomy="agt.data_classification.v1",
33
+ value=_LEVELS[raw],
34
+ producer="agt.data_label",
35
+ )
36
+
37
+
38
+ def agt_data_access_flow(
39
+ factory: EventFactory,
40
+ decision: Any,
41
+ *,
42
+ run_id: str,
43
+ direction: str,
44
+ source: DataEndpoint,
45
+ destination: DataEndpoint,
46
+ purpose: str,
47
+ content_digest: dict[str, str] | None = None,
48
+ size_bytes: int | None = None,
49
+ token_count: int | None = None,
50
+ media_type: str | None = None,
51
+ transformation: str | None = None,
52
+ **envelope: Any,
53
+ ) -> dict[str, Any]:
54
+ """Map an AGT DataAccessDecision without copying labels or free-form reason."""
55
+ allowed = _field(decision, "allowed")
56
+ if not isinstance(allowed, bool):
57
+ raise ValueError("AGT data access allowed must be a boolean")
58
+ result = agt_data_classification(_field(decision, "data_label"))
59
+ agent_id = _field(decision, "agent_id")
60
+ if not isinstance(agent_id, str) or not agent_id:
61
+ raise ValueError("AGT data access agent_id must be a non-empty string")
62
+ class FixedClassifier:
63
+ def classify(self, value: Any) -> ClassificationResult:
64
+ return result
65
+
66
+ return classified_data_flow(
67
+ factory,
68
+ FixedClassifier(),
69
+ None,
70
+ run_id=run_id,
71
+ agent_id=agent_id,
72
+ time_unix_nano=_datetime_ns(_field(decision, "evaluated_at")),
73
+ direction=direction,
74
+ source=source,
75
+ destination=destination,
76
+ purpose=purpose,
77
+ policy_decision="allow" if allowed else "deny",
78
+ content_digest=content_digest,
79
+ size_bytes=size_bytes,
80
+ token_count=token_count,
81
+ media_type=media_type,
82
+ transformation=transformation,
83
+ **envelope,
84
+ )
85
+
86
+
87
+ def _field(source: Any, name: str) -> Any:
88
+ if isinstance(source, dict):
89
+ return source.get(name)
90
+ return getattr(source, name, None)
91
+
92
+
93
+ def _datetime_ns(value: Any) -> int:
94
+ from datetime import datetime, timezone
95
+
96
+ if not isinstance(value, datetime) or value.tzinfo is None:
97
+ raise ValueError("AGT data access evaluated_at must be a timezone-aware datetime")
98
+ utc = value.astimezone(timezone.utc)
99
+ epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
100
+ delta = utc - epoch
101
+ return (
102
+ delta.days * 86_400_000_000_000
103
+ + delta.seconds * 1_000_000_000
104
+ + delta.microseconds * 1_000
105
+ )
@@ -0,0 +1,90 @@
1
+ """Validated envelope construction shared by all adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from copy import deepcopy
8
+ from typing import Any, Callable
9
+
10
+ from ..validation import SchemaValidator
11
+
12
+
13
+ class EventFactory:
14
+ def __init__(
15
+ self,
16
+ validator: SchemaValidator,
17
+ *,
18
+ producer_name: str,
19
+ producer_version: str,
20
+ producer_instance_id: str | None = None,
21
+ clock_ns: Callable[[], int] = time.time_ns,
22
+ event_id_factory: Callable[[], uuid.UUID] = uuid.uuid4,
23
+ ) -> None:
24
+ self._validator = validator
25
+ self._producer = {
26
+ "name": producer_name,
27
+ "version": producer_version,
28
+ **({"instance_id": producer_instance_id} if producer_instance_id else {}),
29
+ }
30
+ self._clock_ns = clock_ns
31
+ self._event_id_factory = event_id_factory
32
+
33
+ def build(
34
+ self,
35
+ event_type: str,
36
+ *,
37
+ run_id: str,
38
+ agent_id: str | None = None,
39
+ workflow_id: str | None = None,
40
+ parent_agent_id: str | None = None,
41
+ task_id: str | None = None,
42
+ trace_id: str | None = None,
43
+ span_id: str | None = None,
44
+ event_id: str | None = None,
45
+ time_unix_nano: int | str | None = None,
46
+ **payload: Any,
47
+ ) -> dict[str, Any]:
48
+ reserved = {
49
+ "spec_version", "event_id", "event_type", "time_unix_nano", "run_id",
50
+ "producer", "agent_id", "workflow_id", "parent_agent_id", "task_id",
51
+ "trace_id", "span_id",
52
+ }
53
+ collision = sorted(reserved.intersection(payload))
54
+ if collision:
55
+ raise ValueError(f"payload cannot override envelope fields: {collision}")
56
+ timestamp = self._clock_ns() if time_unix_nano is None else time_unix_nano
57
+ event: dict[str, Any] = {
58
+ "spec_version": "0.1.0-alpha.3",
59
+ "event_id": event_id or str(self._event_id_factory()),
60
+ "event_type": event_type,
61
+ "time_unix_nano": _unix_nano(timestamp),
62
+ "run_id": run_id,
63
+ "producer": deepcopy(self._producer),
64
+ **{
65
+ key: _unix_nano(value) if key.endswith("_at_unix_nano") else value
66
+ for key, value in payload.items()
67
+ },
68
+ }
69
+ optional = {
70
+ "agent_id": agent_id,
71
+ "workflow_id": workflow_id,
72
+ "parent_agent_id": parent_agent_id,
73
+ "task_id": task_id,
74
+ "trace_id": trace_id,
75
+ "span_id": span_id,
76
+ }
77
+ event.update({key: value for key, value in optional.items() if value is not None})
78
+ self._validator.validate(event)
79
+ return event
80
+
81
+
82
+ def _unix_nano(value: Any) -> str:
83
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
84
+ raise TypeError("Unix nanosecond timestamps must be integers or decimal strings")
85
+ text = str(value)
86
+ if not text.isascii() or not text.isdecimal() or (len(text) > 1 and text.startswith("0")):
87
+ raise ValueError("Unix nanosecond timestamps must be canonical non-negative decimals")
88
+ if len(text) > 20:
89
+ raise ValueError("Unix nanosecond timestamps cannot exceed 20 digits")
90
+ return text
@@ -0,0 +1,66 @@
1
+ """Cedar authorization-response adapter with explicit safe fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Iterable
7
+
8
+ from .base import EventFactory
9
+
10
+
11
+ def cedar_policy_decision(
12
+ factory: EventFactory,
13
+ *,
14
+ run_id: str,
15
+ agent_id: str,
16
+ decision: str,
17
+ cedar_version: str,
18
+ bundle_digest: dict[str, str],
19
+ action_type: str,
20
+ resource_type: str,
21
+ evaluation_duration_ns: int,
22
+ determining_policy_ids: Iterable[str] = (),
23
+ error_codes: Iterable[str] = (),
24
+ enforcement_mode: str = "enforce",
25
+ input_digest: dict[str, str] | None = None,
26
+ **envelope: Any,
27
+ ) -> dict[str, Any]:
28
+ normalized = decision.lower()
29
+ if normalized not in {"allow", "deny"}:
30
+ raise ValueError("Cedar decision must be Allow or Deny")
31
+ policy_ids = _identifiers(determining_policy_ids, "determining_policy_ids")
32
+ errors = _identifiers(error_codes, "error_codes")
33
+ if any(re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", value) is None for value in errors):
34
+ raise ValueError("Cedar error_codes must be identifiers, not error messages")
35
+ reason_codes = [*(f"cedar.policy:{value}" for value in policy_ids), *(f"cedar.error:{value}" for value in errors)]
36
+ if len(reason_codes) > 32:
37
+ raise ValueError("Cedar reasons and errors exceed the 32-code contract limit")
38
+ policy: dict[str, Any] = {
39
+ "engine": "cedar",
40
+ "engine_version": cedar_version,
41
+ "bundle_digest": bundle_digest,
42
+ **({"policy_id": policy_ids[0]} if len(policy_ids) == 1 else {}),
43
+ **({"input_digest": input_digest} if input_digest else {}),
44
+ }
45
+ return factory.build(
46
+ "policy.decision",
47
+ run_id=run_id,
48
+ agent_id=agent_id,
49
+ decision=normalized,
50
+ policy=policy,
51
+ action_type=action_type,
52
+ resource_type=resource_type,
53
+ enforcement_mode=enforcement_mode,
54
+ evaluation_duration_ns=evaluation_duration_ns,
55
+ reason_codes=reason_codes,
56
+ **envelope,
57
+ )
58
+
59
+
60
+ def _identifiers(values: Iterable[str], field: str) -> list[str]:
61
+ if isinstance(values, (str, bytes)):
62
+ raise ValueError(f"Cedar {field} must be an iterable of strings, not a string")
63
+ normalized = sorted(set(values))
64
+ if any(not isinstance(value, str) or not value for value in normalized):
65
+ raise ValueError(f"Cedar {field} must contain non-empty strings")
66
+ return normalized
@@ -0,0 +1,102 @@
1
+ """Open Policy Agent decision-log adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Callable
9
+
10
+ from .base import EventFactory
11
+
12
+
13
+ DecisionMapper = Callable[[Any], str]
14
+ _OPA_EVENT_NAMESPACE = uuid.UUID("881f86d8-7573-4d35-98cb-c00b934cc04f")
15
+ _TIMESTAMP = re.compile(
16
+ r"^(?P<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(?P<fraction>\d{1,9}))?Z$"
17
+ )
18
+
19
+
20
+ def opa_decision_log(
21
+ factory: EventFactory,
22
+ decision_log: dict[str, Any],
23
+ *,
24
+ run_id: str,
25
+ agent_id: str,
26
+ action_type: str,
27
+ resource_type: str,
28
+ bundle_digest: dict[str, str],
29
+ opa_version: str | None = None,
30
+ enforcement_mode: str = "enforce",
31
+ result_mapper: DecisionMapper | None = None,
32
+ ) -> dict[str, Any]:
33
+ source_id = decision_log.get("decision_id")
34
+ if not isinstance(source_id, str) or not source_id:
35
+ raise ValueError("OPA decision log requires a non-empty decision_id")
36
+ mapper = result_mapper or _boolean_decision
37
+ decision = mapper(decision_log.get("result"))
38
+ if decision not in {"allow", "deny", "challenge", "not_applicable", "error"}:
39
+ raise ValueError(f"OPA result mapper returned unsupported decision: {decision!r}")
40
+ labels = decision_log.get("labels", {})
41
+ if not isinstance(labels, dict):
42
+ raise ValueError("OPA labels must be an object")
43
+ version = opa_version or labels.get("version")
44
+ if not isinstance(version, str) or not version:
45
+ raise ValueError("OPA version is required explicitly or in labels.version")
46
+ metrics = decision_log.get("metrics", {})
47
+ if not isinstance(metrics, dict):
48
+ raise ValueError("OPA metrics must be an object")
49
+ duration = metrics.get("timer_rego_query_eval_ns", 0)
50
+ if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0:
51
+ raise ValueError("OPA timer_rego_query_eval_ns must be a non-negative integer")
52
+ path = decision_log.get("path")
53
+ policy: dict[str, Any] = {
54
+ "engine": "opa",
55
+ "engine_version": version,
56
+ "bundle_digest": bundle_digest,
57
+ **({"policy_id": path.lstrip("/")} if isinstance(path, str) and path else {}),
58
+ }
59
+ ids = decision_log.get("ids", [])
60
+ if not isinstance(ids, list) or any(not isinstance(value, str) or not value for value in ids):
61
+ raise ValueError("OPA ids must be an array of non-empty strings")
62
+ if len(ids) > 32:
63
+ raise ValueError("OPA ids exceed the 32-code contract limit")
64
+ return factory.build(
65
+ "policy.decision",
66
+ run_id=run_id,
67
+ agent_id=agent_id,
68
+ event_id=str(uuid.uuid5(_OPA_EVENT_NAMESPACE, source_id)),
69
+ time_unix_nano=_timestamp_ns(decision_log["timestamp"])
70
+ if "timestamp" in decision_log
71
+ else None,
72
+ trace_id=decision_log.get("trace_id"),
73
+ span_id=decision_log.get("span_id"),
74
+ decision=decision,
75
+ policy=policy,
76
+ action_type=action_type,
77
+ resource_type=resource_type,
78
+ enforcement_mode=enforcement_mode,
79
+ evaluation_duration_ns=duration,
80
+ reason_codes=[f"opa.rule:{value}" for value in ids],
81
+ )
82
+
83
+
84
+ def _boolean_decision(result: Any) -> str:
85
+ if result is True:
86
+ return "allow"
87
+ if result is False:
88
+ return "deny"
89
+ if result is None:
90
+ return "not_applicable"
91
+ raise ValueError("OPA non-boolean result requires an explicit result_mapper")
92
+
93
+
94
+ def _timestamp_ns(value: Any) -> int:
95
+ if not isinstance(value, str):
96
+ raise ValueError("OPA timestamp must be an RFC 3339 UTC string")
97
+ match = _TIMESTAMP.fullmatch(value)
98
+ if match is None:
99
+ raise ValueError("OPA timestamp must use RFC 3339 UTC form ending in Z")
100
+ base = datetime.fromisoformat(match.group("date")).replace(tzinfo=timezone.utc)
101
+ fraction = (match.group("fraction") or "").ljust(9, "0")
102
+ return int(base.timestamp()) * 1_000_000_000 + int(fraction or "0")
@@ -0,0 +1,113 @@
1
+ """Validated, caller-owned telemetry emission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Protocol
8
+
9
+ from .context import ContextIds, active_context_ids, current_span
10
+ from .errors import ContextMismatchError
11
+ from .projection import log_record, span_attributes
12
+ from .validation import SchemaValidator
13
+
14
+
15
+ class SpanLike(Protocol):
16
+ def add_event(self, name: str, attributes: dict[str, Any], timestamp: int) -> None: ...
17
+ def get_span_context(self) -> Any: ...
18
+
19
+
20
+ class LogEmitter(Protocol):
21
+ def emit(self, record: dict[str, Any]) -> None: ...
22
+
23
+
24
+ class EvidenceSink(Protocol):
25
+ def append(self, event: dict[str, Any]) -> Any: ...
26
+
27
+
28
+ class MetricEmitter(Protocol):
29
+ def emit(self, event: dict[str, Any]) -> bool: ...
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class EmitResult:
34
+ accepted: bool
35
+ span_event_emitted: bool
36
+ log_emitted: bool
37
+ context: ContextIds | None
38
+ projection_errors: tuple[str, ...] = ()
39
+ evidence_persisted: bool = False
40
+ metrics_emitted: bool = False
41
+
42
+
43
+ class TelemetryClient:
44
+ def __init__(
45
+ self,
46
+ validator: SchemaValidator,
47
+ *,
48
+ span_resolver: Callable[[], SpanLike | None] | None = None,
49
+ log_emitter: LogEmitter | None = None,
50
+ evidence_sink: EvidenceSink | None = None,
51
+ metric_emitter: MetricEmitter | None = None,
52
+ ):
53
+ self._validator = validator
54
+ self._span_resolver = span_resolver
55
+ self._log_emitter = log_emitter
56
+ self._evidence_sink = evidence_sink
57
+ self._metric_emitter = metric_emitter
58
+
59
+ def emit(self, event: dict[str, Any]) -> EmitResult:
60
+ self._validator.validate(event)
61
+ resolver = self._span_resolver or current_span
62
+ span = resolver()
63
+ context = active_context_ids(lambda: span)
64
+ if context:
65
+ if event.get("trace_id") not in (None, context.trace_id):
66
+ raise ContextMismatchError("event trace_id disagrees with active span")
67
+ if event.get("span_id") not in (None, context.span_id):
68
+ raise ContextMismatchError("event span_id disagrees with active span")
69
+
70
+ evidence_persisted = False
71
+ if self._evidence_sink is not None:
72
+ # Evidence is accepted after correlation validation and before any
73
+ # best-effort operational projection.
74
+ self._evidence_sink.append(deepcopy(event))
75
+ evidence_persisted = True
76
+
77
+ errors: list[str] = []
78
+ span_emitted = False
79
+ if span is not None and context is not None:
80
+ try:
81
+ span.add_event(
82
+ event["event_type"],
83
+ attributes=span_attributes(event),
84
+ timestamp=int(event["time_unix_nano"]),
85
+ )
86
+ span_emitted = True
87
+ except Exception as exc: # exporter implementations are external
88
+ errors.append(f"span projection failed: {type(exc).__name__}: {exc}")
89
+
90
+ log_emitted = False
91
+ if self._log_emitter is not None:
92
+ try:
93
+ self._log_emitter.emit(log_record(event, context))
94
+ log_emitted = True
95
+ except Exception as exc: # exporter implementations are external
96
+ errors.append(f"log projection failed: {type(exc).__name__}: {exc}")
97
+
98
+ metrics_emitted = False
99
+ if self._metric_emitter is not None:
100
+ try:
101
+ metrics_emitted = self._metric_emitter.emit(deepcopy(event))
102
+ except Exception as exc:
103
+ errors.append(f"metric projection failed: {type(exc).__name__}: {exc}")
104
+
105
+ return EmitResult(
106
+ True,
107
+ span_emitted,
108
+ log_emitted,
109
+ context,
110
+ tuple(errors),
111
+ evidence_persisted,
112
+ metrics_emitted,
113
+ )
@@ -0,0 +1,30 @@
1
+ """Optional OpenTelemetry context integration without global configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ContextIds:
11
+ trace_id: str
12
+ span_id: str
13
+
14
+
15
+ def current_span() -> Any | None:
16
+ try:
17
+ from opentelemetry import trace
18
+ except ImportError:
19
+ return None
20
+ return trace.get_current_span()
21
+
22
+
23
+ def active_context_ids(span_resolver: Callable[[], Any | None] = current_span) -> ContextIds | None:
24
+ span = span_resolver()
25
+ if span is None:
26
+ return None
27
+ context = span.get_span_context()
28
+ if not getattr(context, "is_valid", False):
29
+ return None
30
+ return ContextIds(trace_id=f"{context.trace_id:032x}", span_id=f"{context.span_id:016x}")