@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.
@@ -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