hyperlake-telemetry 0.1.0__tar.gz

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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperlake-telemetry
3
+ Version: 0.1.0
4
+ Summary: Standards-first telemetry and artifact SDK for Hyperlake
5
+ Author-email: Hyperlake <vc@hyperlake.cloud>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://hyperlake.cloud
8
+ Project-URL: Documentation, https://hyperlake.cloud/docs
9
+ Keywords: opentelemetry,otlp,openinference,agents,temporal,langflow
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: certifi>=2024.2.2
18
+ Provides-Extra: temporal
19
+ Requires-Dist: temporalio[opentelemetry]>=1.20; extra == "temporal"
20
+ Provides-Extra: langflow
21
+ Requires-Dist: langflow>=1.6; extra == "langflow"
22
+ Provides-Extra: otel
23
+ Requires-Dist: opentelemetry-api>=1.30; extra == "otel"
24
+ Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30; extra == "otel"
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == "dev"
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: twine>=6; extra == "dev"
30
+
31
+ # Hyperlake Telemetry SDK for Python
32
+
33
+ `hyperlake-telemetry` sends standards-compatible OTLP/HTTP JSON and can attach
34
+ events or immutable artifacts to the same pipeline. It does not replace the
35
+ OpenTelemetry SDK: applications with existing instrumentation should continue
36
+ using their normal exporter and use this package only for run context, stable
37
+ event identity, evaluations, and artifact attachment.
38
+
39
+ ```bash
40
+ pip install hyperlake-telemetry
41
+ ```
42
+
43
+ ```python
44
+ from hyperlake_telemetry import Client, Privacy
45
+
46
+ client = Client(
47
+ base_url="https://us.hyperlake.cloud",
48
+ tenant_id="tenant-public-id",
49
+ pipeline_id="pipeline-public-id",
50
+ protocol_token="write-only-protocol-token",
51
+ tenant_jwt="short-lived-jwks-jwt",
52
+ privacy=Privacy(capture_content=False),
53
+ )
54
+
55
+ with client.run("invoice-agent", goal_id="goal-42") as run:
56
+ with run.span("lookup-account", kind="RETRIEVER"):
57
+ pass
58
+ run.evaluate("groundedness", score=0.97, label="pass")
59
+
60
+ client.flush()
61
+ ```
62
+
63
+ The authenticated credential determines the authoritative tenant and pipeline.
64
+ The IDs supplied by the application are routing hints and must match the token.
65
+ Prompt, response, tool argument, and tool result content is suppressed by
66
+ default. Set `capture_content=True` only after applying an approved data policy.
67
+
68
+ See the repository integration guides for Temporal, Langflow, OpenInference,
69
+ Collector, and Alloy examples.
70
+
@@ -0,0 +1,40 @@
1
+ # Hyperlake Telemetry SDK for Python
2
+
3
+ `hyperlake-telemetry` sends standards-compatible OTLP/HTTP JSON and can attach
4
+ events or immutable artifacts to the same pipeline. It does not replace the
5
+ OpenTelemetry SDK: applications with existing instrumentation should continue
6
+ using their normal exporter and use this package only for run context, stable
7
+ event identity, evaluations, and artifact attachment.
8
+
9
+ ```bash
10
+ pip install hyperlake-telemetry
11
+ ```
12
+
13
+ ```python
14
+ from hyperlake_telemetry import Client, Privacy
15
+
16
+ client = Client(
17
+ base_url="https://us.hyperlake.cloud",
18
+ tenant_id="tenant-public-id",
19
+ pipeline_id="pipeline-public-id",
20
+ protocol_token="write-only-protocol-token",
21
+ tenant_jwt="short-lived-jwks-jwt",
22
+ privacy=Privacy(capture_content=False),
23
+ )
24
+
25
+ with client.run("invoice-agent", goal_id="goal-42") as run:
26
+ with run.span("lookup-account", kind="RETRIEVER"):
27
+ pass
28
+ run.evaluate("groundedness", score=0.97, label="pass")
29
+
30
+ client.flush()
31
+ ```
32
+
33
+ The authenticated credential determines the authoritative tenant and pipeline.
34
+ The IDs supplied by the application are routing hints and must match the token.
35
+ Prompt, response, tool argument, and tool result content is suppressed by
36
+ default. Set `capture_content=True` only after applying an approved data policy.
37
+
38
+ See the repository integration guides for Temporal, Langflow, OpenInference,
39
+ Collector, and Alloy examples.
40
+
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hyperlake-telemetry"
7
+ version = "0.1.0"
8
+ description = "Standards-first telemetry and artifact SDK for Hyperlake"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [{name = "Hyperlake", email = "vc@hyperlake.cloud"}]
13
+ keywords = ["opentelemetry", "otlp", "openinference", "agents", "temporal", "langflow"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: System :: Monitoring",
20
+ ]
21
+ dependencies = ["certifi>=2024.2.2"]
22
+
23
+ [project.optional-dependencies]
24
+ temporal = ["temporalio[opentelemetry]>=1.20"]
25
+ langflow = ["langflow>=1.6"]
26
+ otel = [
27
+ "opentelemetry-api>=1.30",
28
+ "opentelemetry-sdk>=1.30",
29
+ "opentelemetry-exporter-otlp-proto-http>=1.30",
30
+ ]
31
+ dev = ["build>=1.2", "pytest>=8", "twine>=6"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://hyperlake.cloud"
35
+ Documentation = "https://hyperlake.cloud/docs"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Standards-first OTLP telemetry and artifact helpers."""
2
+
3
+ from .client import Client, Privacy, Run, Span, stable_event_id
4
+
5
+ __all__ = ["Client", "Privacy", "Run", "Span", "stable_event_id"]
6
+ __version__ = "0.1.0"
7
+
@@ -0,0 +1,404 @@
1
+ """Small dependency-free OTLP/HTTP client with explicit privacy controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import ssl
11
+ import time
12
+ from contextlib import AbstractContextManager
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, Mapping, Optional
16
+ from urllib.error import HTTPError, URLError
17
+ from urllib.parse import quote
18
+ from urllib.request import Request, urlopen
19
+
20
+ import certifi
21
+
22
+
23
+ _SENSITIVE_KEY = re.compile(
24
+ r"(^|[._-])(authorization|cookie|password|passwd|secret|token|api[._-]?key|private[._-]?key)([._-]|$)",
25
+ re.IGNORECASE,
26
+ )
27
+
28
+
29
+ def stable_event_id(*parts: Any) -> str:
30
+ """Return a deterministic identity for a logical event or evaluation."""
31
+ canonical = json.dumps(parts, sort_keys=True, separators=(",", ":"), default=str)
32
+ return "evt_" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Privacy:
37
+ """Content and secret handling applied before telemetry leaves the process."""
38
+
39
+ capture_content: bool = False
40
+ redacted_keys: tuple[str, ...] = ()
41
+ max_string_length: int = 4096
42
+ replacement: str = "[REDACTED]"
43
+
44
+ def sanitize(self, value: Any, *, content: bool = False) -> Any:
45
+ if content and not self.capture_content:
46
+ return self.replacement
47
+ return self._walk(value)
48
+
49
+ def _walk(self, value: Any, key: str = "") -> Any:
50
+ if key in self.redacted_keys or _SENSITIVE_KEY.search(key):
51
+ return self.replacement
52
+ if isinstance(value, Mapping):
53
+ return {str(k): self._walk(v, str(k)) for k, v in value.items()}
54
+ if isinstance(value, (list, tuple)):
55
+ return [self._walk(item, key) for item in value]
56
+ if isinstance(value, str) and len(value) > self.max_string_length:
57
+ return value[: self.max_string_length] + "…[TRUNCATED]"
58
+ if value is None or isinstance(value, (str, bool, int, float)):
59
+ return value
60
+ return str(value)
61
+
62
+
63
+ def _any_value(value: Any) -> Dict[str, Any]:
64
+ if value is None:
65
+ return {"stringValue": ""}
66
+ if isinstance(value, bool):
67
+ return {"boolValue": value}
68
+ if isinstance(value, int):
69
+ return {"intValue": str(value)}
70
+ if isinstance(value, float):
71
+ return {"doubleValue": value}
72
+ if isinstance(value, (dict, list, tuple)):
73
+ return {"stringValue": json.dumps(value, sort_keys=True, separators=(",", ":"))}
74
+ return {"stringValue": str(value)}
75
+
76
+
77
+ def _attributes(values: Mapping[str, Any]) -> list[Dict[str, Any]]:
78
+ return [{"key": str(k), "value": _any_value(v)} for k, v in values.items() if v is not None]
79
+
80
+
81
+ @dataclass
82
+ class Span(AbstractContextManager["Span"]):
83
+ client: "Client"
84
+ name: str
85
+ trace_id: str
86
+ span_id: str
87
+ parent_span_id: Optional[str]
88
+ attributes: Dict[str, Any]
89
+ start_ns: int = field(default_factory=time.time_ns)
90
+ end_ns: Optional[int] = None
91
+ status_code: int = 0
92
+ status_message: str = ""
93
+ events: list[Dict[str, Any]] = field(default_factory=list)
94
+ _ended: bool = False
95
+
96
+ def __enter__(self) -> "Span":
97
+ return self
98
+
99
+ def __exit__(self, exc_type, exc, traceback) -> bool:
100
+ if exc is not None:
101
+ self.status_code = 2
102
+ self.status_message = str(exc)
103
+ self.add_event("exception", {"exception.type": exc_type.__name__, "exception.message": str(exc)})
104
+ self.end()
105
+ return False
106
+
107
+ def set_attribute(self, key: str, value: Any, *, content: bool = False) -> "Span":
108
+ self.attributes[key] = self.client.privacy.sanitize(value, content=content)
109
+ return self
110
+
111
+ def add_event(self, name: str, attributes: Optional[Mapping[str, Any]] = None) -> "Span":
112
+ clean = self.client.privacy.sanitize(dict(attributes or {}))
113
+ self.events.append({"timeUnixNano": str(time.time_ns()), "name": name, "attributes": _attributes(clean)})
114
+ return self
115
+
116
+ def end(self, *, error: Optional[str] = None) -> None:
117
+ if self._ended:
118
+ return
119
+ if error:
120
+ self.status_code = 2
121
+ self.status_message = error
122
+ self.end_ns = time.time_ns()
123
+ self._ended = True
124
+ self.client._finish_span(self)
125
+
126
+ def as_otlp(self) -> Dict[str, Any]:
127
+ body: Dict[str, Any] = {
128
+ "traceId": self.trace_id,
129
+ "spanId": self.span_id,
130
+ "name": self.name,
131
+ "kind": 1,
132
+ "startTimeUnixNano": str(self.start_ns),
133
+ "endTimeUnixNano": str(self.end_ns or time.time_ns()),
134
+ "attributes": _attributes(self.attributes),
135
+ "events": self.events,
136
+ "status": {"code": self.status_code, "message": self.status_message},
137
+ }
138
+ if self.parent_span_id:
139
+ body["parentSpanId"] = self.parent_span_id
140
+ return body
141
+
142
+
143
+ class Run(AbstractContextManager["Run"]):
144
+ def __init__(self, client: "Client", root: Span, goal_id: Optional[str]) -> None:
145
+ self.client = client
146
+ self.root = root
147
+ self.goal_id = goal_id
148
+
149
+ @property
150
+ def trace_id(self) -> str:
151
+ return self.root.trace_id
152
+
153
+ @property
154
+ def run_id(self) -> str:
155
+ return str(self.root.attributes["run.id"])
156
+
157
+ def __enter__(self) -> "Run":
158
+ return self
159
+
160
+ def __exit__(self, exc_type, exc, traceback) -> bool:
161
+ return self.root.__exit__(exc_type, exc, traceback)
162
+
163
+ def span(
164
+ self,
165
+ name: str,
166
+ *,
167
+ kind: str = "CHAIN",
168
+ attributes: Optional[Mapping[str, Any]] = None,
169
+ ) -> Span:
170
+ merged = {
171
+ "openinference.span.kind": kind.upper(),
172
+ "run.id": self.run_id,
173
+ "goal.id": self.goal_id,
174
+ **dict(attributes or {}),
175
+ }
176
+ return self.client.start_span(
177
+ name,
178
+ trace_id=self.trace_id,
179
+ parent_span_id=self.root.span_id,
180
+ attributes=merged,
181
+ )
182
+
183
+ def evaluate(self, name: str, *, score: float, label: str, explanation: str = "") -> None:
184
+ self.root.add_event(
185
+ "gen_ai.evaluation",
186
+ {
187
+ "gen_ai.evaluation.name": name,
188
+ "gen_ai.evaluation.score.value": score,
189
+ "gen_ai.evaluation.score.label": label,
190
+ "gen_ai.evaluation.explanation": explanation,
191
+ },
192
+ )
193
+
194
+
195
+ class Client:
196
+ """OTLP sender plus authenticated event and artifact helpers."""
197
+
198
+ def __init__(
199
+ self,
200
+ *,
201
+ base_url: str,
202
+ tenant_id: str,
203
+ pipeline_id: str,
204
+ protocol_token: Optional[str] = None,
205
+ tenant_jwt: Optional[str] = None,
206
+ service_name: str = "application",
207
+ service_version: Optional[str] = None,
208
+ environment: Optional[str] = None,
209
+ privacy: Optional[Privacy] = None,
210
+ timeout: float = 30.0,
211
+ spool_directory: Optional[str] = None,
212
+ max_spool_files: int = 256,
213
+ auto_flush_spans: int = 64,
214
+ ssl_context: Optional[ssl.SSLContext] = None,
215
+ ) -> None:
216
+ self.base_url = base_url.rstrip("/")
217
+ self.tenant_id = tenant_id
218
+ self.pipeline_id = pipeline_id
219
+ self.protocol_token = protocol_token
220
+ self.tenant_jwt = tenant_jwt
221
+ self.service_name = service_name
222
+ self.service_version = service_version
223
+ self.environment = environment
224
+ self.privacy = privacy or Privacy()
225
+ self.timeout = timeout
226
+ self.max_spool_files = max_spool_files
227
+ self.auto_flush_spans = auto_flush_spans
228
+ self.ssl_context = ssl_context or ssl.create_default_context(cafile=certifi.where())
229
+ self._pending: list[Span] = []
230
+ self.spool_directory = Path(spool_directory) if spool_directory else None
231
+ if self.spool_directory:
232
+ self.spool_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
233
+
234
+ def run(
235
+ self,
236
+ name: str,
237
+ *,
238
+ run_id: Optional[str] = None,
239
+ goal_id: Optional[str] = None,
240
+ agent_id: Optional[str] = None,
241
+ attributes: Optional[Mapping[str, Any]] = None,
242
+ ) -> Run:
243
+ actual_run_id = run_id or secrets.token_hex(16)
244
+ root = self.start_span(
245
+ name,
246
+ attributes={
247
+ "openinference.span.kind": "AGENT",
248
+ "gen_ai.operation.name": "invoke_agent",
249
+ "gen_ai.agent.id": agent_id,
250
+ "gen_ai.agent.name": name,
251
+ "run.id": actual_run_id,
252
+ "goal.id": goal_id,
253
+ **dict(attributes or {}),
254
+ },
255
+ )
256
+ return Run(self, root, goal_id)
257
+
258
+ def start_span(
259
+ self,
260
+ name: str,
261
+ *,
262
+ trace_id: Optional[str] = None,
263
+ parent_span_id: Optional[str] = None,
264
+ attributes: Optional[Mapping[str, Any]] = None,
265
+ ) -> Span:
266
+ clean = self.privacy.sanitize(dict(attributes or {}))
267
+ return Span(
268
+ client=self,
269
+ name=name,
270
+ trace_id=trace_id or secrets.token_hex(16),
271
+ span_id=secrets.token_hex(8),
272
+ parent_span_id=parent_span_id,
273
+ attributes=clean,
274
+ )
275
+
276
+ def _finish_span(self, span: Span) -> None:
277
+ self._pending.append(span)
278
+ if len(self._pending) >= self.auto_flush_spans:
279
+ self.flush()
280
+
281
+ def _otlp_payload(self, spans: Iterable[Span]) -> Dict[str, Any]:
282
+ resource = {
283
+ "service.name": self.service_name,
284
+ "service.version": self.service_version,
285
+ "deployment.environment.name": self.environment,
286
+ }
287
+ return {
288
+ "resourceSpans": [{
289
+ "resource": {"attributes": _attributes(resource)},
290
+ "scopeSpans": [{
291
+ "scope": {"name": "hyperlake-telemetry", "version": "0.1.0"},
292
+ "spans": [span.as_otlp() for span in spans],
293
+ }],
294
+ }],
295
+ }
296
+
297
+ def flush(self) -> None:
298
+ self._drain_spool()
299
+ if not self._pending:
300
+ return
301
+ spans, self._pending = self._pending, []
302
+ payload = self._otlp_payload(spans)
303
+ try:
304
+ self._request("POST", "/v1/traces", payload, protocol=True)
305
+ except Exception:
306
+ self._spool(payload)
307
+ raise
308
+
309
+ def record_event(
310
+ self,
311
+ event: Mapping[str, Any],
312
+ *,
313
+ idempotency_key: Optional[str] = None,
314
+ ) -> Dict[str, Any]:
315
+ key = idempotency_key or stable_event_id(self.tenant_id, self.pipeline_id, event)
316
+ path = f"/v1/ingest/{quote(self.tenant_id)}/{quote(self.pipeline_id)}"
317
+ return self._request("POST", path, self.privacy.sanitize(dict(event)), jwt=True, extra_headers={"Idempotency-Key": key})
318
+
319
+ def upload_artifact(
320
+ self,
321
+ path: str,
322
+ *,
323
+ format: str = "auto",
324
+ device_id: Optional[str] = None,
325
+ goal_id: Optional[str] = None,
326
+ metadata: Optional[Mapping[str, Any]] = None,
327
+ ) -> Dict[str, Any]:
328
+ source = Path(path)
329
+ base = f"/v1/artifacts/{quote(self.tenant_id)}/{quote(self.pipeline_id)}/sessions"
330
+ session = self._request(
331
+ "POST",
332
+ base,
333
+ {
334
+ "filename": source.name,
335
+ "format": format,
336
+ "device_id": device_id,
337
+ "goal_id": goal_id,
338
+ "metadata": self.privacy.sanitize(dict(metadata or {})),
339
+ },
340
+ jwt=True,
341
+ )
342
+ session_id = session["session_id"]
343
+ content = source.read_bytes()
344
+ self._request("PUT", f"{base}/{quote(session_id)}/content", content, jwt=True, content_type="application/octet-stream")
345
+ return self._request("POST", f"{base}/{quote(session_id)}/complete", None, jwt=True)
346
+
347
+ def _auth_headers(self, *, protocol: bool = False, jwt: bool = False) -> Dict[str, str]:
348
+ token = self.protocol_token if protocol else self.tenant_jwt if jwt else None
349
+ if not token:
350
+ needed = "protocol_token" if protocol else "tenant_jwt"
351
+ raise ValueError(f"{needed} is required for this operation")
352
+ headers = {"Authorization": f"Bearer {token}"}
353
+ if protocol:
354
+ headers.update({
355
+ "X-Platform-Tenant": self.tenant_id,
356
+ "X-Platform-Pipeline": self.pipeline_id,
357
+ })
358
+ return headers
359
+
360
+ def _request(
361
+ self,
362
+ method: str,
363
+ path: str,
364
+ payload: Any,
365
+ *,
366
+ protocol: bool = False,
367
+ jwt: bool = False,
368
+ content_type: str = "application/json",
369
+ extra_headers: Optional[Mapping[str, str]] = None,
370
+ ) -> Dict[str, Any]:
371
+ headers = self._auth_headers(protocol=protocol, jwt=jwt)
372
+ headers.update(extra_headers or {})
373
+ headers["Content-Type"] = content_type
374
+ headers["Accept"] = "application/json"
375
+ headers["User-Agent"] = "hyperlake-telemetry-python/0.1.0"
376
+ data = payload if isinstance(payload, bytes) else None if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8")
377
+ request = Request(self.base_url + path, data=data, method=method, headers=headers)
378
+ try:
379
+ with urlopen(request, timeout=self.timeout, context=self.ssl_context) as response:
380
+ body = response.read()
381
+ return json.loads(body) if body else {"status": response.status}
382
+ except HTTPError as exc:
383
+ detail = exc.read().decode("utf-8", errors="replace")[:2048]
384
+ raise RuntimeError(f"HTTP {exc.code} from {path}: {detail}") from exc
385
+ except URLError as exc:
386
+ raise RuntimeError(f"Transport failure for {path}: {exc.reason}") from exc
387
+
388
+ def _spool(self, payload: Mapping[str, Any]) -> None:
389
+ if not self.spool_directory:
390
+ return
391
+ files = sorted(self.spool_directory.glob("*.json"))
392
+ while len(files) >= self.max_spool_files:
393
+ files.pop(0).unlink(missing_ok=True)
394
+ target = self.spool_directory / f"{time.time_ns()}-{secrets.token_hex(4)}.json"
395
+ target.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
396
+ os.chmod(target, 0o600)
397
+
398
+ def _drain_spool(self) -> None:
399
+ if not self.spool_directory:
400
+ return
401
+ for item in sorted(self.spool_directory.glob("*.json")):
402
+ payload = json.loads(item.read_text(encoding="utf-8"))
403
+ self._request("POST", "/v1/traces", payload, protocol=True)
404
+ item.unlink()
@@ -0,0 +1,7 @@
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"]
7
+
@@ -0,0 +1,19 @@
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
19
+
@@ -0,0 +1,24 @@
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
+ }
24
+
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: hyperlake-telemetry
3
+ Version: 0.1.0
4
+ Summary: Standards-first telemetry and artifact SDK for Hyperlake
5
+ Author-email: Hyperlake <vc@hyperlake.cloud>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://hyperlake.cloud
8
+ Project-URL: Documentation, https://hyperlake.cloud/docs
9
+ Keywords: opentelemetry,otlp,openinference,agents,temporal,langflow
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: certifi>=2024.2.2
18
+ Provides-Extra: temporal
19
+ Requires-Dist: temporalio[opentelemetry]>=1.20; extra == "temporal"
20
+ Provides-Extra: langflow
21
+ Requires-Dist: langflow>=1.6; extra == "langflow"
22
+ Provides-Extra: otel
23
+ Requires-Dist: opentelemetry-api>=1.30; extra == "otel"
24
+ Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30; extra == "otel"
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == "dev"
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: twine>=6; extra == "dev"
30
+
31
+ # Hyperlake Telemetry SDK for Python
32
+
33
+ `hyperlake-telemetry` sends standards-compatible OTLP/HTTP JSON and can attach
34
+ events or immutable artifacts to the same pipeline. It does not replace the
35
+ OpenTelemetry SDK: applications with existing instrumentation should continue
36
+ using their normal exporter and use this package only for run context, stable
37
+ event identity, evaluations, and artifact attachment.
38
+
39
+ ```bash
40
+ pip install hyperlake-telemetry
41
+ ```
42
+
43
+ ```python
44
+ from hyperlake_telemetry import Client, Privacy
45
+
46
+ client = Client(
47
+ base_url="https://us.hyperlake.cloud",
48
+ tenant_id="tenant-public-id",
49
+ pipeline_id="pipeline-public-id",
50
+ protocol_token="write-only-protocol-token",
51
+ tenant_jwt="short-lived-jwks-jwt",
52
+ privacy=Privacy(capture_content=False),
53
+ )
54
+
55
+ with client.run("invoice-agent", goal_id="goal-42") as run:
56
+ with run.span("lookup-account", kind="RETRIEVER"):
57
+ pass
58
+ run.evaluate("groundedness", score=0.97, label="pass")
59
+
60
+ client.flush()
61
+ ```
62
+
63
+ The authenticated credential determines the authoritative tenant and pipeline.
64
+ The IDs supplied by the application are routing hints and must match the token.
65
+ Prompt, response, tool argument, and tool result content is suppressed by
66
+ default. Set `capture_content=True` only after applying an approved data policy.
67
+
68
+ See the repository integration guides for Temporal, Langflow, OpenInference,
69
+ Collector, and Alloy examples.
70
+
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/hyperlake_telemetry/__init__.py
4
+ src/hyperlake_telemetry/client.py
5
+ src/hyperlake_telemetry.egg-info/PKG-INFO
6
+ src/hyperlake_telemetry.egg-info/SOURCES.txt
7
+ src/hyperlake_telemetry.egg-info/dependency_links.txt
8
+ src/hyperlake_telemetry.egg-info/requires.txt
9
+ src/hyperlake_telemetry.egg-info/top_level.txt
10
+ src/hyperlake_telemetry/integrations/__init__.py
11
+ src/hyperlake_telemetry/integrations/openinference.py
12
+ src/hyperlake_telemetry/integrations/temporal.py
13
+ tests/test_client.py
@@ -0,0 +1,17 @@
1
+ certifi>=2024.2.2
2
+
3
+ [dev]
4
+ build>=1.2
5
+ pytest>=8
6
+ twine>=6
7
+
8
+ [langflow]
9
+ langflow>=1.6
10
+
11
+ [otel]
12
+ opentelemetry-api>=1.30
13
+ opentelemetry-sdk>=1.30
14
+ opentelemetry-exporter-otlp-proto-http>=1.30
15
+
16
+ [temporal]
17
+ temporalio[opentelemetry]>=1.20
@@ -0,0 +1 @@
1
+ hyperlake_telemetry
@@ -0,0 +1,48 @@
1
+ import json
2
+
3
+ from hyperlake_telemetry import Client, Privacy, stable_event_id
4
+ from hyperlake_telemetry.integrations import span_attributes, temporal_attributes
5
+
6
+
7
+ def test_stable_event_id_is_deterministic():
8
+ one = stable_event_id("tenant", "pipeline", {"b": 2, "a": 1})
9
+ two = stable_event_id("tenant", "pipeline", {"a": 1, "b": 2})
10
+ assert one == two
11
+ assert one.startswith("evt_")
12
+
13
+
14
+ def test_privacy_redacts_secrets_content_and_long_values():
15
+ privacy = Privacy(max_string_length=4)
16
+ assert privacy.sanitize("prompt", content=True) == "[REDACTED]"
17
+ assert privacy.sanitize({"api_key": "abc", "safe": "123456"}) == {
18
+ "api_key": "[REDACTED]",
19
+ "safe": "1234…[TRUNCATED]",
20
+ }
21
+
22
+
23
+ def test_run_builds_otlp_tree_without_exporting_content():
24
+ client = Client(
25
+ base_url="https://example.invalid",
26
+ tenant_id="tenant",
27
+ pipeline_id="pipeline",
28
+ protocol_token="token",
29
+ auto_flush_spans=100,
30
+ )
31
+ with client.run("agent", run_id="run-1", goal_id="goal-1") as run:
32
+ with run.span("tool", kind="TOOL") as child:
33
+ child.set_attribute("tool.arguments", {"password": "secret"}, content=True)
34
+ payload = client._otlp_payload(client._pending)
35
+ spans = payload["resourceSpans"][0]["scopeSpans"][0]["spans"]
36
+ assert len(spans) == 2
37
+ assert spans[0]["parentSpanId"] == spans[1]["spanId"]
38
+ encoded = json.dumps(payload)
39
+ assert "secret" not in encoded
40
+ assert "[REDACTED]" in encoded
41
+
42
+
43
+ def test_integration_attribute_mappings():
44
+ assert span_attributes("tool")["openinference.span.kind"] == "TOOL"
45
+ attrs = temporal_attributes(workflow_id="wf", run_id="run", attempt=2)
46
+ assert attrs["run.id"] == "run"
47
+ assert attrs["temporal.activity.attempt"] == 2
48
+