@inline-chat/hermes-agent-adapter 0.0.12 → 0.0.14
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.
- package/README.md +28 -3
- package/package.json +6 -5
- package/plugin/inline/adapter.py +92 -11
- package/plugin/inline/cli.py +48 -1
- package/plugin/inline/plugin.yaml +1 -1
- package/plugin/inline/sidecar/index.mjs +2751 -273
- package/plugin/inline/telemetry.py +222 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Opt-in, privacy-bounded Sentry error reporting for the Inline Hermes plugin.
|
|
2
|
+
|
|
3
|
+
When an operator configures a collector, the plugin sends only exception
|
|
4
|
+
type/message, traceback paths/lines/functions, release, and fixed runtime tags.
|
|
5
|
+
It never sends Hermes events, messages, request bodies, user/chat/account
|
|
6
|
+
identifiers, breadcrumbs, or stack locals.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import traceback
|
|
17
|
+
import urllib.request
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
_TELEMETRY_TIMEOUT_SECONDS = 2.0
|
|
24
|
+
_TELEMETRY_DEDUP_SECONDS = 5 * 60
|
|
25
|
+
_MAX_ERROR_MESSAGE_LENGTH = 8_000
|
|
26
|
+
_MAX_STACK_FRAMES = 80
|
|
27
|
+
_SENSITIVE_ENV_NAME = re.compile(r"(?:token|secret|password|api[_-]?key|authorization)", re.IGNORECASE)
|
|
28
|
+
_SAFE_TAG = re.compile(r"^[a-z0-9._-]+$")
|
|
29
|
+
_last_reports: dict[str, float] = {}
|
|
30
|
+
_report_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _telemetry_disabled(env: Mapping[str, str]) -> bool:
|
|
34
|
+
do_not_track = str(env.get("DO_NOT_TRACK") or "").strip().lower()
|
|
35
|
+
plugin_telemetry = str(env.get("INLINE_PLUGIN_TELEMETRY") or "").strip().lower()
|
|
36
|
+
return do_not_track in {"1", "true", "yes", "on"} or plugin_telemetry in {"0", "false", "off"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_dsn(env: Mapping[str, str]) -> str:
|
|
40
|
+
if _telemetry_disabled(env):
|
|
41
|
+
return ""
|
|
42
|
+
return str(env.get("INLINE_HERMES_SENTRY_DSN") or "").strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sensitive_values(env: Mapping[str, str], secrets: Sequence[str]) -> list[str]:
|
|
46
|
+
values = [str(secret) for secret in secrets if len(str(secret)) >= 8]
|
|
47
|
+
values.extend(
|
|
48
|
+
str(value)
|
|
49
|
+
for name, value in env.items()
|
|
50
|
+
if _SENSITIVE_ENV_NAME.search(str(name)) and len(str(value)) >= 8
|
|
51
|
+
)
|
|
52
|
+
return list(dict.fromkeys(values))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def redact_telemetry_text(
|
|
56
|
+
value: Any,
|
|
57
|
+
*,
|
|
58
|
+
env: Optional[Mapping[str, str]] = None,
|
|
59
|
+
secrets: Sequence[str] = (),
|
|
60
|
+
) -> str:
|
|
61
|
+
source_env = os.environ if env is None else env
|
|
62
|
+
text = str(value or "")
|
|
63
|
+
text = re.sub(
|
|
64
|
+
r"\b(Authorization\s*[:=]\s*)(?:Basic|Bearer)\s+\S+",
|
|
65
|
+
r"\1[REDACTED]",
|
|
66
|
+
text,
|
|
67
|
+
flags=re.IGNORECASE,
|
|
68
|
+
)
|
|
69
|
+
text = re.sub(r"\b((?:Basic|Bearer)\s+)\S+", r"\1[REDACTED]", text, flags=re.IGNORECASE)
|
|
70
|
+
text = re.sub(r"(https?://)[^/\s:@]+:[^@\s/]+@", r"\1[REDACTED]@", text, flags=re.IGNORECASE)
|
|
71
|
+
text = re.sub(
|
|
72
|
+
r"([?&](?:access_token|auth|authorization|key|password|secret|token)[^=\s&]*)=([^&\s]+)",
|
|
73
|
+
r"\1=[REDACTED]",
|
|
74
|
+
text,
|
|
75
|
+
flags=re.IGNORECASE,
|
|
76
|
+
)
|
|
77
|
+
text = re.sub(
|
|
78
|
+
r"\b([A-Za-z0-9_-]*(?:token|secret|password|api[_-]?key|authorization)[A-Za-z0-9_-]*)\s*([=:])\s*\S+",
|
|
79
|
+
r"\1\2[REDACTED]",
|
|
80
|
+
text,
|
|
81
|
+
flags=re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
for secret in _sensitive_values(source_env, secrets):
|
|
84
|
+
text = text.replace(secret, "[REDACTED]")
|
|
85
|
+
return text[:_MAX_ERROR_MESSAGE_LENGTH]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _safe_tag(value: str) -> str:
|
|
89
|
+
normalized = str(value or "").strip().lower()
|
|
90
|
+
if normalized and len(normalized) <= 80 and _SAFE_TAG.fullmatch(normalized):
|
|
91
|
+
return normalized
|
|
92
|
+
return "unknown"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _release() -> Optional[str]:
|
|
96
|
+
try:
|
|
97
|
+
manifest = Path(__file__).with_name("plugin.yaml").read_text(encoding="utf-8")
|
|
98
|
+
match = re.search(r"^version:\s*['\"]?([^'\"\n#]+)", manifest, flags=re.MULTILINE)
|
|
99
|
+
if match and match.group(1).strip():
|
|
100
|
+
return f"inline-hermes-plugin@{match.group(1).strip()}"
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_sentry_event(
|
|
107
|
+
operation: str,
|
|
108
|
+
error: BaseException,
|
|
109
|
+
*,
|
|
110
|
+
handled: bool = True,
|
|
111
|
+
env: Optional[Mapping[str, str]] = None,
|
|
112
|
+
secrets: Sequence[str] = (),
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
source_env = os.environ if env is None else env
|
|
115
|
+
frames = []
|
|
116
|
+
if error.__traceback__ is not None:
|
|
117
|
+
for frame in traceback.extract_tb(error.__traceback__)[-_MAX_STACK_FRAMES:]:
|
|
118
|
+
filename = redact_telemetry_text(frame.filename, env=source_env, secrets=secrets)
|
|
119
|
+
frames.append({
|
|
120
|
+
"filename": filename,
|
|
121
|
+
"abs_path": filename,
|
|
122
|
+
"function": redact_telemetry_text(frame.name or "<unknown>", env=source_env, secrets=secrets),
|
|
123
|
+
"lineno": frame.lineno,
|
|
124
|
+
"in_app": "/plugin/inline/" in filename or filename.endswith("/adapter.py"),
|
|
125
|
+
})
|
|
126
|
+
event: dict[str, Any] = {
|
|
127
|
+
"event_id": uuid.uuid4().hex,
|
|
128
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
129
|
+
"platform": "python",
|
|
130
|
+
"level": "error",
|
|
131
|
+
"logger": "inline.hermes.plugin",
|
|
132
|
+
"exception": {"values": [{
|
|
133
|
+
"type": redact_telemetry_text(type(error).__name__, env=source_env, secrets=secrets),
|
|
134
|
+
"value": redact_telemetry_text(str(error), env=source_env, secrets=secrets),
|
|
135
|
+
"mechanism": {"type": "inline_plugin_boundary", "handled": handled},
|
|
136
|
+
**({"stacktrace": {"frames": frames}} if frames else {}),
|
|
137
|
+
}]},
|
|
138
|
+
"tags": {
|
|
139
|
+
"operation": _safe_tag(operation),
|
|
140
|
+
"component": "adapter",
|
|
141
|
+
"runtime": "python",
|
|
142
|
+
"os": platform.system().lower() or "unknown",
|
|
143
|
+
"arch": platform.machine().lower() or "unknown",
|
|
144
|
+
},
|
|
145
|
+
"sdk": {"name": "inline.plugin.telemetry", "version": "1"},
|
|
146
|
+
}
|
|
147
|
+
release = _release()
|
|
148
|
+
if release:
|
|
149
|
+
event["release"] = release
|
|
150
|
+
return event
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _sentry_target(dsn: str) -> Optional[tuple[str, str]]:
|
|
154
|
+
parsed = urlparse(dsn)
|
|
155
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or not parsed.username:
|
|
156
|
+
return None
|
|
157
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
158
|
+
if not parts or not parts[-1].isdigit():
|
|
159
|
+
return None
|
|
160
|
+
project_id = parts.pop()
|
|
161
|
+
prefix = "/" + "/".join(parts) if parts else ""
|
|
162
|
+
port = f":{parsed.port}" if parsed.port is not None else ""
|
|
163
|
+
endpoint = f"{parsed.scheme}://{parsed.hostname}{port}{prefix}/api/{project_id}/envelope/"
|
|
164
|
+
return endpoint, parsed.username
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _send_envelope(target: tuple[str, str], dsn: str, event: Mapping[str, Any]) -> None:
|
|
168
|
+
endpoint, public_key = target
|
|
169
|
+
envelope = "\n".join([
|
|
170
|
+
json.dumps({"event_id": event["event_id"], "dsn": dsn, "sent_at": event["timestamp"]}, separators=(",", ":")),
|
|
171
|
+
json.dumps({"type": "event", "content_type": "application/json"}, separators=(",", ":")),
|
|
172
|
+
json.dumps(event, separators=(",", ":")),
|
|
173
|
+
]).encode("utf-8")
|
|
174
|
+
request = urllib.request.Request(
|
|
175
|
+
endpoint,
|
|
176
|
+
data=envelope,
|
|
177
|
+
headers={
|
|
178
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
179
|
+
"X-Sentry-Auth": (
|
|
180
|
+
"Sentry sentry_version=7, "
|
|
181
|
+
f"sentry_key={public_key}, sentry_client=inline.plugin.telemetry/1"
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
method="POST",
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
# Do not send private errors through user-configured HTTP proxies.
|
|
188
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
189
|
+
with opener.open(request, timeout=_TELEMETRY_TIMEOUT_SECONDS) as response:
|
|
190
|
+
response.read(1)
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def capture_plugin_error(
|
|
196
|
+
operation: str,
|
|
197
|
+
error: BaseException,
|
|
198
|
+
*,
|
|
199
|
+
handled: bool = True,
|
|
200
|
+
secrets: Sequence[str] = (),
|
|
201
|
+
) -> Optional[threading.Thread]:
|
|
202
|
+
env = dict(os.environ)
|
|
203
|
+
dsn = _resolve_dsn(env)
|
|
204
|
+
target = _sentry_target(dsn)
|
|
205
|
+
if target is None:
|
|
206
|
+
return None
|
|
207
|
+
event = build_sentry_event(operation, error, handled=handled, env=env, secrets=secrets)
|
|
208
|
+
value = event["exception"]["values"][0]
|
|
209
|
+
key = f"{event['tags']['operation']}\0{value['type']}\0{value['value']}"
|
|
210
|
+
now = time.monotonic()
|
|
211
|
+
with _report_lock:
|
|
212
|
+
if now - _last_reports.get(key, 0.0) < _TELEMETRY_DEDUP_SECONDS:
|
|
213
|
+
return None
|
|
214
|
+
_last_reports[key] = now
|
|
215
|
+
thread = threading.Thread(
|
|
216
|
+
target=_send_envelope,
|
|
217
|
+
args=(target, dsn, event),
|
|
218
|
+
name="inline-hermes-telemetry",
|
|
219
|
+
daemon=True,
|
|
220
|
+
)
|
|
221
|
+
thread.start()
|
|
222
|
+
return thread
|