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.
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();