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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Observe local agent session metadata without changing or restarting agents.
|
|
2
|
+
|
|
3
|
+
Session files are read-only. Text, reasoning, arguments, and tool results never
|
|
4
|
+
enter telemetry. These adapters are best effort because agent log formats vary.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .core import normalize_native
|
|
19
|
+
|
|
20
|
+
MAX_READ = 8 * 1024 * 1024
|
|
21
|
+
MAX_LINE = 2 * 1024 * 1024
|
|
22
|
+
MAX_SOURCES = 50
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def identity(*parts, size=32):
|
|
26
|
+
return hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()[:size]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def timestamp(value):
|
|
30
|
+
if isinstance(value, (int, float)):
|
|
31
|
+
magnitude = abs(value)
|
|
32
|
+
factor = 1 if magnitude >= 1e17 else 1000 if magnitude >= 1e14 else 1_000_000 if magnitude >= 1e11 else 1_000_000_000
|
|
33
|
+
return max(0, int(value * factor))
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
try:
|
|
36
|
+
return max(0, int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1e9))
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def count(value):
|
|
43
|
+
try:
|
|
44
|
+
return max(0, int(value or 0))
|
|
45
|
+
except (ValueError, TypeError, OverflowError):
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Session:
|
|
50
|
+
def __init__(self, store, path, runtime, capture_details=True):
|
|
51
|
+
self.store, self.path, self.runtime = store, Path(path), runtime
|
|
52
|
+
self.service = runtime.lower().replace(" ", "-")
|
|
53
|
+
self.source_id = identity(runtime, self.path.resolve())
|
|
54
|
+
self.capture_details = capture_details
|
|
55
|
+
self.internal = False
|
|
56
|
+
self.session_id = self.path.stem
|
|
57
|
+
self.offset = 0
|
|
58
|
+
self.file_id = None
|
|
59
|
+
self.pending = b""
|
|
60
|
+
self.turn = "session"
|
|
61
|
+
self.model = None
|
|
62
|
+
self.project = ""
|
|
63
|
+
self.started = {}
|
|
64
|
+
self.calls = {}
|
|
65
|
+
self.usage = {}
|
|
66
|
+
self.tasks = {}
|
|
67
|
+
self.tool_inputs = {}
|
|
68
|
+
self.modern_usage = False
|
|
69
|
+
self.last_total = None
|
|
70
|
+
self.last_heartbeat = 0
|
|
71
|
+
self.last_state = None
|
|
72
|
+
self.skipped = 0
|
|
73
|
+
self.last_activity = None
|
|
74
|
+
|
|
75
|
+
def span(self, key, name, stamp, attrs=None, *, start=None, parent=True, error=False):
|
|
76
|
+
if not stamp:
|
|
77
|
+
return None
|
|
78
|
+
attributes = {"session.id": self.session_id, "observatory.project": self.project,
|
|
79
|
+
"observatory.data_source": "local-session-log", **(attrs or {})}
|
|
80
|
+
return normalize_native({
|
|
81
|
+
"trace_id": identity(self.source_id, self.turn),
|
|
82
|
+
"span_id": identity(self.source_id, key, size=16) if parent else identity(self.source_id, self.turn, "root", size=16),
|
|
83
|
+
"parent_span_id": identity(self.source_id, self.turn, "root", size=16) if parent else None,
|
|
84
|
+
"name": name, "start_unix_nano": min(start or stamp, stamp), "end_unix_nano": stamp,
|
|
85
|
+
"status": "ERROR" if error else "OK", "attributes": attributes,
|
|
86
|
+
"resource_attributes": {"service.name": self.service, "host.name": socket.gethostname(),
|
|
87
|
+
"os.type": platform.system().lower(), "observatory.source_id": self.source_id,
|
|
88
|
+
"observatory.capture": "local-session-log"},
|
|
89
|
+
"scope_name": "observatory.local_agents",
|
|
90
|
+
}, self.service)
|
|
91
|
+
|
|
92
|
+
def root(self, stamp, state):
|
|
93
|
+
attrs = {"agent.task": "Approval review" if self.internal else self.tasks.get(self.turn) or self.runtime + (" · " + self.project if self.project else "") + " · turn",
|
|
94
|
+
"agent.run.state": state}
|
|
95
|
+
if state in {"completed", "interrupted"}:
|
|
96
|
+
attrs["agent.stopped_reason"] = state
|
|
97
|
+
return self.span((self.turn, "root"), "agent.run", stamp,
|
|
98
|
+
attrs, start=self.started.get(self.turn), parent=False)
|
|
99
|
+
|
|
100
|
+
def model_span(self, key, usage, stamp, *, claude=False):
|
|
101
|
+
if not isinstance(usage, dict):
|
|
102
|
+
return None
|
|
103
|
+
input_tokens = count(usage.get("input_tokens"))
|
|
104
|
+
if claude:
|
|
105
|
+
input_tokens += count(usage.get("cache_read_input_tokens")) + count(usage.get("cache_creation_input_tokens"))
|
|
106
|
+
attrs = {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "anthropic" if claude else "openai",
|
|
107
|
+
"gen_ai.usage.input_tokens": input_tokens,
|
|
108
|
+
"gen_ai.usage.output_tokens": count(usage.get("output_tokens")),
|
|
109
|
+
"gen_ai.usage.cache_read.input_tokens": count(usage.get("cache_read_input_tokens") if claude else usage.get("cached_input_tokens")),
|
|
110
|
+
"observatory.duration_unknown": True}
|
|
111
|
+
if self.model:
|
|
112
|
+
attrs["gen_ai.request.model"] = str(self.model)[:200]
|
|
113
|
+
return self.span(key, self.runtime + " · " + str(self.model or "model call")[:200], stamp, attrs)
|
|
114
|
+
|
|
115
|
+
def codex(self, row, stamp):
|
|
116
|
+
kind, payload = row.get("type"), row.get("payload") or {}
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
return []
|
|
119
|
+
if kind == "session_meta":
|
|
120
|
+
self.session_id = str(payload.get("id") or payload.get("session_id") or self.session_id)
|
|
121
|
+
self.project = Path(payload.get("cwd") or "").name
|
|
122
|
+
elif kind == "turn_context":
|
|
123
|
+
self.turn = str(payload.get("turn_id") or self.turn)
|
|
124
|
+
self.model = payload.get("model") or self.model
|
|
125
|
+
if self.model == "codex-auto-review" and not self.internal:
|
|
126
|
+
self.internal = True
|
|
127
|
+
self.capture_details = False
|
|
128
|
+
self.service = "codex-review"
|
|
129
|
+
self.tasks.clear()
|
|
130
|
+
self.store.redact_internal_source(self.source_id)
|
|
131
|
+
self.project = Path(payload.get("cwd") or "").name
|
|
132
|
+
elif kind == "token_usage_record":
|
|
133
|
+
self.modern_usage = True
|
|
134
|
+
self.turn = str(payload.get("turn_id") or self.turn)
|
|
135
|
+
key = payload.get("response_id") or row.get("ordinal") or row.get("timestamp")
|
|
136
|
+
return [self.model_span(("model", key), payload.get("usage"), stamp)]
|
|
137
|
+
elif kind == "event_msg":
|
|
138
|
+
event = payload.get("type")
|
|
139
|
+
self.turn = str(payload.get("turn_id") or self.turn)
|
|
140
|
+
if event == "task_started":
|
|
141
|
+
self.started[self.turn] = timestamp(payload.get("started_at")) or stamp
|
|
142
|
+
return [self.root(stamp, "running")]
|
|
143
|
+
if event in {"task_complete", "task_aborted"}:
|
|
144
|
+
return [self.root(stamp, "completed" if event == "task_complete" else "interrupted")]
|
|
145
|
+
if event == "token_count" and not self.modern_usage:
|
|
146
|
+
info = payload.get("info") or {}
|
|
147
|
+
total = info.get("total_token_usage")
|
|
148
|
+
if not isinstance(total, dict) or total == self.last_total:
|
|
149
|
+
return []
|
|
150
|
+
usage = info.get("last_token_usage")
|
|
151
|
+
self.last_total = total
|
|
152
|
+
return [self.model_span(("usage", self.turn, json.dumps(total, sort_keys=True)), usage, stamp)]
|
|
153
|
+
if event == "item_completed":
|
|
154
|
+
item = payload.get("item") or {}
|
|
155
|
+
tool_type = item.get("type")
|
|
156
|
+
names = {"CommandExecution": "Shell command", "FileChange": "File changes",
|
|
157
|
+
"McpToolCall": "MCP tool", "Extension": "Extension tool"}
|
|
158
|
+
if tool_type in names:
|
|
159
|
+
attrs = {"gen_ai.operation.name": "execute_tool", "tool.name": names[tool_type]}
|
|
160
|
+
if tool_type == "McpToolCall":
|
|
161
|
+
attrs["tool.name"] = str(item.get("tool") or "MCP tool")[:128]
|
|
162
|
+
attrs["mcp.tool.name"] = attrs["tool.name"]
|
|
163
|
+
attrs["mcp.server.name"] = str(item.get("server") or "")[:128]
|
|
164
|
+
attrs["mcp.method.name"] = "tools/call"
|
|
165
|
+
code = item.get("exit_code")
|
|
166
|
+
if isinstance(code, int):
|
|
167
|
+
attrs["process.exit.code"] = code
|
|
168
|
+
error = item.get("status") in {"failed", "error"} or isinstance(code, int) and code != 0
|
|
169
|
+
end = timestamp(payload.get("completed_at_ms")) or stamp
|
|
170
|
+
start = timestamp(payload.get("started_at_ms"))
|
|
171
|
+
from .local_activity import duration
|
|
172
|
+
elapsed = duration(item)
|
|
173
|
+
if not start and elapsed is not None:
|
|
174
|
+
start = max(0, end-int(elapsed*1e6))
|
|
175
|
+
if not start:
|
|
176
|
+
attrs["observatory.duration_unknown"] = True
|
|
177
|
+
name = (attrs["mcp.server.name"] + " / " + attrs["tool.name"]) if tool_type == "McpToolCall" else str(attrs["tool.name"])
|
|
178
|
+
return [self.span(("tool", item.get("id") or row.get("ordinal") or stamp),
|
|
179
|
+
name, end, attrs, start=start, error=error)]
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
def claude(self, row, stamp):
|
|
183
|
+
self.session_id = str(row.get("sessionId") or self.session_id)
|
|
184
|
+
self.project = Path(row.get("cwd") or "").name
|
|
185
|
+
message = row.get("message") or {}
|
|
186
|
+
if not isinstance(message, dict):
|
|
187
|
+
return []
|
|
188
|
+
content = message.get("content") or []
|
|
189
|
+
blocks = content if isinstance(content, list) else []
|
|
190
|
+
result = []
|
|
191
|
+
if row.get("type") == "user":
|
|
192
|
+
tools = [block for block in blocks if isinstance(block, dict) and block.get("type") == "tool_result"]
|
|
193
|
+
if not tools:
|
|
194
|
+
self.turn = str(row.get("uuid") or stamp)
|
|
195
|
+
self.started[self.turn] = stamp
|
|
196
|
+
result.append(self.root(stamp, "running"))
|
|
197
|
+
for block in tools:
|
|
198
|
+
key = block.get("tool_use_id")
|
|
199
|
+
name, start, turn = self.calls.pop(key, ("Claude tool", stamp, self.turn))
|
|
200
|
+
previous, self.turn = self.turn, turn
|
|
201
|
+
result.append(self.span(("tool", key), name, stamp,
|
|
202
|
+
{"gen_ai.operation.name": "execute_tool", "tool.name": name},
|
|
203
|
+
start=start, error=bool(block.get("is_error"))))
|
|
204
|
+
self.turn = previous
|
|
205
|
+
elif row.get("type") == "assistant":
|
|
206
|
+
self.model = message.get("model") or self.model
|
|
207
|
+
key = message.get("id") or row.get("uuid") or stamp
|
|
208
|
+
usage = message.get("usage")
|
|
209
|
+
if isinstance(usage, dict):
|
|
210
|
+
previous = self.usage.setdefault(key, {})
|
|
211
|
+
for field in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"):
|
|
212
|
+
previous[field] = max(count(previous.get(field)), count(usage.get(field)))
|
|
213
|
+
result.append(self.model_span(("model", key), previous, stamp, claude=True))
|
|
214
|
+
for block in blocks:
|
|
215
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
216
|
+
self.calls[block.get("id")] = (str(block.get("name") or "Claude tool")[:128], stamp, self.turn)
|
|
217
|
+
if message.get("stop_reason") == "end_turn":
|
|
218
|
+
result.append(self.root(stamp, "completed"))
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
def poll(self):
|
|
222
|
+
stat = self.path.stat()
|
|
223
|
+
file_id = stat.st_dev, stat.st_ino
|
|
224
|
+
if self.file_id != file_id or stat.st_size < self.offset:
|
|
225
|
+
self.offset, self.pending = 0, b""
|
|
226
|
+
self.file_id = file_id
|
|
227
|
+
self.last_total = None
|
|
228
|
+
self.started.clear()
|
|
229
|
+
self.calls.clear()
|
|
230
|
+
self.usage.clear()
|
|
231
|
+
self.tasks.clear()
|
|
232
|
+
self.tool_inputs.clear()
|
|
233
|
+
with self.path.open("rb") as stream:
|
|
234
|
+
if self.offset == 0 and stat.st_size > MAX_READ:
|
|
235
|
+
# Recover session identity from the header, then import a bounded tail.
|
|
236
|
+
header = stream.readline(MAX_LINE)
|
|
237
|
+
try:
|
|
238
|
+
row = json.loads(header)
|
|
239
|
+
if self.runtime == "Codex":
|
|
240
|
+
self.codex(row, timestamp(row.get("timestamp")))
|
|
241
|
+
except (ValueError, TypeError, AttributeError):
|
|
242
|
+
pass
|
|
243
|
+
stream.seek(stat.st_size - MAX_READ)
|
|
244
|
+
stream.readline()
|
|
245
|
+
self.offset = stream.tell()
|
|
246
|
+
stream.seek(self.offset)
|
|
247
|
+
data = stream.read(MAX_READ)
|
|
248
|
+
self.offset = stream.tell()
|
|
249
|
+
chunks = (self.pending + data).split(b"\n")
|
|
250
|
+
self.pending = chunks.pop()
|
|
251
|
+
if len(self.pending) > MAX_LINE:
|
|
252
|
+
self.pending = b""
|
|
253
|
+
self.skipped += 1
|
|
254
|
+
records = []
|
|
255
|
+
for line in chunks:
|
|
256
|
+
if len(line) > MAX_LINE:
|
|
257
|
+
self.skipped += 1
|
|
258
|
+
continue
|
|
259
|
+
try:
|
|
260
|
+
row = json.loads(line)
|
|
261
|
+
if isinstance(row, dict):
|
|
262
|
+
records.append(row)
|
|
263
|
+
except (ValueError, UnicodeDecodeError):
|
|
264
|
+
self.skipped += 1
|
|
265
|
+
if self.runtime == "Codex" and any(row.get("type") == "token_usage_record" for row in records):
|
|
266
|
+
self.modern_usage = True
|
|
267
|
+
spans = []
|
|
268
|
+
logs = []
|
|
269
|
+
from .local_activity import codex_activity, claude_activity
|
|
270
|
+
for row in records:
|
|
271
|
+
try:
|
|
272
|
+
stamp = timestamp(row.get("timestamp"))
|
|
273
|
+
spans.extend(span for span in (self.codex(row, stamp) if self.runtime == "Codex" else self.claude(row, stamp)) if span)
|
|
274
|
+
logs.extend(codex_activity(self, row, stamp) if self.runtime == "Codex" else claude_activity(self, row, stamp))
|
|
275
|
+
if self.capture_details and self.turn in self.tasks and self.turn in self.started:
|
|
276
|
+
payload = row.get("payload") or {}
|
|
277
|
+
if (self.runtime == "Codex" and isinstance(payload, dict) and (payload.get("item") or {}).get("type") == "UserMessage") or (self.runtime == "Claude Code" and row.get("type") == "user" and plaintext_task(row)):
|
|
278
|
+
spans.append(self.root(stamp, "running"))
|
|
279
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
280
|
+
self.skipped += 1
|
|
281
|
+
if self.internal:
|
|
282
|
+
for span in spans:
|
|
283
|
+
span['service'] = self.service
|
|
284
|
+
span['resource_attributes']['service.name'] = self.service
|
|
285
|
+
if span['name'] == 'agent.run':
|
|
286
|
+
span['attributes']['agent.task'] = 'Approval review'
|
|
287
|
+
for log in logs:
|
|
288
|
+
log['service'] = self.service
|
|
289
|
+
log['body'] = ''
|
|
290
|
+
log['attributes']['agent.activity.subject'] = 'Approval review'
|
|
291
|
+
if spans:
|
|
292
|
+
self.store.ingest(spans)
|
|
293
|
+
if logs:
|
|
294
|
+
self.store.ingest_logs(logs)
|
|
295
|
+
last = logs[-1]
|
|
296
|
+
self.last_activity = {"time_unix_nano": last["time_unix_nano"],
|
|
297
|
+
"domain": last["attributes"].get("agent.activity.domain"),
|
|
298
|
+
"action": last["attributes"].get("agent.activity.action"),
|
|
299
|
+
"subject": last["attributes"].get("agent.activity.subject")}
|
|
300
|
+
state = "running" if time.time() - stat.st_mtime < 35 else "stopped"
|
|
301
|
+
if time.monotonic() - self.last_heartbeat > 10 or state != self.last_state:
|
|
302
|
+
self.store.register_connection({"source_id": self.source_id, "service": self.service,
|
|
303
|
+
"runtime": self.runtime + " local sessions", "integrations": ["local-session-log"],
|
|
304
|
+
"capture_content": self.capture_details, "state": state})
|
|
305
|
+
self.last_heartbeat, self.last_state = time.monotonic(), state
|
|
306
|
+
return len(spans)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def plaintext_task(row):
|
|
310
|
+
message = row.get("message") or {}
|
|
311
|
+
if not isinstance(message, dict):
|
|
312
|
+
return False
|
|
313
|
+
content = message.get("content")
|
|
314
|
+
return isinstance(content, str) or isinstance(content, list) and any(isinstance(block, dict) and block.get("type") == "text" for block in content)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class LocalAgents:
|
|
318
|
+
def __init__(self, store, capture_details=True):
|
|
319
|
+
self.store = store
|
|
320
|
+
self.capture_details = capture_details
|
|
321
|
+
self.stop = threading.Event()
|
|
322
|
+
self.sessions = {}
|
|
323
|
+
self.errors = 0
|
|
324
|
+
self.runtime_snapshot = {}
|
|
325
|
+
self.runtime_updated = 0
|
|
326
|
+
self.thread = threading.Thread(target=self.loop, name="observatory-local-agents", daemon=True)
|
|
327
|
+
|
|
328
|
+
def discover(self):
|
|
329
|
+
roots = [(Path(os.environ.get("CODEX_HOME", str(Path.home()/".codex")))/"sessions", "Codex"),
|
|
330
|
+
(Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home()/".claude")))/"projects", "Claude Code")]
|
|
331
|
+
recent = []
|
|
332
|
+
for root, runtime in roots:
|
|
333
|
+
if not root.is_dir():
|
|
334
|
+
continue
|
|
335
|
+
for path in root.rglob("*.jsonl"):
|
|
336
|
+
try:
|
|
337
|
+
modified = path.stat().st_mtime
|
|
338
|
+
if modified >= time.time() - 86400:
|
|
339
|
+
recent.append((modified, path, runtime))
|
|
340
|
+
except OSError:
|
|
341
|
+
self.errors += 1
|
|
342
|
+
selected = sorted(recent, key=lambda row: row[0], reverse=True)[:MAX_SOURCES]
|
|
343
|
+
keys = {path for _, path, _ in selected}
|
|
344
|
+
self.sessions = {path: session for path, session in self.sessions.items() if path in keys}
|
|
345
|
+
for _, path, runtime in selected:
|
|
346
|
+
if path not in self.sessions:
|
|
347
|
+
self.sessions[path] = Session(self.store, path, runtime, capture_details=self.capture_details)
|
|
348
|
+
|
|
349
|
+
def poll(self):
|
|
350
|
+
accepted = 0
|
|
351
|
+
for session in list(self.sessions.values()):
|
|
352
|
+
try:
|
|
353
|
+
accepted += session.poll()
|
|
354
|
+
except OSError:
|
|
355
|
+
self.errors += 1
|
|
356
|
+
return accepted
|
|
357
|
+
|
|
358
|
+
def loop(self):
|
|
359
|
+
discovered = 0
|
|
360
|
+
while not self.stop.is_set():
|
|
361
|
+
try:
|
|
362
|
+
if time.monotonic() - discovered >= 10:
|
|
363
|
+
self.discover()
|
|
364
|
+
discovered = time.monotonic()
|
|
365
|
+
self.poll()
|
|
366
|
+
except Exception:
|
|
367
|
+
self.errors += 1
|
|
368
|
+
self.stop.wait(1)
|
|
369
|
+
|
|
370
|
+
def start(self):
|
|
371
|
+
self.thread.start()
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
def status(self):
|
|
375
|
+
sessions = list(self.sessions.values())
|
|
376
|
+
if time.monotonic() - self.runtime_updated >= 5:
|
|
377
|
+
from .local_runtime import snapshot
|
|
378
|
+
self.runtime_snapshot = snapshot()
|
|
379
|
+
self.runtime_updated = time.monotonic()
|
|
380
|
+
return {"enabled": True, "sources": len(sessions), "read_errors": self.errors,
|
|
381
|
+
"skipped_records": sum(session.skipped for session in sessions),
|
|
382
|
+
"capture_content": self.capture_details, "lookback_hours": 24, "max_sources": MAX_SOURCES,
|
|
383
|
+
"runtime": self.runtime_snapshot,
|
|
384
|
+
"sessions": [{"source_id": session.source_id, "runtime": session.runtime,
|
|
385
|
+
"service": session.service, "session_id": session.session_id,
|
|
386
|
+
"project": session.project, "model": session.model,
|
|
387
|
+
"task": "Approval review" if session.internal else (session.tasks.get(session.turn) or "")[:500],
|
|
388
|
+
"capture_content": session.capture_details,
|
|
389
|
+
"last_activity": session.last_activity,
|
|
390
|
+
"bytes_read": session.offset} for session in sessions]}
|
|
391
|
+
|
|
392
|
+
def close(self):
|
|
393
|
+
self.stop.set()
|
|
394
|
+
self.thread.join(timeout=15)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Local host and known agent process metadata; no environment or arguments."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def snapshot():
|
|
12
|
+
result = {"host": socket.gethostname(), "os": platform.system(), "architecture": platform.machine(),
|
|
13
|
+
"collector_pid": os.getpid(), "processes": [], "process_inventory_available": Path('/proc').is_dir()}
|
|
14
|
+
try:
|
|
15
|
+
result["load_average"] = list(os.getloadavg())
|
|
16
|
+
except (OSError, AttributeError):
|
|
17
|
+
pass
|
|
18
|
+
if not result["process_inventory_available"]:
|
|
19
|
+
return result
|
|
20
|
+
allowed = {"codex", "claude", "claude-code", "opencode", "gemini", "ollama", "cursor"}
|
|
21
|
+
for proc in Path('/proc').iterdir():
|
|
22
|
+
if not proc.name.isdigit():
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
if proc.stat().st_uid != os.getuid():
|
|
26
|
+
continue
|
|
27
|
+
name = (proc/'comm').read_text().strip()
|
|
28
|
+
if name.lower() not in allowed:
|
|
29
|
+
continue
|
|
30
|
+
fields = {}
|
|
31
|
+
for line in (proc/'status').read_text().splitlines():
|
|
32
|
+
key, _, value = line.partition(':')
|
|
33
|
+
fields[key] = value.strip()
|
|
34
|
+
stat = (proc/'stat').read_text().rsplit(')', 1)[1].split()
|
|
35
|
+
ticks = os.sysconf('SC_CLK_TCK')
|
|
36
|
+
info = {"pid": int(proc.name), "name": name, "state": fields.get('State', ''),
|
|
37
|
+
"parent_pid": int(fields.get('PPid', 0)),
|
|
38
|
+
"memory_rss_kib": int(fields.get('VmRSS', '0 kB').split()[0]),
|
|
39
|
+
"cpu_seconds": round((int(stat[11])+int(stat[12]))/ticks, 2)}
|
|
40
|
+
try:
|
|
41
|
+
info['project'] = Path(os.readlink(proc/'cwd')).name
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
result['processes'].append(info)
|
|
45
|
+
except (OSError, ValueError, IndexError):
|
|
46
|
+
continue
|
|
47
|
+
result['processes'] = sorted(result['processes'], key=lambda item:item['pid'])[:50]
|
|
48
|
+
result['observed_at_ns'] = time.time_ns()
|
|
49
|
+
return result
|
observatory/logs.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Normalize OTLP log records from JSON and HTTP/protobuf requests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .core import TRACE_ID, SPAN_ID, _attrs, _value
|
|
7
|
+
from .otlp_proto import _fields, _nested, _attributes, _any_value, _text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _optional_id(value, pattern):
|
|
11
|
+
if isinstance(value, str) and pattern.fullmatch(value) and any(c != "0" for c in value):
|
|
12
|
+
return value.lower()
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _log(raw, service, resource, scope):
|
|
17
|
+
timestamp = int(raw.get("time_unix_nano") or raw.get("observed_time_unix_nano") or time.time_ns())
|
|
18
|
+
if timestamp < 0:
|
|
19
|
+
raise ValueError("invalid log timestamp")
|
|
20
|
+
attrs = raw.get("attributes") or {}
|
|
21
|
+
if not isinstance(attrs, dict):
|
|
22
|
+
raise ValueError("log attributes must be an object")
|
|
23
|
+
return {"time_unix_nano": timestamp,
|
|
24
|
+
"trace_id": _optional_id(raw.get("trace_id"), TRACE_ID),
|
|
25
|
+
"span_id": _optional_id(raw.get("span_id"), SPAN_ID),
|
|
26
|
+
"service": service, "scope_name": scope,
|
|
27
|
+
"resource_attributes": resource,
|
|
28
|
+
"severity_number": int(raw.get("severity_number") or 0),
|
|
29
|
+
"severity_text": str(raw.get("severity_text") or ""),
|
|
30
|
+
"event_name": str(raw.get("event_name") or attrs.get("event.name") or ""),
|
|
31
|
+
"body": raw.get("body"), "attributes": attrs}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_otlp_logs(body):
|
|
35
|
+
result = []
|
|
36
|
+
for group in body.get("resourceLogs", []):
|
|
37
|
+
resource = _attrs(group.get("resource", {}).get("attributes"))
|
|
38
|
+
service = str(resource.get("service.name") or "unknown")
|
|
39
|
+
for scope in group.get("scopeLogs", []):
|
|
40
|
+
scope_name = str(scope.get("scope", {}).get("name") or "")
|
|
41
|
+
for item in scope.get("logRecords", []):
|
|
42
|
+
raw = {"time_unix_nano": item.get("timeUnixNano"),
|
|
43
|
+
"observed_time_unix_nano": item.get("observedTimeUnixNano"),
|
|
44
|
+
"trace_id": item.get("traceId"), "span_id": item.get("spanId"),
|
|
45
|
+
"severity_number": item.get("severityNumber"),
|
|
46
|
+
"severity_text": item.get("severityText"),
|
|
47
|
+
"event_name": item.get("eventName"),
|
|
48
|
+
"body": _value(item.get("body", {})),
|
|
49
|
+
"attributes": _attrs(item.get("attributes"))}
|
|
50
|
+
result.append(_log(raw, service, resource, scope_name))
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record_proto(raw, service, resource, scope, depth):
|
|
55
|
+
item = {"attributes": {}}
|
|
56
|
+
for number, wire, value in _nested(raw, depth):
|
|
57
|
+
if number == 1 and wire == 1:
|
|
58
|
+
item["time_unix_nano"] = value
|
|
59
|
+
elif number == 11 and wire == 1:
|
|
60
|
+
item["observed_time_unix_nano"] = value
|
|
61
|
+
elif number == 2 and wire == 0:
|
|
62
|
+
item["severity_number"] = value
|
|
63
|
+
elif number == 3 and wire == 2:
|
|
64
|
+
item["severity_text"] = _text(value)
|
|
65
|
+
elif number == 5 and wire == 2:
|
|
66
|
+
item["body"] = _any_value(value, depth + 1)
|
|
67
|
+
elif number == 6 and wire == 2:
|
|
68
|
+
key, attr = _kv_log(value, depth + 1)
|
|
69
|
+
if key:
|
|
70
|
+
item["attributes"][key] = attr
|
|
71
|
+
elif number == 9 and wire == 2:
|
|
72
|
+
item["trace_id"] = value.hex()
|
|
73
|
+
elif number == 10 and wire == 2:
|
|
74
|
+
item["span_id"] = value.hex()
|
|
75
|
+
elif number == 12 and wire == 2:
|
|
76
|
+
item["event_name"] = _text(value)
|
|
77
|
+
return _log(item, service, resource, scope)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _kv_log(raw, depth):
|
|
81
|
+
from .otlp_proto import _kv
|
|
82
|
+
return _kv(raw, depth)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decode_log_request(raw):
|
|
86
|
+
result = []
|
|
87
|
+
for number, wire, group in _fields(raw):
|
|
88
|
+
if number != 1 or wire != 2:
|
|
89
|
+
continue
|
|
90
|
+
resource = {}
|
|
91
|
+
scopes = []
|
|
92
|
+
for field, kind, value in _nested(group, 1):
|
|
93
|
+
if field == 1 and kind == 2:
|
|
94
|
+
resource = _attributes(value, 2)
|
|
95
|
+
elif field == 2 and kind == 2:
|
|
96
|
+
scopes.append(value)
|
|
97
|
+
service = str(resource.get("service.name") or "unknown")
|
|
98
|
+
for scope in scopes:
|
|
99
|
+
scope_name = ""
|
|
100
|
+
records = []
|
|
101
|
+
for field, kind, value in _nested(scope, 2):
|
|
102
|
+
if field == 1 and kind == 2:
|
|
103
|
+
for key, typ, item in _nested(value, 3):
|
|
104
|
+
if key == 1 and typ == 2:
|
|
105
|
+
scope_name = _text(item)
|
|
106
|
+
elif field == 2 and kind == 2:
|
|
107
|
+
records.append(value)
|
|
108
|
+
result.extend(_record_proto(item, service, resource, scope_name, 3) for item in records)
|
|
109
|
+
return result
|