trazio-telemetry 0.3.0__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.
- hyperlake_telemetry/__init__.py +20 -0
- trazio_telemetry/__init__.py +16 -0
- trazio_telemetry/client.py +819 -0
- trazio_telemetry/integrations/__init__.py +6 -0
- trazio_telemetry/integrations/openinference.py +18 -0
- trazio_telemetry/integrations/temporal.py +23 -0
- trazio_telemetry/semantic_query.py +143 -0
- trazio_telemetry-0.3.0.dist-info/METADATA +113 -0
- trazio_telemetry-0.3.0.dist-info/RECORD +11 -0
- trazio_telemetry-0.3.0.dist-info/WHEEL +5 -0
- trazio_telemetry-0.3.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Deprecated alias: the SDK is now ``trazio_telemetry`` (package ``trazio-telemetry``)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
import trazio_telemetry as _trazio
|
|
7
|
+
from trazio_telemetry import * # noqa: F401,F403
|
|
8
|
+
from trazio_telemetry import __all__, __version__ # noqa: F401
|
|
9
|
+
from trazio_telemetry import client, integrations, semantic_query # noqa: F401
|
|
10
|
+
|
|
11
|
+
warnings.warn(
|
|
12
|
+
"hyperlake_telemetry is renamed to trazio_telemetry; update your imports",
|
|
13
|
+
DeprecationWarning,
|
|
14
|
+
stacklevel=2,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
for _name in ("client", "semantic_query", "integrations", "integrations.openinference", "integrations.temporal"):
|
|
18
|
+
__import__(f"trazio_telemetry.{_name}")
|
|
19
|
+
sys.modules[f"{__name__}.{_name}"] = sys.modules[f"trazio_telemetry.{_name}"]
|
|
20
|
+
del _name, _trazio
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Standards-first OTLP telemetry and artifact helpers."""
|
|
2
|
+
|
|
3
|
+
from .client import AdminClient, Client, Privacy, Run, Span, stable_event_id
|
|
4
|
+
from .semantic_query import (
|
|
5
|
+
DeterministicSemanticPlanner,
|
|
6
|
+
ModelSemanticPlanner,
|
|
7
|
+
deterministic_plan,
|
|
8
|
+
normalize_semantic_query,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AdminClient", "Client", "Privacy", "Run", "Span", "stable_event_id",
|
|
13
|
+
"DeterministicSemanticPlanner", "ModelSemanticPlanner",
|
|
14
|
+
"deterministic_plan", "normalize_semantic_query",
|
|
15
|
+
]
|
|
16
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"""Small dependency-free OTLP/HTTP client with explicit privacy controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import ssl
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import AbstractContextManager
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Iterable, Mapping, Optional
|
|
18
|
+
from urllib.error import HTTPError, URLError
|
|
19
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
20
|
+
from urllib.request import Request, HTTPRedirectHandler, HTTPSHandler, build_opener
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _NoCredentialRedirect(HTTPRedirectHandler):
|
|
24
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
25
|
+
raise HTTPError(req.full_url, code, "Redirect refused", headers, fp)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def urlopen(request, *, timeout, context):
|
|
29
|
+
return build_opener(_NoCredentialRedirect(), HTTPSHandler(context=context)).open(request, timeout=timeout)
|
|
30
|
+
|
|
31
|
+
import certifi
|
|
32
|
+
|
|
33
|
+
from .semantic_query import DeterministicSemanticPlanner, normalize_semantic_query
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SENSITIVE_KEY = re.compile(
|
|
37
|
+
r"(^|[._-])(authorization|cookie|password|passwd|secret|token|api[._-]?key|private[._-]?key)([._-]|$)",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _segment(value: Any) -> str:
|
|
43
|
+
return quote(str(value), safe="")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decode_jwt(token: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
47
|
+
if not token:
|
|
48
|
+
return None
|
|
49
|
+
parts = token.split(".")
|
|
50
|
+
if len(parts) != 3:
|
|
51
|
+
raise ValueError("tenant_jwt must be a three-segment JWT")
|
|
52
|
+
try:
|
|
53
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
54
|
+
claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
|
|
55
|
+
if not claims.get("tenant_id") or not claims.get("pipeline_id"):
|
|
56
|
+
raise ValueError("scope missing")
|
|
57
|
+
if claims.get("exp") and int(claims["exp"]) <= int(time.time()):
|
|
58
|
+
raise ValueError("expired")
|
|
59
|
+
return claims
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise ValueError(f"tenant_jwt claims are invalid: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stable_event_id(*parts: Any) -> str:
|
|
65
|
+
"""Return a deterministic identity for a logical event or evaluation."""
|
|
66
|
+
canonical = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)
|
|
67
|
+
return "evt_" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Privacy:
|
|
72
|
+
"""Content and secret handling applied before telemetry leaves the process."""
|
|
73
|
+
|
|
74
|
+
capture_content: bool = False
|
|
75
|
+
redacted_keys: tuple[str, ...] = ()
|
|
76
|
+
max_string_length: int = 4096
|
|
77
|
+
replacement: str = "[REDACTED]"
|
|
78
|
+
|
|
79
|
+
def sanitize(self, value: Any, *, content: bool = False) -> Any:
|
|
80
|
+
if content and not self.capture_content:
|
|
81
|
+
return self.replacement
|
|
82
|
+
return self._walk(value)
|
|
83
|
+
|
|
84
|
+
def _walk(self, value: Any, key: str = "") -> Any:
|
|
85
|
+
if key in self.redacted_keys or _SENSITIVE_KEY.search(key):
|
|
86
|
+
return self.replacement
|
|
87
|
+
if isinstance(value, Mapping):
|
|
88
|
+
return {str(k): self._walk(v, str(k)) for k, v in value.items()}
|
|
89
|
+
if isinstance(value, (list, tuple)):
|
|
90
|
+
return [self._walk(item, key) for item in value]
|
|
91
|
+
if isinstance(value, str) and len(value) > self.max_string_length:
|
|
92
|
+
return value[: self.max_string_length] + "…[TRUNCATED]"
|
|
93
|
+
if value is None or isinstance(value, (str, bool, int, float)):
|
|
94
|
+
return value
|
|
95
|
+
return str(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _any_value(value: Any) -> Dict[str, Any]:
|
|
99
|
+
if value is None:
|
|
100
|
+
return {"stringValue": ""}
|
|
101
|
+
if isinstance(value, bool):
|
|
102
|
+
return {"boolValue": value}
|
|
103
|
+
if isinstance(value, int):
|
|
104
|
+
return {"intValue": str(value)}
|
|
105
|
+
if isinstance(value, float):
|
|
106
|
+
return {"doubleValue": value}
|
|
107
|
+
if isinstance(value, (dict, list, tuple)):
|
|
108
|
+
return {"stringValue": json.dumps(value, sort_keys=True, separators=(",", ":"))}
|
|
109
|
+
return {"stringValue": str(value)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _attributes(values: Mapping[str, Any]) -> list[Dict[str, Any]]:
|
|
113
|
+
return [{"key": str(k), "value": _any_value(v)} for k, v in values.items() if v is not None]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Span(AbstractContextManager["Span"]):
|
|
118
|
+
client: "Client"
|
|
119
|
+
name: str
|
|
120
|
+
trace_id: str
|
|
121
|
+
span_id: str
|
|
122
|
+
parent_span_id: Optional[str]
|
|
123
|
+
attributes: Dict[str, Any]
|
|
124
|
+
start_ns: int = field(default_factory=time.time_ns)
|
|
125
|
+
end_ns: Optional[int] = None
|
|
126
|
+
status_code: int = 0
|
|
127
|
+
status_message: str = ""
|
|
128
|
+
events: list[Dict[str, Any]] = field(default_factory=list)
|
|
129
|
+
_ended: bool = False
|
|
130
|
+
|
|
131
|
+
def __enter__(self) -> "Span":
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
135
|
+
if exc is not None:
|
|
136
|
+
self.status_code = 2
|
|
137
|
+
self.status_message = str(exc)
|
|
138
|
+
self.add_event("exception", {"exception.type": exc_type.__name__, "exception.message": str(exc)})
|
|
139
|
+
self.end()
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def set_attribute(self, key: str, value: Any, *, content: bool = False) -> "Span":
|
|
143
|
+
self.attributes[key] = self.client.privacy.sanitize(value, content=content)
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def add_event(self, name: str, attributes: Optional[Mapping[str, Any]] = None) -> "Span":
|
|
147
|
+
clean = self.client.privacy.sanitize(dict(attributes or {}))
|
|
148
|
+
self.events.append({"timeUnixNano": str(time.time_ns()), "name": name, "attributes": _attributes(clean)})
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def end(self, *, error: Optional[str] = None) -> None:
|
|
152
|
+
if self._ended:
|
|
153
|
+
return
|
|
154
|
+
if error:
|
|
155
|
+
self.status_code = 2
|
|
156
|
+
self.status_message = error
|
|
157
|
+
self.end_ns = time.time_ns()
|
|
158
|
+
self._ended = True
|
|
159
|
+
self.client._finish_span(self)
|
|
160
|
+
|
|
161
|
+
def as_otlp(self) -> Dict[str, Any]:
|
|
162
|
+
body: Dict[str, Any] = {
|
|
163
|
+
"traceId": self.trace_id,
|
|
164
|
+
"spanId": self.span_id,
|
|
165
|
+
"name": self.name,
|
|
166
|
+
"kind": 1,
|
|
167
|
+
"startTimeUnixNano": str(self.start_ns),
|
|
168
|
+
"endTimeUnixNano": str(self.end_ns or time.time_ns()),
|
|
169
|
+
"attributes": _attributes(self.attributes),
|
|
170
|
+
"events": self.events,
|
|
171
|
+
"status": {"code": self.status_code, "message": self.status_message},
|
|
172
|
+
}
|
|
173
|
+
if self.parent_span_id:
|
|
174
|
+
body["parentSpanId"] = self.parent_span_id
|
|
175
|
+
return body
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Run(AbstractContextManager["Run"]):
|
|
179
|
+
def __init__(self, client: "Client", root: Span, goal_id: Optional[str]) -> None:
|
|
180
|
+
self.client = client
|
|
181
|
+
self.root = root
|
|
182
|
+
self.goal_id = goal_id
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def trace_id(self) -> str:
|
|
186
|
+
return self.root.trace_id
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def run_id(self) -> str:
|
|
190
|
+
return str(self.root.attributes["run.id"])
|
|
191
|
+
|
|
192
|
+
def __enter__(self) -> "Run":
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
196
|
+
return self.root.__exit__(exc_type, exc, traceback)
|
|
197
|
+
|
|
198
|
+
def span(
|
|
199
|
+
self,
|
|
200
|
+
name: str,
|
|
201
|
+
*,
|
|
202
|
+
kind: str = "CHAIN",
|
|
203
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
204
|
+
) -> Span:
|
|
205
|
+
merged = {
|
|
206
|
+
"openinference.span.kind": kind.upper(),
|
|
207
|
+
"run.id": self.run_id,
|
|
208
|
+
"goal.id": self.goal_id,
|
|
209
|
+
**dict(attributes or {}),
|
|
210
|
+
}
|
|
211
|
+
return self.client.start_span(
|
|
212
|
+
name,
|
|
213
|
+
trace_id=self.trace_id,
|
|
214
|
+
parent_span_id=self.root.span_id,
|
|
215
|
+
attributes=merged,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def evaluate(self, name: str, *, score: float, label: str, explanation: str = "") -> None:
|
|
219
|
+
self.root.add_event(
|
|
220
|
+
"gen_ai.evaluation",
|
|
221
|
+
{
|
|
222
|
+
"gen_ai.evaluation.name": name,
|
|
223
|
+
"gen_ai.evaluation.score.value": score,
|
|
224
|
+
"gen_ai.evaluation.score.label": label,
|
|
225
|
+
"gen_ai.evaluation.explanation": explanation,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Client:
|
|
231
|
+
"""OTLP sender plus authenticated event and artifact helpers."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
*,
|
|
236
|
+
base_url: str,
|
|
237
|
+
tenant_id: Optional[str] = None,
|
|
238
|
+
pipeline_id: Optional[str] = None,
|
|
239
|
+
protocol_token: Optional[str] = None,
|
|
240
|
+
tenant_jwt: Optional[str] = None,
|
|
241
|
+
service_name: str = "application",
|
|
242
|
+
service_version: Optional[str] = None,
|
|
243
|
+
environment: Optional[str] = None,
|
|
244
|
+
privacy: Optional[Privacy] = None,
|
|
245
|
+
timeout: float = 30.0,
|
|
246
|
+
spool_directory: Optional[str] = None,
|
|
247
|
+
max_spool_files: int = 256,
|
|
248
|
+
auto_flush_spans: int = 64,
|
|
249
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
250
|
+
semantic_planner: Any = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
parsed_base = urlparse(base_url)
|
|
253
|
+
if parsed_base.scheme != "https" or not parsed_base.netloc:
|
|
254
|
+
raise ValueError("base_url must be an HTTPS origin")
|
|
255
|
+
claims = _decode_jwt(tenant_jwt)
|
|
256
|
+
if claims and tenant_id and tenant_id != claims["tenant_id"]:
|
|
257
|
+
raise ValueError("tenant_id does not match tenant_jwt scope")
|
|
258
|
+
if claims and pipeline_id and pipeline_id != claims["pipeline_id"]:
|
|
259
|
+
raise ValueError("pipeline_id does not match tenant_jwt scope")
|
|
260
|
+
self.base_url = f"https://{parsed_base.netloc}"
|
|
261
|
+
self.tenant_id = tenant_id or (claims or {}).get("tenant_id")
|
|
262
|
+
self.pipeline_id = pipeline_id or (claims or {}).get("pipeline_id")
|
|
263
|
+
if not self.tenant_id or not self.pipeline_id:
|
|
264
|
+
raise ValueError("tenant_id and pipeline_id are required when tenant_jwt cannot supply scope")
|
|
265
|
+
self.claims = claims
|
|
266
|
+
self.protocol_token = protocol_token
|
|
267
|
+
self.tenant_jwt = tenant_jwt
|
|
268
|
+
self.service_name = service_name
|
|
269
|
+
self.service_version = service_version
|
|
270
|
+
self.environment = environment
|
|
271
|
+
self.privacy = privacy or Privacy()
|
|
272
|
+
self.timeout = timeout
|
|
273
|
+
self.max_spool_files = max_spool_files
|
|
274
|
+
self.auto_flush_spans = auto_flush_spans
|
|
275
|
+
self.ssl_context = ssl_context or ssl.create_default_context(cafile=certifi.where())
|
|
276
|
+
self.semantic_planner = semantic_planner or DeterministicSemanticPlanner()
|
|
277
|
+
self._pending: list[Span] = []
|
|
278
|
+
self.spool_directory = Path(spool_directory) if spool_directory else None
|
|
279
|
+
if self.spool_directory:
|
|
280
|
+
self.spool_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
281
|
+
|
|
282
|
+
def scope(self) -> Dict[str, Any]:
|
|
283
|
+
return {
|
|
284
|
+
"tenant_id": self.tenant_id,
|
|
285
|
+
"pipeline_id": self.pipeline_id,
|
|
286
|
+
"subject": (self.claims or {}).get("sub"),
|
|
287
|
+
"cluster_id": (self.claims or {}).get("cluster_id"),
|
|
288
|
+
"app_id": (self.claims or {}).get("app_id"),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
def run(
|
|
292
|
+
self,
|
|
293
|
+
name: str,
|
|
294
|
+
*,
|
|
295
|
+
run_id: Optional[str] = None,
|
|
296
|
+
goal_id: Optional[str] = None,
|
|
297
|
+
agent_id: Optional[str] = None,
|
|
298
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
299
|
+
) -> Run:
|
|
300
|
+
actual_run_id = run_id or secrets.token_hex(16)
|
|
301
|
+
root = self.start_span(
|
|
302
|
+
name,
|
|
303
|
+
attributes={
|
|
304
|
+
"openinference.span.kind": "AGENT",
|
|
305
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
306
|
+
"gen_ai.agent.id": agent_id,
|
|
307
|
+
"gen_ai.agent.name": name,
|
|
308
|
+
"run.id": actual_run_id,
|
|
309
|
+
"goal.id": goal_id,
|
|
310
|
+
**dict(attributes or {}),
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
return Run(self, root, goal_id)
|
|
314
|
+
|
|
315
|
+
def start_span(
|
|
316
|
+
self,
|
|
317
|
+
name: str,
|
|
318
|
+
*,
|
|
319
|
+
trace_id: Optional[str] = None,
|
|
320
|
+
parent_span_id: Optional[str] = None,
|
|
321
|
+
attributes: Optional[Mapping[str, Any]] = None,
|
|
322
|
+
) -> Span:
|
|
323
|
+
clean = self.privacy.sanitize(dict(attributes or {}))
|
|
324
|
+
return Span(
|
|
325
|
+
client=self,
|
|
326
|
+
name=name,
|
|
327
|
+
trace_id=trace_id or secrets.token_hex(16),
|
|
328
|
+
span_id=secrets.token_hex(8),
|
|
329
|
+
parent_span_id=parent_span_id,
|
|
330
|
+
attributes=clean,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _finish_span(self, span: Span) -> None:
|
|
334
|
+
self._pending.append(span)
|
|
335
|
+
if len(self._pending) >= self.auto_flush_spans:
|
|
336
|
+
self.flush()
|
|
337
|
+
|
|
338
|
+
def _otlp_payload(self, spans: Iterable[Span]) -> Dict[str, Any]:
|
|
339
|
+
resource = {
|
|
340
|
+
"service.name": self.service_name,
|
|
341
|
+
"service.version": self.service_version,
|
|
342
|
+
"deployment.environment.name": self.environment,
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
"resourceSpans": [{
|
|
346
|
+
"resource": {"attributes": _attributes(resource)},
|
|
347
|
+
"scopeSpans": [{
|
|
348
|
+
"scope": {"name": "trazio-telemetry", "version": "0.3.0"},
|
|
349
|
+
"spans": [span.as_otlp() for span in spans],
|
|
350
|
+
}],
|
|
351
|
+
}],
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
def flush(self) -> None:
|
|
355
|
+
self._drain_spool()
|
|
356
|
+
if not self._pending:
|
|
357
|
+
return
|
|
358
|
+
spans, self._pending = self._pending, []
|
|
359
|
+
payload = self._otlp_payload(spans)
|
|
360
|
+
try:
|
|
361
|
+
self._request("POST", "/v1/traces", payload, protocol=True)
|
|
362
|
+
except Exception:
|
|
363
|
+
self._spool(payload)
|
|
364
|
+
raise
|
|
365
|
+
|
|
366
|
+
def record_event(
|
|
367
|
+
self,
|
|
368
|
+
event: Mapping[str, Any],
|
|
369
|
+
*,
|
|
370
|
+
idempotency_key: Optional[str] = None,
|
|
371
|
+
) -> Dict[str, Any]:
|
|
372
|
+
key = idempotency_key or stable_event_id(self.tenant_id, self.pipeline_id, event)
|
|
373
|
+
path = f"/v1/ingest/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}"
|
|
374
|
+
return self._request("POST", path, self.privacy.sanitize(dict(event)), jwt=True, extra_headers={"Idempotency-Key": key})
|
|
375
|
+
|
|
376
|
+
def upload_artifact(
|
|
377
|
+
self,
|
|
378
|
+
path: str,
|
|
379
|
+
*,
|
|
380
|
+
format: str = "auto",
|
|
381
|
+
device_id: Optional[str] = None,
|
|
382
|
+
goal_id: Optional[str] = None,
|
|
383
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
384
|
+
) -> Dict[str, Any]:
|
|
385
|
+
source = Path(path)
|
|
386
|
+
base = f"/v1/artifacts/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/sessions"
|
|
387
|
+
session = self._request(
|
|
388
|
+
"POST",
|
|
389
|
+
base,
|
|
390
|
+
{
|
|
391
|
+
"filename": source.name,
|
|
392
|
+
"format": format,
|
|
393
|
+
"device_id": device_id,
|
|
394
|
+
"goal_id": goal_id,
|
|
395
|
+
"metadata": self.privacy.sanitize(dict(metadata or {})),
|
|
396
|
+
},
|
|
397
|
+
jwt=True,
|
|
398
|
+
)
|
|
399
|
+
session_id = session["session_id"]
|
|
400
|
+
content = source.read_bytes()
|
|
401
|
+
self._request("PUT", f"{base}/{_segment(session_id)}/content", content, jwt=True, content_type="application/octet-stream")
|
|
402
|
+
return self._request("POST", f"{base}/{_segment(session_id)}/complete", None, jwt=True)
|
|
403
|
+
|
|
404
|
+
def list_notice_deliveries(self, *, transport="room", limit=50, cursor=None):
|
|
405
|
+
if transport not in {"room", "moq"}:
|
|
406
|
+
raise ValueError("transport must be room or moq")
|
|
407
|
+
params = {"limit": limit}
|
|
408
|
+
if cursor:
|
|
409
|
+
params["cursor"] = cursor
|
|
410
|
+
base = "livekit" if transport == "room" else "live"
|
|
411
|
+
return self._request("GET", f"/v1/{base}/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/notice-deliveries?{urlencode(params)}", None, jwt=True)
|
|
412
|
+
|
|
413
|
+
def replay_notice_delivery(self, body, *, transport="room"):
|
|
414
|
+
if transport not in {"room", "moq"}:
|
|
415
|
+
raise ValueError("transport must be room or moq")
|
|
416
|
+
base = "livekit" if transport == "room" else "live"
|
|
417
|
+
return self._request("POST", f"/v1/{base}/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/notice-replay", dict(body), jwt=True)
|
|
418
|
+
|
|
419
|
+
def analyze_live_frame(
|
|
420
|
+
self,
|
|
421
|
+
path: str,
|
|
422
|
+
*,
|
|
423
|
+
stream_session_id: str,
|
|
424
|
+
frame_id: str,
|
|
425
|
+
observed_at: Optional[str] = None,
|
|
426
|
+
device_id: Optional[str] = None,
|
|
427
|
+
run_id: Optional[str] = None,
|
|
428
|
+
goal_id: Optional[str] = None,
|
|
429
|
+
content_type: str = "image/jpeg",
|
|
430
|
+
) -> Dict[str, Any]:
|
|
431
|
+
if not stream_session_id or not frame_id:
|
|
432
|
+
raise ValueError("stream_session_id and frame_id are required")
|
|
433
|
+
headers = {
|
|
434
|
+
"X-Stream-Session-Id": stream_session_id,
|
|
435
|
+
"X-Frame-Id": frame_id,
|
|
436
|
+
"X-Observed-At": observed_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
437
|
+
}
|
|
438
|
+
for name, value in (("X-Device-Id", device_id), ("X-Run-Id", run_id), ("X-Goal-Id", goal_id)):
|
|
439
|
+
if value:
|
|
440
|
+
headers[name] = value
|
|
441
|
+
live_path = f"/v1/live/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/analyze-frame"
|
|
442
|
+
return self._request(
|
|
443
|
+
"POST", live_path, Path(path).read_bytes(), jwt=True,
|
|
444
|
+
content_type=content_type, extra_headers=headers,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def _query_path(self, suffix: str = "") -> str:
|
|
448
|
+
return f"/v1/query/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}{suffix}"
|
|
449
|
+
|
|
450
|
+
def _artifact_path(self, suffix: str = "") -> str:
|
|
451
|
+
return f"/v1/artifacts/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}{suffix}"
|
|
452
|
+
|
|
453
|
+
def _tenant_path(self, suffix: str = "") -> str:
|
|
454
|
+
return f"/v1/tenant/pipelines/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}{suffix}"
|
|
455
|
+
|
|
456
|
+
def list_events(self, *, limit: int = 100, cursor: Optional[str] = None, include_ai: bool = True) -> Dict[str, Any]:
|
|
457
|
+
params = {"limit": min(max(int(limit), 1), 100), "include_ai": str(include_ai).lower()}
|
|
458
|
+
if cursor:
|
|
459
|
+
params["cursor"] = cursor
|
|
460
|
+
return self._request("GET", f"{self._query_path()}?{urlencode(params)}", None, jwt=True)
|
|
461
|
+
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _event(row: Mapping[str, Any]) -> Dict[str, Any]:
|
|
464
|
+
event = row.get("event") or {}
|
|
465
|
+
metadata = row.get("metadata") or {}
|
|
466
|
+
evaluation = row.get("rule_evaluation") or {}
|
|
467
|
+
timestamps = event.get("timestamps") or {}
|
|
468
|
+
return {
|
|
469
|
+
"event_id": metadata.get("event_id") or event.get("event_id"),
|
|
470
|
+
"timestamp": timestamps.get("observed") or timestamps.get("received") or event.get("received_at") or metadata.get("received_at"),
|
|
471
|
+
"source_type": event.get("source_type") or (event.get("source") or {}).get("type") or "unknown",
|
|
472
|
+
"event_type": event.get("event_type"),
|
|
473
|
+
"severity": evaluation.get("severity") or event.get("severity") or "info",
|
|
474
|
+
"message": str(event.get("message") or ((event.get("content") or {}).get("normalized") or {}).get("message") or "")[:2000],
|
|
475
|
+
"device_id": event.get("device_id"),
|
|
476
|
+
"run_id": event.get("run_id") or event.get("goal_id") or event.get("mission_id") or event.get("recording_id"),
|
|
477
|
+
"rule_evaluation": row.get("rule_evaluation"),
|
|
478
|
+
"ai_enrichment": row.get("ai_enrichment"),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
def search_events(self, query: str | Mapping[str, Any]) -> Dict[str, Any]:
|
|
482
|
+
plan = normalize_semantic_query({"question": query, "targets": ["events"]} if isinstance(query, str) else {**query, "targets": ["events"]})
|
|
483
|
+
rows, cursor = [], None
|
|
484
|
+
while len(rows) < plan["options"]["max_scan"]:
|
|
485
|
+
page = self.list_events(limit=min(100, plan["options"]["max_scan"] - len(rows)), cursor=cursor)
|
|
486
|
+
rows.extend(page.get("results") or [])
|
|
487
|
+
cursor = page.get("cursor")
|
|
488
|
+
if not cursor or page.get("list_complete"):
|
|
489
|
+
break
|
|
490
|
+
filters = plan["filters"]
|
|
491
|
+
words = list(dict.fromkeys(filters["keywords"] + re.findall(r"[a-z0-9_.:-]{2,}", plan["question"].lower())))
|
|
492
|
+
matches = []
|
|
493
|
+
for row in rows:
|
|
494
|
+
event = self._event(row)
|
|
495
|
+
source = str(event["source_type"])
|
|
496
|
+
if filters["source_types"] and not any(source.startswith(item[:-1]) if item.endswith(".*") else source == item for item in filters["source_types"]):
|
|
497
|
+
continue
|
|
498
|
+
if filters["event_types"] and event["event_type"] not in filters["event_types"]:
|
|
499
|
+
continue
|
|
500
|
+
if filters["severities"] and event["severity"] not in filters["severities"]:
|
|
501
|
+
continue
|
|
502
|
+
if filters["device_id"] and event["device_id"] != filters["device_id"]:
|
|
503
|
+
continue
|
|
504
|
+
if filters["run_id"] and event["run_id"] != filters["run_id"]:
|
|
505
|
+
continue
|
|
506
|
+
event_time = _timestamp_value(event["timestamp"])
|
|
507
|
+
if filters["from"] and event_time is not None and event_time < _timestamp_value(filters["from"]):
|
|
508
|
+
continue
|
|
509
|
+
if filters["to"] and event_time is not None and event_time > _timestamp_value(filters["to"]):
|
|
510
|
+
continue
|
|
511
|
+
haystack = json.dumps(event, default=str).lower()
|
|
512
|
+
matched = [word for word in words if word in haystack]
|
|
513
|
+
if words and not matched:
|
|
514
|
+
continue
|
|
515
|
+
matches.append({"event": event, "score": len(matched) / len(words) if words else 1, "matched_terms": matched})
|
|
516
|
+
matches.sort(key=lambda item: item["score"], reverse=True)
|
|
517
|
+
matches = matches[: plan["options"]["limit"]]
|
|
518
|
+
return {"scope": self.scope(), "plan": plan, "scanned": len(rows), "count": len(matches), "matches": matches, "retrieval": "structured_lexical"}
|
|
519
|
+
|
|
520
|
+
def search_artifacts(self, query: str | Mapping[str, Any]) -> Dict[str, Any]:
|
|
521
|
+
plan = normalize_semantic_query({"question": query, "targets": ["artifacts"]} if isinstance(query, str) else {**query, "targets": ["artifacts"]})
|
|
522
|
+
filters = plan["filters"]
|
|
523
|
+
payload = {"query": plan["question"], "top_k": plan["options"]["top_k"]}
|
|
524
|
+
for key in ("session_id", "device_id", "run_id"):
|
|
525
|
+
if filters[key]:
|
|
526
|
+
payload[key] = filters[key]
|
|
527
|
+
return self._request("POST", self._artifact_path("/search"), payload, jwt=True)
|
|
528
|
+
|
|
529
|
+
def correlation_graph(self, **options: Any) -> Dict[str, Any]:
|
|
530
|
+
params = {key: options[key] for key in ("query", "node_id", "event_id", "edge_id", "relationship", "rule_id") if options.get(key)}
|
|
531
|
+
if options.get("event_ids"):
|
|
532
|
+
params["event_ids"] = ",".join(options["event_ids"][:50])
|
|
533
|
+
params["hops"] = min(max(int(options.get("hops", 1)), 0), 3)
|
|
534
|
+
params["limit"] = min(max(int(options.get("limit", 100)), 1), 500)
|
|
535
|
+
return self._request("GET", f"{self._query_path('/graph')}?{urlencode(params)}", None, jwt=True)
|
|
536
|
+
|
|
537
|
+
def get_artifact_session(self, session_id: str) -> Dict[str, Any]:
|
|
538
|
+
return self._request("GET", self._artifact_path(f"/sessions/{_segment(session_id)}"), None, jwt=True)
|
|
539
|
+
|
|
540
|
+
def get_telemetry(self, session_id: str) -> str:
|
|
541
|
+
return self._request("GET", self._artifact_path(f"/sessions/{_segment(session_id)}/telemetry"), None, jwt=True, response_type="text")
|
|
542
|
+
|
|
543
|
+
def download_artifact(self, session_id: str) -> bytes:
|
|
544
|
+
return self._request("GET", self._artifact_path(f"/sessions/{_segment(session_id)}/content"), None, jwt=True, response_type="bytes")
|
|
545
|
+
|
|
546
|
+
def open_video(self, session_id: str, *, title: Optional[str] = None, ttl_seconds: int = 900, embed_origin: Optional[str] = None) -> Dict[str, Any]:
|
|
547
|
+
result = self._request("POST", self._artifact_path(f"/sessions/{_segment(session_id)}/playback"), {"title": title, "ttl_seconds": min(max(ttl_seconds, 60), 3600), **({"embed_origin": embed_origin} if embed_origin else {})}, jwt=True)
|
|
548
|
+
result["viewer_url"] = self.base_url + result["viewer_url"]
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
def renew_playback(self, session_id: str, token: str) -> Dict[str, Any]:
|
|
552
|
+
return self._request("POST", self._artifact_path(f"/sessions/{_segment(session_id)}/playback-renew"), {"token": token}, jwt=True)
|
|
553
|
+
|
|
554
|
+
def create_live_embed(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
|
555
|
+
return self._request("POST", f"/v1/live/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/viewer-grants", options, jwt=True)
|
|
556
|
+
|
|
557
|
+
def renew_live_embed(self, grant_id: str) -> Dict[str, Any]:
|
|
558
|
+
return self._request("PATCH", f"/v1/live/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/viewer-grants/{_segment(grant_id)}", None, jwt=True)
|
|
559
|
+
|
|
560
|
+
def revoke_live_embed(self, grant_id: str) -> Dict[str, Any]:
|
|
561
|
+
return self._request("DELETE", f"/v1/live/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/viewer-grants/{_segment(grant_id)}", None, jwt=True)
|
|
562
|
+
|
|
563
|
+
def revoke_playback(self, session_id: str, token: str) -> Dict[str, Any]:
|
|
564
|
+
return self._request("POST", self._artifact_path(f"/sessions/{_segment(session_id)}/playback-revoke"), {"token": token}, jwt=True)
|
|
565
|
+
|
|
566
|
+
def request_video_export(self, session_id: str, *, start_seconds: float, end_seconds: float, idempotency_key: str) -> Dict[str, Any]:
|
|
567
|
+
return self._request("POST", self._artifact_path(f"/sessions/{_segment(session_id)}/exports"), {"start_seconds": start_seconds, "end_seconds": end_seconds}, jwt=True, extra_headers={"Idempotency-Key": idempotency_key})
|
|
568
|
+
|
|
569
|
+
def get_video_export(self, session_id: str, export_id: str) -> Dict[str, Any]:
|
|
570
|
+
return self._request("GET", self._artifact_path(f"/sessions/{_segment(session_id)}/exports/{_segment(export_id)}"), None, jwt=True)
|
|
571
|
+
|
|
572
|
+
def download_video_export(self, session_id: str, export_id: str) -> bytes:
|
|
573
|
+
return self._request("GET", self._artifact_path(f"/sessions/{_segment(session_id)}/exports/{_segment(export_id)}/content"), None, jwt=True, response_type="bytes")
|
|
574
|
+
|
|
575
|
+
def timeline(self, viewer_url: str, *, start_seconds: float = 0, end_seconds: Optional[float] = None, padding_seconds: float = 5) -> Dict[str, Any]:
|
|
576
|
+
parsed = urlparse(viewer_url)
|
|
577
|
+
expected = urlparse(self.base_url)
|
|
578
|
+
if parsed.scheme != expected.scheme or parsed.netloc != expected.netloc or not re.fullmatch(r"/v1/artifacts/play/(?:pc_[a-f0-9]{32}/)?pb_[A-Za-z0-9_-]+", parsed.path):
|
|
579
|
+
raise ValueError("viewer_url must be an opaque URL issued by this regional service")
|
|
580
|
+
params = urlencode({"start": start_seconds, "end": start_seconds if end_seconds is None else end_seconds, "padding": min(max(padding_seconds, 0), 60)})
|
|
581
|
+
return self._request("GET", f"{parsed.path}/timeline?{params}", None, public=True)
|
|
582
|
+
|
|
583
|
+
def list_runs(self) -> Dict[str, Any]:
|
|
584
|
+
return self._request("GET", self._artifact_path("/runs"), None, jwt=True)
|
|
585
|
+
|
|
586
|
+
def get_run(self, run_id: str) -> Dict[str, Any]:
|
|
587
|
+
return self._request("GET", self._artifact_path(f"/runs/{_segment(run_id)}"), None, jwt=True)
|
|
588
|
+
|
|
589
|
+
def get_tenant_config(self) -> Dict[str, Any]:
|
|
590
|
+
return self._request("GET", self._tenant_path("/config"), None, jwt=True)
|
|
591
|
+
|
|
592
|
+
def operations_snapshot(self) -> Dict[str, Any]:
|
|
593
|
+
"""Replace the dashboard state with this bounded, pipeline-scoped snapshot."""
|
|
594
|
+
path = f"/v1/operations/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/snapshot"
|
|
595
|
+
return self._request("GET", path, None, jwt=True)
|
|
596
|
+
|
|
597
|
+
def operations_overview(self) -> Dict[str, Any]:
|
|
598
|
+
path = f"/v1/operations/{_segment(self.tenant_id)}/{_segment(self.pipeline_id)}/overview"
|
|
599
|
+
return self._request("GET", path, None, jwt=True)
|
|
600
|
+
|
|
601
|
+
def update_tenant_config(self, patch: Mapping[str, Any]) -> Dict[str, Any]:
|
|
602
|
+
return self._request("PATCH", self._tenant_path("/config"), dict(patch), jwt=True)
|
|
603
|
+
|
|
604
|
+
def rotate_protocol_token(self) -> Dict[str, Any]:
|
|
605
|
+
return self._request("POST", self._tenant_path("/protocol-token/rotate"), {}, jwt=True)
|
|
606
|
+
|
|
607
|
+
def validate_rule_pack(self, pack: Mapping[str, Any]) -> Dict[str, Any]:
|
|
608
|
+
return self._request("POST", self._tenant_path("/rule-packs/validate"), dict(pack), jwt=True)
|
|
609
|
+
|
|
610
|
+
def dry_run_rule_pack(self, pack: Mapping[str, Any], events: list[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
611
|
+
return self._request("POST", self._tenant_path("/rule-packs/dry-run"), {"pack": dict(pack), "events": [dict(event) for event in events]}, jwt=True)
|
|
612
|
+
|
|
613
|
+
def publish_rule_pack(self, name: str, version: str, pack: Mapping[str, Any]) -> Dict[str, Any]:
|
|
614
|
+
return self._request("POST", self._tenant_path(f"/rule-packs/{_segment(name)}/{_segment(version)}"), dict(pack), jwt=True)
|
|
615
|
+
|
|
616
|
+
def validate_source_contract(self, contract: Mapping[str, Any]) -> Dict[str, Any]:
|
|
617
|
+
return self._request("POST", self._tenant_path("/contracts/validate"), dict(contract), jwt=True)
|
|
618
|
+
|
|
619
|
+
def dry_run_source_contract(self, contract: Mapping[str, Any], events: list[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
620
|
+
return self._request("POST", self._tenant_path("/contracts/dry-run"), {"contract": dict(contract), "events": [dict(event) for event in events]}, jwt=True)
|
|
621
|
+
|
|
622
|
+
def get_audit(self, *, cursor: Optional[str] = None, limit: int = 100) -> Dict[str, Any]:
|
|
623
|
+
params = {"limit": min(max(limit, 1), 100)}
|
|
624
|
+
if cursor:
|
|
625
|
+
params["cursor"] = cursor
|
|
626
|
+
return self._request("GET", f"{self._tenant_path('/audit')}?{urlencode(params)}", None, jwt=True)
|
|
627
|
+
|
|
628
|
+
def semantic_query(self, question: str, *, planner: Any = None) -> Dict[str, Any]:
|
|
629
|
+
plan = normalize_semantic_query((planner or self.semantic_planner).plan(question), question=question)
|
|
630
|
+
result: Dict[str, Any] = {"scope": self.scope(), "plan": plan, "events": None, "artifacts": None, "graph": None, "errors": {}}
|
|
631
|
+
operations = {
|
|
632
|
+
"events": lambda: self.search_events(plan),
|
|
633
|
+
"artifacts": lambda: self.search_artifacts(plan),
|
|
634
|
+
"graph": lambda: self.correlation_graph(query=plan["question"], relationship=plan["filters"]["relationship"], rule_id=plan["filters"]["rule_id"], hops=plan["options"]["graph_hops"], limit=min(plan["options"]["limit"] * 5, 500)),
|
|
635
|
+
}
|
|
636
|
+
for target in plan["targets"]:
|
|
637
|
+
try:
|
|
638
|
+
result[target] = operations[target]()
|
|
639
|
+
except Exception as exc:
|
|
640
|
+
result["errors"][target] = {"name": type(exc).__name__, "message": str(exc)[:500]}
|
|
641
|
+
result["reliability"] = {"scope_source": "jwt_claims_server_verified_per_request", "plan_validated": True, "raw_records_merged": False}
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
def _auth_headers(self, *, protocol: bool = False, jwt: bool = False) -> Dict[str, str]:
|
|
645
|
+
token = self.protocol_token if protocol else self.tenant_jwt if jwt else None
|
|
646
|
+
if not token:
|
|
647
|
+
needed = "protocol_token" if protocol else "tenant_jwt"
|
|
648
|
+
raise ValueError(f"{needed} is required for this operation")
|
|
649
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
650
|
+
if protocol:
|
|
651
|
+
headers.update({
|
|
652
|
+
"X-Platform-Tenant": self.tenant_id,
|
|
653
|
+
"X-Platform-Pipeline": self.pipeline_id,
|
|
654
|
+
})
|
|
655
|
+
return headers
|
|
656
|
+
|
|
657
|
+
def _request(
|
|
658
|
+
self,
|
|
659
|
+
method: str,
|
|
660
|
+
path: str,
|
|
661
|
+
payload: Any,
|
|
662
|
+
*,
|
|
663
|
+
protocol: bool = False,
|
|
664
|
+
jwt: bool = False,
|
|
665
|
+
content_type: str = "application/json",
|
|
666
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
667
|
+
public: bool = False,
|
|
668
|
+
response_type: str = "json",
|
|
669
|
+
) -> Any:
|
|
670
|
+
headers = {} if public else self._auth_headers(protocol=protocol, jwt=jwt)
|
|
671
|
+
headers.update(extra_headers or {})
|
|
672
|
+
headers["Content-Type"] = content_type
|
|
673
|
+
headers["Accept"] = "application/json"
|
|
674
|
+
headers["User-Agent"] = "trazio-telemetry-python/0.3.0"
|
|
675
|
+
data = payload if isinstance(payload, bytes) else None if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
676
|
+
request = Request(self.base_url + path, data=data, method=method, headers=headers)
|
|
677
|
+
try:
|
|
678
|
+
with urlopen(request, timeout=self.timeout, context=self.ssl_context) as response:
|
|
679
|
+
body = response.read()
|
|
680
|
+
if response_type == "bytes":
|
|
681
|
+
return body
|
|
682
|
+
if response_type == "text":
|
|
683
|
+
return body.decode("utf-8", errors="replace")
|
|
684
|
+
return json.loads(body) if body else {"status": response.status}
|
|
685
|
+
except HTTPError as exc:
|
|
686
|
+
detail = exc.read().decode("utf-8", errors="replace")[:2048]
|
|
687
|
+
raise RuntimeError(f"HTTP {exc.code} from {path}: {detail}") from exc
|
|
688
|
+
except URLError as exc:
|
|
689
|
+
raise RuntimeError(f"Transport failure for {path}: {exc.reason}") from exc
|
|
690
|
+
|
|
691
|
+
def _spool(self, payload: Mapping[str, Any]) -> None:
|
|
692
|
+
if not self.spool_directory:
|
|
693
|
+
return
|
|
694
|
+
files = sorted(self.spool_directory.glob("*.json"))
|
|
695
|
+
while len(files) >= self.max_spool_files:
|
|
696
|
+
files.pop(0).unlink(missing_ok=True)
|
|
697
|
+
target = self.spool_directory / f"{time.time_ns()}-{secrets.token_hex(4)}.json"
|
|
698
|
+
target.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
|
|
699
|
+
os.chmod(target, 0o600)
|
|
700
|
+
|
|
701
|
+
def _drain_spool(self) -> None:
|
|
702
|
+
if not self.spool_directory:
|
|
703
|
+
return
|
|
704
|
+
for item in sorted(self.spool_directory.glob("*.json")):
|
|
705
|
+
payload = json.loads(item.read_text(encoding="utf-8"))
|
|
706
|
+
self._request("POST", "/v1/traces", payload, protocol=True)
|
|
707
|
+
item.unlink()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _timestamp_value(value: Any) -> Optional[float]:
|
|
711
|
+
if not value:
|
|
712
|
+
return None
|
|
713
|
+
try:
|
|
714
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
715
|
+
except (TypeError, ValueError):
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class AdminClient:
|
|
720
|
+
"""Regional operator client. Keep this credential separate from tenant clients."""
|
|
721
|
+
|
|
722
|
+
def __init__(self, *, base_url: str, admin_token: str, timeout: float = 30.0, ssl_context: Optional[ssl.SSLContext] = None) -> None:
|
|
723
|
+
parsed = urlparse(base_url)
|
|
724
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
725
|
+
raise ValueError("base_url must be an HTTPS origin")
|
|
726
|
+
if not admin_token:
|
|
727
|
+
raise ValueError("admin_token is required")
|
|
728
|
+
self.base_url = f"https://{parsed.netloc}"
|
|
729
|
+
self.admin_token = admin_token
|
|
730
|
+
self.timeout = timeout
|
|
731
|
+
self.ssl_context = ssl_context or ssl.create_default_context(cafile=certifi.where())
|
|
732
|
+
|
|
733
|
+
def request(self, method: str, path: str, body: Any = None) -> Dict[str, Any]:
|
|
734
|
+
if not path.startswith("/v1/admin/"):
|
|
735
|
+
raise ValueError("admin requests must use /v1/admin paths")
|
|
736
|
+
data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
|
|
737
|
+
request = Request(self.base_url + path, data=data, method=method, headers={
|
|
738
|
+
"X-Admin-Token": self.admin_token, "Accept": "application/json", "Content-Type": "application/json",
|
|
739
|
+
"User-Agent": "trazio-telemetry-python/0.3.0",
|
|
740
|
+
})
|
|
741
|
+
try:
|
|
742
|
+
with urlopen(request, timeout=self.timeout, context=self.ssl_context) as response:
|
|
743
|
+
payload = response.read()
|
|
744
|
+
return json.loads(payload) if payload else {"status": response.status}
|
|
745
|
+
except HTTPError as exc:
|
|
746
|
+
detail = exc.read().decode("utf-8", errors="replace")[:2048]
|
|
747
|
+
raise RuntimeError(f"HTTP {exc.code} from {path}: {detail}") from exc
|
|
748
|
+
except URLError as exc:
|
|
749
|
+
raise RuntimeError(f"Transport failure for {path}: {exc.reason}") from exc
|
|
750
|
+
|
|
751
|
+
@staticmethod
|
|
752
|
+
def _pipeline_path(tenant_id: str, pipeline_id: str, suffix: str = "") -> str:
|
|
753
|
+
return f"/v1/admin/pipelines/{_segment(tenant_id)}/{_segment(pipeline_id)}{suffix}"
|
|
754
|
+
|
|
755
|
+
def create_tenant(self, *, name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
|
756
|
+
return self.request("POST", "/v1/admin/customers", {"name": name, "description": description})
|
|
757
|
+
|
|
758
|
+
def list_pipelines(self, *, cursor: Optional[str] = None, limit: int = 100) -> Dict[str, Any]:
|
|
759
|
+
params = {"limit": min(max(limit, 1), 100)}
|
|
760
|
+
if cursor:
|
|
761
|
+
params["cursor"] = cursor
|
|
762
|
+
return self.request("GET", f"/v1/admin/pipelines?{urlencode(params)}")
|
|
763
|
+
|
|
764
|
+
def get_pipeline(self, tenant_id: str, pipeline_id: str) -> Dict[str, Any]:
|
|
765
|
+
return self.request("GET", self._pipeline_path(tenant_id, pipeline_id))
|
|
766
|
+
|
|
767
|
+
def create_pipeline(self, tenant_id: str, pipeline_id: str, config: Mapping[str, Any]) -> Dict[str, Any]:
|
|
768
|
+
return self.request("POST", self._pipeline_path(tenant_id, pipeline_id), dict(config))
|
|
769
|
+
|
|
770
|
+
def replace_pipeline(self, tenant_id: str, pipeline_id: str, config: Mapping[str, Any]) -> Dict[str, Any]:
|
|
771
|
+
return self.request("PUT", self._pipeline_path(tenant_id, pipeline_id), dict(config))
|
|
772
|
+
|
|
773
|
+
def update_pipeline(self, tenant_id: str, pipeline_id: str, patch: Mapping[str, Any]) -> Dict[str, Any]:
|
|
774
|
+
return self.request("PATCH", self._pipeline_path(tenant_id, pipeline_id), dict(patch))
|
|
775
|
+
|
|
776
|
+
def delete_pipeline(self, tenant_id: str, pipeline_id: str) -> Dict[str, Any]:
|
|
777
|
+
return self.request("DELETE", self._pipeline_path(tenant_id, pipeline_id))
|
|
778
|
+
|
|
779
|
+
def validate_rule_pack(self, pack: Mapping[str, Any]) -> Dict[str, Any]:
|
|
780
|
+
return self.request("POST", "/v1/admin/rule-packs/validate", dict(pack))
|
|
781
|
+
|
|
782
|
+
def dry_run_rule_pack(self, pack: Mapping[str, Any], events: list[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
783
|
+
return self.request("POST", "/v1/admin/rule-packs/dry-run", {"pack": dict(pack), "events": [dict(event) for event in events]})
|
|
784
|
+
|
|
785
|
+
def publish_rule_pack(self, owner: str, name: str, version: str, pack: Mapping[str, Any]) -> Dict[str, Any]:
|
|
786
|
+
path = f"/v1/admin/rule-packs/{_segment(owner)}/{_segment(name)}/{_segment(version)}"
|
|
787
|
+
return self.request("POST", path, dict(pack))
|
|
788
|
+
|
|
789
|
+
def publish_model_profile(self, name: str, version: str, profile: Mapping[str, Any]) -> Dict[str, Any]:
|
|
790
|
+
path = f"/v1/admin/model-profiles/{_segment(name)}/{_segment(version)}"
|
|
791
|
+
return self.request("POST", path, dict(profile))
|
|
792
|
+
|
|
793
|
+
def get_model_profile(self, name: str, version: str) -> Dict[str, Any]:
|
|
794
|
+
path = f"/v1/admin/model-profiles/{_segment(name)}/{_segment(version)}"
|
|
795
|
+
return self.request("GET", path)
|
|
796
|
+
|
|
797
|
+
def stats(self) -> Dict[str, Any]:
|
|
798
|
+
return self.request("GET", "/v1/admin/stats")
|
|
799
|
+
|
|
800
|
+
def reindex_artifact(self, tenant_id: str, pipeline_id: str, session_id: str) -> Dict[str, Any]:
|
|
801
|
+
path = f"/v1/admin/artifacts/{_segment(tenant_id)}/{_segment(pipeline_id)}/{_segment(session_id)}/reindex"
|
|
802
|
+
return self.request("POST", path, {})
|
|
803
|
+
|
|
804
|
+
def reliability_summary(self) -> Dict[str, Any]:
|
|
805
|
+
return self.request("GET", "/v1/admin/reliability/summary")
|
|
806
|
+
|
|
807
|
+
def reliability_list(self, kind: str, *, cursor: Optional[str] = None, limit: int = 100) -> Dict[str, Any]:
|
|
808
|
+
if kind not in {"receipts", "outbox", "quarantine", "artifacts"}:
|
|
809
|
+
raise ValueError("unsupported reliability collection")
|
|
810
|
+
params = {"limit": min(max(limit, 1), 100)}
|
|
811
|
+
if cursor:
|
|
812
|
+
params["cursor"] = cursor
|
|
813
|
+
return self.request("GET", f"/v1/admin/reliability/{kind}?{urlencode(params)}")
|
|
814
|
+
|
|
815
|
+
def replay(self, body: Mapping[str, Any]) -> Dict[str, Any]:
|
|
816
|
+
return self.request("POST", "/v1/admin/reliability/replay", dict(body))
|
|
817
|
+
|
|
818
|
+
def reconcile(self, body: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
|
|
819
|
+
return self.request("POST", "/v1/admin/reliability/reconcile", dict(body or {}))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""OpenInference-compatible attribute construction without a second tracer."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def span_attributes(
|
|
7
|
+
kind: str,
|
|
8
|
+
*,
|
|
9
|
+
input_value: Optional[Any] = None,
|
|
10
|
+
output_value: Optional[Any] = None,
|
|
11
|
+
**attributes: Any,
|
|
12
|
+
) -> Dict[str, Any]:
|
|
13
|
+
values: Dict[str, Any] = {"openinference.span.kind": kind.upper(), **attributes}
|
|
14
|
+
if input_value is not None:
|
|
15
|
+
values["input.value"] = input_value
|
|
16
|
+
if output_value is not None:
|
|
17
|
+
values["output.value"] = output_value
|
|
18
|
+
return values
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Correlation mapping for Temporal's native OpenTelemetry interceptor."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def temporal_attributes(
|
|
7
|
+
*,
|
|
8
|
+
workflow_id: str,
|
|
9
|
+
run_id: str,
|
|
10
|
+
workflow_type: Optional[str] = None,
|
|
11
|
+
task_queue: Optional[str] = None,
|
|
12
|
+
activity_id: Optional[str] = None,
|
|
13
|
+
attempt: Optional[int] = None,
|
|
14
|
+
) -> Dict[str, Any]:
|
|
15
|
+
return {
|
|
16
|
+
"temporal.workflow.id": workflow_id,
|
|
17
|
+
"temporal.run.id": run_id,
|
|
18
|
+
"temporal.workflow.type": workflow_type,
|
|
19
|
+
"temporal.task_queue": task_queue,
|
|
20
|
+
"temporal.activity.id": activity_id,
|
|
21
|
+
"temporal.activity.attempt": attempt,
|
|
22
|
+
"run.id": run_id,
|
|
23
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Versioned, scope-free semantic query plans with deterministic validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Any, Callable, Mapping
|
|
11
|
+
|
|
12
|
+
SEVERITY_ORDER = ("info", "low", "medium", "high", "critical")
|
|
13
|
+
SEVERITIES = set(SEVERITY_ORDER)
|
|
14
|
+
TARGETS = {"events", "artifacts", "graph"}
|
|
15
|
+
MODES = {"hybrid", "semantic", "lexical", "structured"}
|
|
16
|
+
SOURCE_ALIASES = {
|
|
17
|
+
"kubernetes": "kubernetes.*", "k8s": "kubernetes.*", "otel": "otel.*",
|
|
18
|
+
"opentelemetry": "otel.*", "robot": "robot.*", "robotics": "robot.*",
|
|
19
|
+
"desktop": "desktop.*", "endpoint": "desktop.security", "argocd": "argocd", "mqtt": "mqtt",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _unique(values: Any, *, allowed: set[str] | None = None, limit: int = 32) -> list[str]:
|
|
24
|
+
if values is None:
|
|
25
|
+
return []
|
|
26
|
+
if not isinstance(values, list):
|
|
27
|
+
raise ValueError("semantic query list fields must be arrays")
|
|
28
|
+
result = list(dict.fromkeys(str(value).strip() for value in values if str(value).strip()))[:limit]
|
|
29
|
+
if allowed and any(value not in allowed for value in result):
|
|
30
|
+
raise ValueError("semantic query contains an unsupported value")
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _timestamp(value: Any, name: str) -> str | None:
|
|
35
|
+
if value in (None, ""):
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise ValueError(f"{name} must be an ISO-8601 timestamp") from exc
|
|
41
|
+
if parsed.tzinfo is None:
|
|
42
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
43
|
+
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def deterministic_plan(question: str, *, now: datetime | None = None) -> dict[str, Any]:
|
|
47
|
+
question = str(question).strip()
|
|
48
|
+
if not question or len(question) > 4000:
|
|
49
|
+
raise ValueError("question must contain 1-4000 characters")
|
|
50
|
+
lower = question.lower()
|
|
51
|
+
targets = ["events"]
|
|
52
|
+
if re.search(r"\b(video|scene|recording|mcap|image|artifact|file)\b", lower):
|
|
53
|
+
targets.append("artifacts")
|
|
54
|
+
if re.search(r"\b(correlat|relationship|related|graph|trace|neighbor)\w*\b", lower):
|
|
55
|
+
targets.append("graph")
|
|
56
|
+
source_types = [source for alias, source in SOURCE_ALIASES.items() if re.search(rf"\b{re.escape(alias)}\b", lower)]
|
|
57
|
+
from_time = None
|
|
58
|
+
recent = re.search(r"\b(?:last|past)\s+(\d{1,4})\s*(minute|hour|day|week)s?\b", lower)
|
|
59
|
+
if recent:
|
|
60
|
+
units = {"minute": "minutes", "hour": "hours", "day": "days", "week": "weeks"}
|
|
61
|
+
current = now or datetime.now(timezone.utc)
|
|
62
|
+
from_time = (current - timedelta(**{units[recent.group(2)]: int(recent.group(1))})).isoformat().replace("+00:00", "Z")
|
|
63
|
+
return normalize_semantic_query({
|
|
64
|
+
"version": "1.0", "question": question, "mode": "hybrid", "targets": targets,
|
|
65
|
+
"filters": {
|
|
66
|
+
"source_types": list(dict.fromkeys(source_types)), "event_types": [],
|
|
67
|
+
"severities": [item for item in SEVERITY_ORDER if re.search(rf"\b{item}\b", lower)],
|
|
68
|
+
"from": from_time, "to": None,
|
|
69
|
+
},
|
|
70
|
+
"options": {"limit": 20, "max_scan": 300, "top_k": 10, "graph_hops": 1},
|
|
71
|
+
"planner": {"kind": "deterministic", "model": None},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_semantic_query(value: Mapping[str, Any], *, question: str | None = None, planner: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
76
|
+
if not isinstance(value, Mapping):
|
|
77
|
+
raise ValueError("semantic query must be an object")
|
|
78
|
+
for forbidden in ("tenant_id", "pipeline_id", "base_url", "authorization", "token"):
|
|
79
|
+
if forbidden in value:
|
|
80
|
+
raise ValueError(f"semantic query cannot set {forbidden}")
|
|
81
|
+
actual_question = str(question or value.get("question") or "").strip()
|
|
82
|
+
if not actual_question or len(actual_question) > 4000:
|
|
83
|
+
raise ValueError("question must contain 1-4000 characters")
|
|
84
|
+
mode = str(value.get("mode") or "hybrid")
|
|
85
|
+
if mode not in MODES:
|
|
86
|
+
raise ValueError("unsupported semantic query mode")
|
|
87
|
+
filters = value.get("filters") if isinstance(value.get("filters"), Mapping) else {}
|
|
88
|
+
options = value.get("options") if isinstance(value.get("options"), Mapping) else {}
|
|
89
|
+
from_time, to_time = _timestamp(filters.get("from"), "filters.from"), _timestamp(filters.get("to"), "filters.to")
|
|
90
|
+
if from_time and to_time and from_time > to_time:
|
|
91
|
+
raise ValueError("filters.from must not be after filters.to")
|
|
92
|
+
source = planner or (value.get("planner") if isinstance(value.get("planner"), Mapping) else {})
|
|
93
|
+
result = {
|
|
94
|
+
"version": "1.0", "question": actual_question, "mode": mode,
|
|
95
|
+
"targets": _unique(value.get("targets") or ["events"], allowed=TARGETS, limit=3),
|
|
96
|
+
"filters": {
|
|
97
|
+
"source_types": _unique(filters.get("source_types"), limit=20),
|
|
98
|
+
"event_types": _unique(filters.get("event_types"), limit=20),
|
|
99
|
+
"severities": _unique(filters.get("severities"), allowed=SEVERITIES, limit=5),
|
|
100
|
+
"keywords": _unique(filters.get("keywords"), limit=32),
|
|
101
|
+
**{key: (str(filters[key])[:limit] if filters.get(key) else None) for key, limit in {
|
|
102
|
+
"device_id": 200, "run_id": 300, "session_id": 200, "relationship": 100, "rule_id": 200,
|
|
103
|
+
}.items()},
|
|
104
|
+
"from": from_time, "to": to_time,
|
|
105
|
+
},
|
|
106
|
+
"options": {
|
|
107
|
+
"limit": min(max(int(options.get("limit") or 20), 1), 100),
|
|
108
|
+
"max_scan": min(max(int(options.get("max_scan") or 300), 1), 500),
|
|
109
|
+
"top_k": min(max(int(options.get("top_k") or 10), 1), 50),
|
|
110
|
+
"graph_hops": min(max(int(options.get("graph_hops") or 1), 0), 3),
|
|
111
|
+
},
|
|
112
|
+
"planner": {"kind": str(source.get("kind") or "deterministic"), "model": source.get("model")},
|
|
113
|
+
}
|
|
114
|
+
result["plan_hash"] = hashlib.sha256(json.dumps(result, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class DeterministicSemanticPlanner:
|
|
120
|
+
def plan(self, question: str) -> dict[str, Any]:
|
|
121
|
+
return deterministic_plan(question)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ModelSemanticPlanner:
|
|
126
|
+
generate_object: Callable[[dict[str, Any]], Mapping[str, Any]]
|
|
127
|
+
model: str = "unspecified"
|
|
128
|
+
fallback: Any = None
|
|
129
|
+
|
|
130
|
+
def plan(self, question: str) -> dict[str, Any]:
|
|
131
|
+
try:
|
|
132
|
+
candidate = self.generate_object({
|
|
133
|
+
"question": question, "contract": "semantic-query-plan/1.0",
|
|
134
|
+
"allowed_targets": sorted(TARGETS), "allowed_modes": sorted(MODES),
|
|
135
|
+
"allowed_severities": sorted(SEVERITIES),
|
|
136
|
+
"instruction": "Return only a query plan; never include tenant, pipeline, URL, token, SQL, or authorization fields.",
|
|
137
|
+
})
|
|
138
|
+
return normalize_semantic_query(candidate, question=question, planner={"kind": "model", "model": self.model})
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
plan = (self.fallback or DeterministicSemanticPlanner()).plan(question)
|
|
141
|
+
plan = normalize_semantic_query(plan, question=question, planner={"kind": "deterministic_fallback", "model": self.model})
|
|
142
|
+
plan["planner_error"] = type(exc).__name__
|
|
143
|
+
return plan
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trazio-telemetry
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Scoped telemetry, artifact, investigation, and semantic-query SDK
|
|
5
|
+
Author-email: Trazio <vc@hyperlake.cloud>
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://hyperlake.cloud
|
|
8
|
+
Project-URL: Documentation, https://hyperlake.cloud/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/cerebrixos-org/sovereign-telemetry-platform
|
|
10
|
+
Keywords: opentelemetry,otlp,openinference,agents,temporal,langflow
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: certifi>=2024.2.2
|
|
19
|
+
Provides-Extra: temporal
|
|
20
|
+
Requires-Dist: temporalio[opentelemetry]>=1.20; extra == "temporal"
|
|
21
|
+
Provides-Extra: langflow
|
|
22
|
+
Requires-Dist: langflow>=1.6; extra == "langflow"
|
|
23
|
+
Provides-Extra: otel
|
|
24
|
+
Requires-Dist: opentelemetry-api>=1.30; extra == "otel"
|
|
25
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
|
|
26
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30; extra == "otel"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# Trazio Telemetry SDK for Python
|
|
33
|
+
|
|
34
|
+
Version 0.3.0 renames the package from `hyperlake-telemetry`; the API is unchanged.
|
|
35
|
+
See [SDK integration and release status](https://github.com/cerebrixos-org/sovereign-telemetry-platform/blob/main/sovereign-telemetry-platform/sdk/README.md).
|
|
36
|
+
|
|
37
|
+
`trazio-telemetry` sends standards-compatible OTLP/HTTP JSON, attaches
|
|
38
|
+
events and immutable artifacts, manages tenant-editable settings, and performs
|
|
39
|
+
scoped event, artifact, video, Run, and evidence-graph investigations. It does not replace the
|
|
40
|
+
OpenTelemetry SDK: applications with existing instrumentation should continue
|
|
41
|
+
using their normal exporter and use this package only for run context, stable
|
|
42
|
+
event identity, evaluations, and artifact attachment.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install trazio-telemetry
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from trazio_telemetry import Client, Privacy
|
|
50
|
+
|
|
51
|
+
client = Client(
|
|
52
|
+
base_url="https://us.hyperlake.cloud",
|
|
53
|
+
protocol_token="write-only-protocol-token",
|
|
54
|
+
tenant_jwt="short-lived-jwks-jwt",
|
|
55
|
+
privacy=Privacy(capture_content=False),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
with client.run("invoice-agent", goal_id="goal-42") as run:
|
|
59
|
+
with run.span("lookup-account", kind="RETRIEVER"):
|
|
60
|
+
pass
|
|
61
|
+
run.evaluate("groundedness", score=0.97, label="pass")
|
|
62
|
+
|
|
63
|
+
client.flush()
|
|
64
|
+
|
|
65
|
+
result = client.semantic_query(
|
|
66
|
+
"Show critical robot video and related faults from the last 2 hours"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
detection = client.analyze_live_frame(
|
|
70
|
+
"./frame.jpg",
|
|
71
|
+
stream_session_id="camera-session-17",
|
|
72
|
+
frame_id="frame-42",
|
|
73
|
+
device_id="robot-17",
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`analyze_live_frame()` uses the immutable model profile pinned to the JWT's
|
|
78
|
+
pipeline. It requires a short-lived JWT with `live:analyze` and `event:ingest`.
|
|
79
|
+
The response is a factual object-detection result; the attached live Rule Packs
|
|
80
|
+
determine whether it becomes an alert or notification.
|
|
81
|
+
|
|
82
|
+
The JWT determines tenant and pipeline routing. Explicit routing IDs are
|
|
83
|
+
optional with a JWT and are rejected when they conflict with its claims. Local
|
|
84
|
+
claim decoding is not signature verification; the regional service verifies
|
|
85
|
+
signature, issuer, audience, expiry, permissions, tenant, and pipeline for every
|
|
86
|
+
request.
|
|
87
|
+
Prompt, response, tool argument, and tool result content is suppressed by
|
|
88
|
+
default. Set `capture_content=True` only after applying an approved data policy.
|
|
89
|
+
|
|
90
|
+
See the repository integration guides for Temporal, Langflow, OpenInference,
|
|
91
|
+
Collector, and Alloy examples.
|
|
92
|
+
|
|
93
|
+
## Reliable semantic queries
|
|
94
|
+
|
|
95
|
+
`semantic_query()` creates and validates a versioned, bounded query plan before
|
|
96
|
+
calling any endpoint. Plans may target event retrieval, artifact/vector search,
|
|
97
|
+
and evidence-graph traversal, but cannot contain tenant, pipeline, URL, token,
|
|
98
|
+
SQL, or authorization fields. Every response includes the plan hash and scope
|
|
99
|
+
provenance.
|
|
100
|
+
|
|
101
|
+
`DeterministicSemanticPlanner` works without an LLM. `ModelSemanticPlanner`
|
|
102
|
+
accepts any callable that returns a JSON-like object; unsafe or invalid model
|
|
103
|
+
output falls back to the deterministic plan. A model therefore interprets user
|
|
104
|
+
intent but cannot expand authorization or execute arbitrary queries.
|
|
105
|
+
|
|
106
|
+
Artifact and video retrieval uses configured vector embeddings. Event retrieval
|
|
107
|
+
is bounded structured/lexical retrieval, while graph results come from recorded
|
|
108
|
+
scoped evidence edges. These signals remain separate and similarity is not
|
|
109
|
+
treated as causation.
|
|
110
|
+
|
|
111
|
+
The client also exposes event listing/search, artifact search/upload/download,
|
|
112
|
+
typed telemetry, video playback/timeline, Runs, evidence graphs, tenant config,
|
|
113
|
+
protocol-token rotation, and Rule Pack validate/dry-run/publish helpers.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hyperlake_telemetry/__init__.py,sha256=SdQYQa525QhPCqWD3ywwhjebsVjg4d7SJDnBH_vy3SQ,749
|
|
2
|
+
trazio_telemetry/__init__.py,sha256=qAjZeJg9D2hNagXGuwb0y7Nokv8CChuLIgjTZ08zh9Q,508
|
|
3
|
+
trazio_telemetry/client.py,sha256=x-tH7MTR-bShoq6mrcMRTQm4QUiRjX3nKvpuruT_fs8,38704
|
|
4
|
+
trazio_telemetry/semantic_query.py,sha256=m94KduI5oznQSysfhyI1v_GOp98FkGe8-yTB5-hxv8I,7178
|
|
5
|
+
trazio_telemetry/integrations/__init__.py,sha256=FXPNdoH9stxeM6DWlSEHfOjtg6ZWUsDRFJhR4pgsXN4,199
|
|
6
|
+
trazio_telemetry/integrations/openinference.py,sha256=KEfWjFBhd56BtH_jLiAZqJ9n0U3s8it8WDqQx0Fr8mc,545
|
|
7
|
+
trazio_telemetry/integrations/temporal.py,sha256=bPP9m-TE9LPE0ay-U9VNPOboe5OPCB49UqIv0DO4EvU,670
|
|
8
|
+
trazio_telemetry-0.3.0.dist-info/METADATA,sha256=HfWPNfKJl6FSBmidgoKbS62s1SuRKa-wVJ2nBxFWDr8,4858
|
|
9
|
+
trazio_telemetry-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
trazio_telemetry-0.3.0.dist-info/top_level.txt,sha256=Fu_LSWQvNm83gwFuHzsN-oGSpS6CPeBBTt4_ZyN-Z6o,37
|
|
11
|
+
trazio_telemetry-0.3.0.dist-info/RECORD,,
|