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.
- hyperlake_telemetry-0.1.0/PKG-INFO +70 -0
- hyperlake_telemetry-0.1.0/README.md +40 -0
- hyperlake_telemetry-0.1.0/pyproject.toml +41 -0
- hyperlake_telemetry-0.1.0/setup.cfg +4 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry/__init__.py +7 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry/client.py +404 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry/integrations/__init__.py +7 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry/integrations/openinference.py +19 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry/integrations/temporal.py +24 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry.egg-info/PKG-INFO +70 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry.egg-info/SOURCES.txt +13 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry.egg-info/dependency_links.txt +1 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry.egg-info/requires.txt +17 -0
- hyperlake_telemetry-0.1.0/src/hyperlake_telemetry.egg-info/top_level.txt +1 -0
- hyperlake_telemetry-0.1.0/tests/test_client.py +48 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|
+
|