agent-observatory-cli 0.1.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.
- agent_observatory_cli-0.1.0.dist-info/METADATA +115 -0
- agent_observatory_cli-0.1.0.dist-info/RECORD +31 -0
- agent_observatory_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_observatory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_observatory_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_observatory_cli-0.1.0.dist-info/top_level.txt +1 -0
- observatory/__init__.py +5 -0
- observatory/__main__.py +105 -0
- observatory/activity.css +10 -0
- observatory/activity.js +116 -0
- observatory/auto.py +212 -0
- observatory/client.py +253 -0
- observatory/core.py +1654 -0
- observatory/distribution.py +67 -0
- observatory/findings.py +176 -0
- observatory/index.html +84 -0
- observatory/integrations/__init__.py +1 -0
- observatory/integrations/langchain.py +234 -0
- observatory/integrations/openai_agents.py +177 -0
- observatory/local_activity.py +207 -0
- observatory/local_agents.py +394 -0
- observatory/local_runtime.py +49 -0
- observatory/logs.py +109 -0
- observatory/metrics.py +281 -0
- observatory/otlp_proto.py +210 -0
- observatory/server.py +368 -0
- observatory/setup.css +40 -0
- observatory/setup.js +145 -0
- observatory/terminal.py +525 -0
- observatory/terminal_ui.py +1299 -0
- observatory/watch.py +62 -0
observatory/server.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Small local HTTP receiver and dashboard, using only the Python standard library."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hmac
|
|
5
|
+
import gzip
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
from .core import Store, normalize_native, normalize_otlp, TRACE_ID
|
|
15
|
+
from .otlp_proto import decode_trace_request
|
|
16
|
+
from .logs import normalize_otlp_logs, decode_log_request
|
|
17
|
+
from .metrics import normalize_otlp_metrics, decode_metric_request
|
|
18
|
+
from .watch import start_watchers
|
|
19
|
+
from .distribution import installer_info, package_archive
|
|
20
|
+
|
|
21
|
+
UI = Path(__file__).with_name("index.html")
|
|
22
|
+
MAX_BODY = 8 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def handler_for(store: Store, token: str | None = None, quiet=False):
|
|
26
|
+
class Handler(BaseHTTPRequestHandler):
|
|
27
|
+
def _json(self, code, value):
|
|
28
|
+
data = json.dumps(value, ensure_ascii=False).encode()
|
|
29
|
+
self.send_response(code)
|
|
30
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
31
|
+
self.send_header("Content-Length", str(len(data)))
|
|
32
|
+
self.send_header("Cache-Control", "no-store")
|
|
33
|
+
self.end_headers()
|
|
34
|
+
self.wfile.write(data)
|
|
35
|
+
|
|
36
|
+
def _protobuf_ok(self):
|
|
37
|
+
# Empty OTLP export response means the full batch was accepted.
|
|
38
|
+
self.send_response(200)
|
|
39
|
+
self.send_header("Content-Type", "application/x-protobuf")
|
|
40
|
+
self.send_header("Content-Length", "0")
|
|
41
|
+
self.end_headers()
|
|
42
|
+
|
|
43
|
+
def _authorized(self):
|
|
44
|
+
if not token:
|
|
45
|
+
return True
|
|
46
|
+
header = self.headers.get("Authorization", "")
|
|
47
|
+
return hmac.compare_digest(header, f"Bearer {token}")
|
|
48
|
+
|
|
49
|
+
def _route(self):
|
|
50
|
+
route = urlparse(self.path)
|
|
51
|
+
# The static shell contains no telemetry; its API requests still need a token.
|
|
52
|
+
if self.command == "GET" and route.path in {"/", "/setup.js", "/setup.css", "/activity.js", "/activity.css"}:
|
|
53
|
+
return route
|
|
54
|
+
if not self._authorized():
|
|
55
|
+
self._json(401, {"error": "unauthorized"})
|
|
56
|
+
return None
|
|
57
|
+
return route
|
|
58
|
+
|
|
59
|
+
def do_GET(self):
|
|
60
|
+
route = self._route()
|
|
61
|
+
if route is None:
|
|
62
|
+
return
|
|
63
|
+
path = route.path
|
|
64
|
+
if path == "/":
|
|
65
|
+
data = UI.read_bytes()
|
|
66
|
+
self.send_response(200)
|
|
67
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
68
|
+
self.send_header("Content-Length", str(len(data)))
|
|
69
|
+
self.send_header("Cache-Control", "no-store")
|
|
70
|
+
self.end_headers()
|
|
71
|
+
self.wfile.write(data)
|
|
72
|
+
elif path in {"/setup.js", "/setup.css", "/activity.js", "/activity.css"}:
|
|
73
|
+
data = UI.with_name(path[1:]).read_bytes()
|
|
74
|
+
self.send_response(200)
|
|
75
|
+
self.send_header("Content-Type", "text/javascript; charset=utf-8" if path.endswith(".js") else "text/css; charset=utf-8")
|
|
76
|
+
self.send_header("Content-Length", str(len(data)))
|
|
77
|
+
self.send_header("Cache-Control", "no-store")
|
|
78
|
+
self.end_headers()
|
|
79
|
+
self.wfile.write(data)
|
|
80
|
+
elif path == "/api/health":
|
|
81
|
+
local = getattr(store, "local_agent_monitor", None)
|
|
82
|
+
self._json(200, {"ok": True, "local_agents": local.status() if local else {"enabled": False}})
|
|
83
|
+
elif path == "/api/setup":
|
|
84
|
+
self._json(200, {**installer_info(),
|
|
85
|
+
"auth_required": bool(token),
|
|
86
|
+
"integrations": ["OpenAI Python SDK", "Anthropic Python SDK", "LangChain / LangGraph"]})
|
|
87
|
+
elif path == "/api/package":
|
|
88
|
+
data, filename = package_archive()
|
|
89
|
+
self.send_response(200)
|
|
90
|
+
self.send_header("Content-Type", "application/gzip")
|
|
91
|
+
self.send_header("Content-Length", str(len(data)))
|
|
92
|
+
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
|
93
|
+
self.send_header("Cache-Control", "no-store")
|
|
94
|
+
self.end_headers()
|
|
95
|
+
self.wfile.write(data)
|
|
96
|
+
elif path == "/api/connections":
|
|
97
|
+
self._json(200, {"connections": store.connections()})
|
|
98
|
+
elif path == "/api/activity":
|
|
99
|
+
query = parse_qs(route.query)
|
|
100
|
+
try:
|
|
101
|
+
page = store.activity_page(limit=int(query.get("limit", [100])[0]),
|
|
102
|
+
service=query.get("service", [None])[0], domain=query.get("domain", [None])[0],
|
|
103
|
+
query=query.get("q", [None])[0], session_id=query.get("session_id", [None])[0],
|
|
104
|
+
cursor=query.get("cursor", [None])[0],
|
|
105
|
+
include_reviews=query.get("include_reviews", ["1"])[0]!="0",
|
|
106
|
+
errors_only=query.get("errors_only", ["0"])[0]=="1")
|
|
107
|
+
self._json(200, page)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
self._json(400, {"error": str(exc)})
|
|
110
|
+
elif path == "/api/metrics":
|
|
111
|
+
self._json(200, store.metrics())
|
|
112
|
+
elif path == "/api/mcp":
|
|
113
|
+
query = parse_qs(route.query)
|
|
114
|
+
try:
|
|
115
|
+
page = store.mcp_page(limit=int(query.get('limit', [100])[0]),
|
|
116
|
+
service=query.get('service', [None])[0], session_id=query.get('session_id', [None])[0],
|
|
117
|
+
query=query.get('q', [None])[0], cursor=query.get('cursor', [None])[0],
|
|
118
|
+
include_reviews=query.get('include_reviews', ['1'])[0]!='0')
|
|
119
|
+
self._json(200, {**page, 'scope': 'stored MCP operations'})
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
self._json(400, {'error': str(exc)})
|
|
122
|
+
elif path == "/api/usage-series":
|
|
123
|
+
query = parse_qs(route.query)
|
|
124
|
+
try:
|
|
125
|
+
days = int(query.get("days", [7])[0])
|
|
126
|
+
step = query.get("step", ["day"])[0]
|
|
127
|
+
service = query.get("service", [None])[0]
|
|
128
|
+
self._json(200, store.usage_series(days=days, step=step, service=service))
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
self._json(400, {"error": str(exc)})
|
|
131
|
+
elif path == "/api/logs":
|
|
132
|
+
self._json(200, {"logs": store.logs(limit=500)})
|
|
133
|
+
elif path == "/api/measurements":
|
|
134
|
+
query = parse_qs(route.query, keep_blank_values=True)
|
|
135
|
+
try:
|
|
136
|
+
limit = max(1, min(500, int(query.get("limit", [500])[0])))
|
|
137
|
+
temporality = query.get("temporality", [None])[0]
|
|
138
|
+
points = store.metric_points(limit=limit,
|
|
139
|
+
service=query.get("service", [None])[0], name=query.get("name", [None])[0],
|
|
140
|
+
kind=query.get("kind", [None])[0],
|
|
141
|
+
temporality=int(temporality) if temporality is not None else None,
|
|
142
|
+
scope_name=query.get("scope_name", [None])[0],
|
|
143
|
+
unit=query.get("unit", [None])[0],
|
|
144
|
+
description=query.get("description", [None])[0])
|
|
145
|
+
except ValueError as exc:
|
|
146
|
+
self._json(400, {"error": str(exc)})
|
|
147
|
+
return
|
|
148
|
+
self._json(200, {"points": points})
|
|
149
|
+
elif path == "/api/metric-catalog":
|
|
150
|
+
query = parse_qs(route.query)
|
|
151
|
+
try:
|
|
152
|
+
limit = max(1, min(500, int(query.get("limit", [200])[0])))
|
|
153
|
+
self._json(200, {"metrics": store.metric_catalog(limit=limit)})
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
self._json(400, {"error": str(exc)})
|
|
156
|
+
elif path == "/api/findings":
|
|
157
|
+
query = parse_qs(route.query)
|
|
158
|
+
try:
|
|
159
|
+
limit = max(1, min(500, int(query.get("limit", [100])[0])))
|
|
160
|
+
page = store.finding_page(limit=limit, cursor=query.get("cursor", [None])[0])
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
self._json(400, {"error": str(exc)})
|
|
163
|
+
return
|
|
164
|
+
self._json(200, page)
|
|
165
|
+
elif path == "/api/sessions":
|
|
166
|
+
query = parse_qs(route.query)
|
|
167
|
+
try:
|
|
168
|
+
limit = max(1, min(500, int(query.get("limit", [100])[0])))
|
|
169
|
+
self._json(200, {"sessions": store.sessions(limit=limit)})
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
self._json(400, {"error": str(exc)})
|
|
172
|
+
elif path == "/api/endpoints":
|
|
173
|
+
query = parse_qs(route.query)
|
|
174
|
+
try:
|
|
175
|
+
limit = max(1, min(500, int(query.get("limit", [100])[0])))
|
|
176
|
+
self._json(200, {"endpoints": store.endpoints(limit=limit)})
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
self._json(400, {"error": str(exc)})
|
|
179
|
+
elif path == "/api/outcomes":
|
|
180
|
+
self._json(200, {"outcomes": list(store.outcomes().values())})
|
|
181
|
+
elif path == "/api/traces":
|
|
182
|
+
query = parse_qs(route.query)
|
|
183
|
+
try:
|
|
184
|
+
limit = max(1, min(500, int(query.get("limit", [100])[0])))
|
|
185
|
+
except ValueError:
|
|
186
|
+
self._json(400, {"error": "invalid limit"})
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
page = store.trace_page(limit, query.get("service", [None])[0],
|
|
190
|
+
query.get("status", [None])[0],
|
|
191
|
+
query.get("cursor", [None])[0],
|
|
192
|
+
query.get("session_id", [None])[0],
|
|
193
|
+
query.get("q", [None])[0],
|
|
194
|
+
query.get("endpoint_id", [None])[0],
|
|
195
|
+
query.get("include_reviews", ["1"])[0]!="0")
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
self._json(400, {"error": str(exc)})
|
|
198
|
+
return
|
|
199
|
+
self._json(200, page)
|
|
200
|
+
elif path.startswith("/api/traces/") and path.endswith("/related"):
|
|
201
|
+
trace_id = path.removeprefix("/api/traces/").removesuffix("/related")
|
|
202
|
+
if not TRACE_ID.fullmatch(trace_id):
|
|
203
|
+
self._json(400, {"error": "invalid trace_id"})
|
|
204
|
+
return
|
|
205
|
+
self._json(200, {"related": store.related_traces(trace_id)})
|
|
206
|
+
elif path.startswith("/api/traces/") and path.endswith("/spans"):
|
|
207
|
+
trace_id = path.removeprefix("/api/traces/").removesuffix("/spans")
|
|
208
|
+
if not TRACE_ID.fullmatch(trace_id):
|
|
209
|
+
self._json(400, {"error": "invalid trace_id"})
|
|
210
|
+
return
|
|
211
|
+
query = parse_qs(route.query, keep_blank_values=True)
|
|
212
|
+
try:
|
|
213
|
+
limit = max(1, min(500, int(query.get("limit", [200])[0])))
|
|
214
|
+
page = store.span_page(trace_id, limit=limit,
|
|
215
|
+
cursor=query.get("cursor", [None])[0],
|
|
216
|
+
query=query.get("q", [""])[0].strip()[:200],
|
|
217
|
+
span_filter=query.get("filter", ["all"])[0])
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
self._json(400, {"error": str(exc)})
|
|
220
|
+
return
|
|
221
|
+
self._json(200, page)
|
|
222
|
+
elif path.startswith("/api/traces/"):
|
|
223
|
+
trace_id = path.removeprefix("/api/traces/")
|
|
224
|
+
if not TRACE_ID.fullmatch(trace_id):
|
|
225
|
+
self._json(400, {"error": "invalid trace_id"})
|
|
226
|
+
return
|
|
227
|
+
query = parse_qs(route.query, keep_blank_values=True)
|
|
228
|
+
if "span_limit" in query:
|
|
229
|
+
try:
|
|
230
|
+
limit = max(1, min(500, int(query.get("span_limit", [200])[0])))
|
|
231
|
+
result = store.trace_overview(trace_id)
|
|
232
|
+
span_page = store.span_page(trace_id, limit=limit,
|
|
233
|
+
query=query.get("q", [""])[0].strip()[:200],
|
|
234
|
+
span_filter=query.get("filter", ["all"])[0])
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
self._json(400, {"error": str(exc)})
|
|
237
|
+
return
|
|
238
|
+
result["spans"] = span_page["spans"]
|
|
239
|
+
result["span_page"] = {key: span_page[key] for key in
|
|
240
|
+
("total", "trace_total", "next_cursor", "start_unix_nano", "end_unix_nano")}
|
|
241
|
+
else:
|
|
242
|
+
result = store.trace(trace_id)
|
|
243
|
+
self._json(200 if result["summary"] or result["progress"] or result["logs"] or result["outcome"] else 404, result)
|
|
244
|
+
else:
|
|
245
|
+
self._json(404, {"error": "not found"})
|
|
246
|
+
|
|
247
|
+
def do_POST(self):
|
|
248
|
+
route = self._route()
|
|
249
|
+
if route is None:
|
|
250
|
+
return
|
|
251
|
+
try:
|
|
252
|
+
size = int(self.headers.get("Content-Length", "0"))
|
|
253
|
+
if size <= 0 or size > MAX_BODY:
|
|
254
|
+
self._json(413, {"error": "body size must be 1 to 8 MiB"})
|
|
255
|
+
return
|
|
256
|
+
raw = self.rfile.read(size)
|
|
257
|
+
if len(raw) != size:
|
|
258
|
+
raise ValueError("truncated request body")
|
|
259
|
+
encoding = self.headers.get("Content-Encoding", "identity").lower()
|
|
260
|
+
if encoding == "gzip":
|
|
261
|
+
with gzip.GzipFile(fileobj=io.BytesIO(raw)) as stream:
|
|
262
|
+
raw = stream.read(MAX_BODY + 1)
|
|
263
|
+
if len(raw) > MAX_BODY:
|
|
264
|
+
self._json(413, {"error": "decompressed body exceeds 8 MiB"})
|
|
265
|
+
return
|
|
266
|
+
elif encoding != "identity":
|
|
267
|
+
self._json(415, {"error": "unsupported content encoding"})
|
|
268
|
+
return
|
|
269
|
+
content_type = self.headers.get("Content-Type", "").split(";")[0].strip()
|
|
270
|
+
if route.path in ("/v1/traces", "/v1/logs", "/v1/metrics"):
|
|
271
|
+
if content_type == "application/x-protobuf":
|
|
272
|
+
if route.path == "/v1/traces":
|
|
273
|
+
store.ingest(decode_trace_request(raw))
|
|
274
|
+
elif route.path == "/v1/logs":
|
|
275
|
+
store.ingest_logs(decode_log_request(raw))
|
|
276
|
+
else:
|
|
277
|
+
store.ingest_metrics(decode_metric_request(raw))
|
|
278
|
+
self._protobuf_ok()
|
|
279
|
+
return
|
|
280
|
+
if content_type != "application/json":
|
|
281
|
+
self._json(415, {"error": "OTLP requires application/json or application/x-protobuf"})
|
|
282
|
+
return
|
|
283
|
+
body = json.loads(raw)
|
|
284
|
+
if route.path == "/api/spans":
|
|
285
|
+
raw_spans = body if isinstance(body, list) else body.get("spans", [])
|
|
286
|
+
if not isinstance(raw_spans, list):
|
|
287
|
+
raise ValueError("spans must be an array")
|
|
288
|
+
service = body.get("service", "agent-harness") if isinstance(body, dict) else "agent-harness"
|
|
289
|
+
count = store.ingest([normalize_native(s, service) for s in raw_spans])
|
|
290
|
+
self._json(200, {"accepted": count})
|
|
291
|
+
elif route.path == "/v1/traces":
|
|
292
|
+
count = store.ingest(normalize_otlp(body))
|
|
293
|
+
self._json(200, {"partialSuccess": {"rejectedSpans": 0}, "accepted": count})
|
|
294
|
+
elif route.path == "/v1/logs":
|
|
295
|
+
count = store.ingest_logs(normalize_otlp_logs(body))
|
|
296
|
+
self._json(200, {"partialSuccess": {"rejectedLogRecords": 0}, "accepted": count})
|
|
297
|
+
elif route.path == "/v1/metrics":
|
|
298
|
+
count = store.ingest_metrics(normalize_otlp_metrics(body))
|
|
299
|
+
self._json(200, {"partialSuccess": {"rejectedDataPoints": 0}, "accepted": count})
|
|
300
|
+
elif route.path == "/api/progress":
|
|
301
|
+
store.add_progress(body["trace_id"], body["stage"], body.get("message", ""),
|
|
302
|
+
body.get("percent"), body.get("session_id"), body.get("service"))
|
|
303
|
+
self._json(200, {"accepted": 1})
|
|
304
|
+
elif route.path == "/api/connections":
|
|
305
|
+
store.register_connection(body)
|
|
306
|
+
self._json(200, {"accepted": 1})
|
|
307
|
+
elif route.path == "/api/outcomes":
|
|
308
|
+
records = body if isinstance(body, list) else body.get("results", [body])
|
|
309
|
+
count = store.ingest_outcomes(records, source="api")
|
|
310
|
+
self._json(200, {"accepted": count})
|
|
311
|
+
else:
|
|
312
|
+
self._json(404, {"error": "not found"})
|
|
313
|
+
except (ValueError, TypeError, KeyError, AttributeError, OSError, EOFError, json.JSONDecodeError) as exc:
|
|
314
|
+
self._json(400, {"error": str(exc)})
|
|
315
|
+
|
|
316
|
+
def log_message(self, format, *args):
|
|
317
|
+
if not quiet and os.environ.get("OBSERVATORY_QUIET") != "1":
|
|
318
|
+
super().log_message(format, *args)
|
|
319
|
+
|
|
320
|
+
return Handler
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def serve(db_path="observatory.db", host="127.0.0.1", port=4318, token=None,
|
|
324
|
+
watch_jsonl=(), watch_service="agent-harness", retention_days=None, local_agents=True, metadata_only=False):
|
|
325
|
+
store = Store(db_path, async_derived_refresh=True)
|
|
326
|
+
if retention_days is not None:
|
|
327
|
+
store.prune(retention_days)
|
|
328
|
+
server = ThreadingHTTPServer((host, port), handler_for(store, token))
|
|
329
|
+
local = None
|
|
330
|
+
if local_agents and host in {"127.0.0.1", "localhost", "::1"}:
|
|
331
|
+
from .local_agents import LocalAgents
|
|
332
|
+
local = LocalAgents(store, capture_details=not metadata_only).start()
|
|
333
|
+
store.local_agent_monitor = local
|
|
334
|
+
watch = start_watchers(store, watch_jsonl, watch_service) if watch_jsonl else None
|
|
335
|
+
maintenance = _start_retention(store, retention_days) if retention_days is not None else None
|
|
336
|
+
print(f"Observatory listening at http://{host}:{server.server_port} (database: {db_path})", flush=True)
|
|
337
|
+
if local:
|
|
338
|
+
print("Watching local Codex / Claude Code sessions · " + ("metadata only" if metadata_only else "activity and bounded, redacted content"), flush=True)
|
|
339
|
+
try:
|
|
340
|
+
server.serve_forever()
|
|
341
|
+
except KeyboardInterrupt:
|
|
342
|
+
pass
|
|
343
|
+
finally:
|
|
344
|
+
server.server_close()
|
|
345
|
+
if local:
|
|
346
|
+
local.close()
|
|
347
|
+
if watch:
|
|
348
|
+
watch[0].set()
|
|
349
|
+
watch[1].join(timeout=2)
|
|
350
|
+
if maintenance:
|
|
351
|
+
maintenance[0].set()
|
|
352
|
+
maintenance[1].join(timeout=2)
|
|
353
|
+
store.close()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _start_retention(store, retention_days, interval_seconds=3600):
|
|
357
|
+
stop = threading.Event()
|
|
358
|
+
|
|
359
|
+
def maintain():
|
|
360
|
+
while not stop.is_set():
|
|
361
|
+
stop.wait(interval_seconds)
|
|
362
|
+
if stop.is_set():
|
|
363
|
+
break
|
|
364
|
+
store.prune(retention_days)
|
|
365
|
+
|
|
366
|
+
thread = threading.Thread(target=maintain, name="observatory-retention", daemon=True)
|
|
367
|
+
thread.start()
|
|
368
|
+
return stop, thread
|
observatory/setup.css
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[hidden]{display:none!important}
|
|
2
|
+
.header-actions{display:flex;align-items:center;gap:18px}
|
|
3
|
+
.connect-button{border:1px solid #527fc2;background:#24466f;color:#eff5ff;border-radius:8px;padding:8px 14px;font-weight:600;white-space:nowrap}
|
|
4
|
+
.connect-button:hover{background:#315b8b}
|
|
5
|
+
.setup-panel{border:1px solid #34547b;border-radius:14px;background:linear-gradient(135deg,#182a44,#111b2c);padding:26px;margin-bottom:20px}
|
|
6
|
+
.setup-heading{display:flex;justify-content:space-between;gap:20px;align-items:flex-start}
|
|
7
|
+
.setup-heading h1{font-size:27px;letter-spacing:-.025em;margin:6px 0 10px}
|
|
8
|
+
.setup-heading p{color:#afc0d7;max-width:720px;line-height:1.6;margin:0}
|
|
9
|
+
.setup-close{border:0;background:transparent;color:#aebdd1;font-size:24px;padding:0 4px}
|
|
10
|
+
.setup-fields{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:24px}
|
|
11
|
+
.setup-field{display:flex;flex-direction:column;gap:7px;color:#ced9e9;font-size:12px}
|
|
12
|
+
.setup-field input,.setup-field select{width:100%;padding:10px 12px;background:#0c1727;color:#e7edf7;border:1px solid #3b4e69;border-radius:7px}
|
|
13
|
+
.setup-field small{color:#98abc4;line-height:1.4}
|
|
14
|
+
.setup-capture{display:flex;align-items:center;gap:9px;margin:18px 0 6px;color:#d8e3f2;font-size:13px}
|
|
15
|
+
.setup-capture input{accent-color:#77aaff}
|
|
16
|
+
.setup-capture-note{font-size:12px;color:#98abc4;margin-left:23px;line-height:1.5}
|
|
17
|
+
.setup-steps{display:grid;grid-template-columns:1fr 1.3fr;gap:14px;margin-top:24px}
|
|
18
|
+
.setup-step{background:#101c2e;border:1px solid #30435d;border-radius:10px;padding:16px;min-width:0}
|
|
19
|
+
.setup-step h3{margin:0 0 8px;font-size:14px;display:flex;align-items:center;gap:9px}
|
|
20
|
+
.setup-step h3 span{display:inline-flex;align-items:center;justify-content:center;border:1px solid #4c6c94;border-radius:50%;height:24px;width:24px;font-size:12px;color:#a9ceff}
|
|
21
|
+
.setup-step p{font-size:12px;color:#a7b8d0;line-height:1.6;margin:0 0 12px}
|
|
22
|
+
.setup-code{position:relative;border:1px solid #2d405a;border-radius:7px;background:#091321;padding:12px;overflow:auto}
|
|
23
|
+
.setup-code pre{margin:0;padding-right:54px;white-space:pre-wrap;overflow-wrap:anywhere;color:#c4ddff;font:12px/1.7 ui-monospace,SFMono-Regular,monospace}
|
|
24
|
+
.setup-copy{position:absolute;right:7px;top:8px;border:1px solid #3d5676;border-radius:5px;background:#1b2e48;color:#c8dbf5;font-size:11px;padding:4px 7px}
|
|
25
|
+
.setup-hint{font-size:12px;color:#a7b8d0;line-height:1.6;margin:18px 0 0}
|
|
26
|
+
.setup-support{display:flex;gap:7px;flex-wrap:wrap;margin-top:14px}
|
|
27
|
+
.setup-support span{border:1px solid #355374;border-radius:20px;padding:5px 9px;font-size:11px;color:#bbd3f0}
|
|
28
|
+
.source-strip{margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
29
|
+
.receiver-state{font-size:12px;color:#a8d9c0;display:inline-flex;align-items:center;gap:7px}
|
|
30
|
+
.receiver-state::before{content:'';width:7px;height:7px;border-radius:50%;background:#78d4a4}
|
|
31
|
+
.connected-source{border:1px solid #354b68;background:#15243a;border-radius:8px;padding:9px 12px;display:flex;flex-direction:column;gap:5px;font-size:12px}
|
|
32
|
+
.connected-source small{color:#a5b8d3;font-size:11px}
|
|
33
|
+
.connected-source.receiving{border-color:#37745c}
|
|
34
|
+
.connected-source.offline{opacity:.65}
|
|
35
|
+
.waiting-card{border:1px dashed #34506f;background:#101b2b;border-radius:12px;text-align:center;padding:44px 24px}
|
|
36
|
+
.waiting-card h2{font-size:19px;margin:0 0 9px;color:#e0ebfa}
|
|
37
|
+
.waiting-card p{line-height:1.6;max-width:580px;margin:0 auto;color:#a4b6cf}
|
|
38
|
+
.setup-feedback{font-size:12px;color:#acdcbe;min-height:17px;margin-top:9px}
|
|
39
|
+
@media(max-width:900px){.setup-steps{grid-template-columns:1fr}.setup-panel{padding:20px}.header-actions{gap:8px}header small{display:none}}
|
|
40
|
+
@media(max-width:600px){.setup-fields{grid-template-columns:1fr}.setup-heading h1{font-size:23px}.connect-button{font-size:12px;padding:7px 9px}.setup-panel{padding:16px}}
|
observatory/setup.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
let setupConfig = null;
|
|
2
|
+
let setupOpened = false;
|
|
3
|
+
let setupDismissed = false;
|
|
4
|
+
window.observatoryHasData = false;
|
|
5
|
+
const setupElement = id => document.getElementById(id);
|
|
6
|
+
const shellQuote = value => "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
7
|
+
|
|
8
|
+
function openAgentSetup() {
|
|
9
|
+
setupOpened = true;
|
|
10
|
+
setupDismissed = false;
|
|
11
|
+
setupElement('agent-setup').hidden = false;
|
|
12
|
+
setupElement('agent-setup').scrollIntoView({behavior:'smooth',block:'start'});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function updateSetupCommands() {
|
|
16
|
+
const python = setupElement('setup-runtime').value === 'python';
|
|
17
|
+
const endpoint = setupElement('setup-endpoint').value.trim() || location.origin;
|
|
18
|
+
const service = setupElement('setup-service').value.trim() || 'my-agent';
|
|
19
|
+
const target = setupElement('setup-target').value.trim() || 'agent.py';
|
|
20
|
+
setupElement('setup-install-step').hidden = !python;
|
|
21
|
+
setupElement('setup-launch-number').textContent = python ? '2' : '1';
|
|
22
|
+
setupElement('setup-target-field').hidden = !python;
|
|
23
|
+
setupElement('setup-capture-options').hidden = !python;
|
|
24
|
+
setupElement('setup-launch-title').textContent = python ? 'Start your agent with automatic capture' : 'Configure your exporter';
|
|
25
|
+
setupElement('setup-launch-help').textContent = python
|
|
26
|
+
? 'Use your agent’s Python environment. Replace its usual “python agent.py” command with this command; its arguments go at the end.'
|
|
27
|
+
: 'Use these settings in an app that already exports OpenTelemetry. Choose OTLP over HTTP, then restart the app.';
|
|
28
|
+
let command;
|
|
29
|
+
if (python) {
|
|
30
|
+
command = 'python -m observatory run --endpoint ' + shellQuote(endpoint) + ' --service ' + shellQuote(service);
|
|
31
|
+
if (setupElement('setup-capture').checked) command += ' --capture-content';
|
|
32
|
+
if (setupElement('setup-module').checked) command += ' --module';
|
|
33
|
+
command += ' ' + shellQuote(target);
|
|
34
|
+
} else {
|
|
35
|
+
command = 'export OTEL_EXPORTER_OTLP_ENDPOINT=' + shellQuote(endpoint) + '\n'
|
|
36
|
+
+ 'export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf\n'
|
|
37
|
+
+ 'export OTEL_SERVICE_NAME=' + shellQuote(service);
|
|
38
|
+
}
|
|
39
|
+
setupElement('setup-launch-code').textContent = command;
|
|
40
|
+
setupElement('setup-install-code').textContent = setupConfig?.install_command
|
|
41
|
+
|| 'Download the installer to get your install command.';
|
|
42
|
+
setupElement('setup-runtime-note').textContent = python
|
|
43
|
+
? 'Captures OpenAI and Anthropic SDK calls, plus LangChain / LangGraph activity. OpenAI-compatible local servers work when called through the OpenAI Python SDK. Restart existing agents through this launcher.'
|
|
44
|
+
: 'Your app needs an OpenTelemetry SDK and exporter. These settings send its existing traces, logs, and metrics to this receiver.';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function downloadSetupPackage() {
|
|
48
|
+
const button = setupElement('download-agent-package');
|
|
49
|
+
button.disabled = true;
|
|
50
|
+
try {
|
|
51
|
+
if (!setupConfig) {
|
|
52
|
+
setupConfig = await get('/api/setup');
|
|
53
|
+
updateSetupCommands();
|
|
54
|
+
}
|
|
55
|
+
const response = await authorizedFetch('/api/package');
|
|
56
|
+
if (!response.ok) throw new Error('HTTP '+response.status);
|
|
57
|
+
const blob = await response.blob();
|
|
58
|
+
const url = URL.createObjectURL(blob);
|
|
59
|
+
const link = document.createElement('a');
|
|
60
|
+
link.href = url;
|
|
61
|
+
link.download = setupConfig?.package_filename || 'agent-observatory.tar.gz';
|
|
62
|
+
document.body.append(link);
|
|
63
|
+
link.click();
|
|
64
|
+
link.remove();
|
|
65
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
66
|
+
setupElement('setup-feedback').textContent = 'Downloaded. Run the install command from your download folder, in your agent’s Python environment.';
|
|
67
|
+
} catch (error) {
|
|
68
|
+
setupElement('setup-feedback').textContent = 'Download failed: '+error.message+'. Refresh the page and retry.';
|
|
69
|
+
} finally {
|
|
70
|
+
button.disabled = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function copySetupCommand(id, button) {
|
|
75
|
+
const text = setupElement(id).textContent;
|
|
76
|
+
try {
|
|
77
|
+
await navigator.clipboard.writeText(text);
|
|
78
|
+
button.textContent = 'Copied';
|
|
79
|
+
setupElement('setup-feedback').textContent = 'Command copied. Paste it into your agent’s terminal.';
|
|
80
|
+
setTimeout(() => { button.textContent = 'Copy'; }, 1600);
|
|
81
|
+
} catch {
|
|
82
|
+
const selection = window.getSelection();
|
|
83
|
+
const range = document.createRange();
|
|
84
|
+
range.selectNodeContents(setupElement(id));
|
|
85
|
+
selection.removeAllRanges();
|
|
86
|
+
selection.addRange(range);
|
|
87
|
+
setupElement('setup-feedback').textContent = 'Select and copy the highlighted command.';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function refreshAgentConnections(stats) {
|
|
92
|
+
if (!setupConfig) {
|
|
93
|
+
setupConfig = await get('/api/setup');
|
|
94
|
+
setupElement('setup-auth-hint').hidden = !setupConfig.auth_required;
|
|
95
|
+
updateSetupCommands();
|
|
96
|
+
}
|
|
97
|
+
const data = await get('/api/connections');
|
|
98
|
+
const sources = data.connections;
|
|
99
|
+
window.observatoryHasData = Boolean(stats.spans || stats.logs || stats.metric_points || stats.runs);
|
|
100
|
+
setupElement('agent-setup').hidden = setupDismissed || (window.observatoryHasData && !setupOpened);
|
|
101
|
+
setupElement('investigation').hidden = !window.observatoryHasData;
|
|
102
|
+
setupElement('waiting-first-call').hidden = window.observatoryHasData;
|
|
103
|
+
const active = sources.filter(source => source.state === 'running');
|
|
104
|
+
setupElement('receiver-state').textContent = active.length
|
|
105
|
+
? 'Receiver listening · '+active.length+' agent'+(active.length === 1 ? '' : 's')+' connected'
|
|
106
|
+
: 'Receiver listening · '+location.host;
|
|
107
|
+
setupElement('waiting-title').textContent = active.length ? 'Agent connected. Waiting for a model call.' : 'Waiting for your first agent';
|
|
108
|
+
setupElement('waiting-help').textContent = active.length
|
|
109
|
+
? 'Use your agent normally. Finished model calls appear here with their model, duration, token usage, and recorded errors.'
|
|
110
|
+
: 'Install automatic capture in your agent’s environment, start it with the command above, and make a model call. This page updates automatically.';
|
|
111
|
+
const host = setupElement('connected-agents');
|
|
112
|
+
host.replaceChildren();
|
|
113
|
+
for (const source of sources.slice(0,8)) {
|
|
114
|
+
const card = document.createElement('div');
|
|
115
|
+
card.className = 'connected-source' + (source.span_count ? ' receiving' : '') + (source.state === 'offline' ? ' offline' : '');
|
|
116
|
+
const title = document.createElement('strong');
|
|
117
|
+
title.textContent = source.service;
|
|
118
|
+
const status = document.createElement('small');
|
|
119
|
+
const local = source.integrations.includes('local-session-log');
|
|
120
|
+
const state = local ? (source.state === 'running' ? 'Recent session activity' : 'Idle / last observed') : source.state === 'running' ? (source.span_count ? 'Receiving telemetry' : 'Connected · monitoring enabled')
|
|
121
|
+
: source.state === 'stopped' ? 'Finished' : 'Disconnected';
|
|
122
|
+
status.textContent = state+' · '+source.span_count+' spans received';
|
|
123
|
+
const details = document.createElement('small');
|
|
124
|
+
details.textContent = source.integrations.join(', ') + (source.capture_content ? ' · content included' : ' · metadata only');
|
|
125
|
+
card.append(title,status,details);
|
|
126
|
+
host.append(card);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
setupElement('connect-agent').onclick = openAgentSetup;
|
|
131
|
+
setupElement('download-agent-package').onclick = downloadSetupPackage;
|
|
132
|
+
setupElement('close-agent-setup').onclick = () => {
|
|
133
|
+
setupDismissed = true;
|
|
134
|
+
setupOpened = false;
|
|
135
|
+
setupElement('agent-setup').hidden = true;
|
|
136
|
+
};
|
|
137
|
+
setupElement('setup-endpoint').value = location.origin;
|
|
138
|
+
for (const id of ['setup-runtime','setup-endpoint','setup-service','setup-target','setup-capture','setup-module']) {
|
|
139
|
+
setupElement(id).addEventListener('input', updateSetupCommands);
|
|
140
|
+
setupElement(id).addEventListener('change', updateSetupCommands);
|
|
141
|
+
}
|
|
142
|
+
for (const button of document.querySelectorAll('[data-copy-command]')) {
|
|
143
|
+
button.onclick = () => copySetupCommand(button.dataset.copyCommand, button);
|
|
144
|
+
}
|
|
145
|
+
updateSetupCommands();
|