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.
@@ -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,6 @@
1
+ """Helpers for established workflow and agent runtimes."""
2
+
3
+ from .openinference import span_attributes
4
+ from .temporal import temporal_attributes
5
+
6
+ __all__ = ["span_attributes", "temporal_attributes"]
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ hyperlake_telemetry
2
+ trazio_telemetry