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/core.py
ADDED
|
@@ -0,0 +1,1654 @@
|
|
|
1
|
+
"""Normalize native JSONL and OTLP/JSON spans into one queryable store."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
import base64
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .findings import analyze
|
|
16
|
+
|
|
17
|
+
TRACE_ID = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
18
|
+
SPAN_ID = re.compile(r"^[0-9a-fA-F]{16}$")
|
|
19
|
+
SESSION_KEYS = ("gen_ai.conversation.id", "session.id", "agent.session_id",
|
|
20
|
+
"session_id", "conversation_id", "thread_id", "langgraph.thread_id",
|
|
21
|
+
"langchain.session_id")
|
|
22
|
+
ENDPOINT_ID_KEYS = ("host.id", "host.name", "device.id", "service.instance.id")
|
|
23
|
+
ENDPOINT_RESOURCE_KEYS = frozenset((*ENDPOINT_ID_KEYS, "host.arch", "os.type", "os.name",
|
|
24
|
+
"os.description", "os.version", "user.name", "user.id",
|
|
25
|
+
"deployment.environment.name", "cloud.region"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _id(value, pattern, label):
|
|
29
|
+
if not isinstance(value, str) or not pattern.fullmatch(value):
|
|
30
|
+
raise ValueError(f"invalid {label}")
|
|
31
|
+
if not any(c != "0" for c in value):
|
|
32
|
+
raise ValueError(f"invalid zero {label}")
|
|
33
|
+
return value.lower()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _session_value(value):
|
|
37
|
+
if not isinstance(value, str):
|
|
38
|
+
return None
|
|
39
|
+
value = value.strip()
|
|
40
|
+
return value if value and len(value) <= 512 else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _endpoint_identity(resource):
|
|
44
|
+
if not isinstance(resource, dict):
|
|
45
|
+
return None
|
|
46
|
+
for key in ENDPOINT_ID_KEYS:
|
|
47
|
+
value = resource.get(key)
|
|
48
|
+
if isinstance(value, str) and value.strip():
|
|
49
|
+
return key, value.strip()[:512]
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _session_for(spans, progress, logs):
|
|
54
|
+
ordered_spans = sorted(spans, key=lambda span: (bool(span["parent_span_id"]), span["start_unix_nano"]))
|
|
55
|
+
for key in SESSION_KEYS:
|
|
56
|
+
for span in ordered_spans:
|
|
57
|
+
value = _session_value(span["attributes"].get(key) or span["resource_attributes"].get(key))
|
|
58
|
+
if value:
|
|
59
|
+
return value
|
|
60
|
+
for event in span["events"]:
|
|
61
|
+
value = _session_value(event.get("attributes", {}).get(key))
|
|
62
|
+
if value:
|
|
63
|
+
return value
|
|
64
|
+
for log in logs:
|
|
65
|
+
value = _session_value(log["attributes"].get(key) or log["resource_attributes"].get(key))
|
|
66
|
+
if value:
|
|
67
|
+
return value
|
|
68
|
+
if key == "session.id":
|
|
69
|
+
for event in progress:
|
|
70
|
+
value = _session_value(event.get("session_id"))
|
|
71
|
+
if value:
|
|
72
|
+
return value
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _activity_row(attrs, time_ns, source, source_id, span_id=None, service="unknown", body=None):
|
|
77
|
+
"""Normalize the shared agent.activity event vocabulary for the trace ledger."""
|
|
78
|
+
attrs = attrs if isinstance(attrs, dict) else {}
|
|
79
|
+
def value(*keys):
|
|
80
|
+
for key in keys:
|
|
81
|
+
item = attrs.get(key)
|
|
82
|
+
if item is not None and item != "":
|
|
83
|
+
return str(item)
|
|
84
|
+
return ""
|
|
85
|
+
try:
|
|
86
|
+
time_ns = int(time_ns or 0)
|
|
87
|
+
except (TypeError, ValueError, OverflowError):
|
|
88
|
+
time_ns = 0
|
|
89
|
+
return {"time_unix_nano": max(0, time_ns),
|
|
90
|
+
"domain": value("agent.activity.domain", "activity.domain") or "other",
|
|
91
|
+
"action": value("agent.activity.action", "activity.action") or "activity",
|
|
92
|
+
"subject": value("agent.activity.subject", "agent.activity.target",
|
|
93
|
+
"activity.subject", "activity.target"),
|
|
94
|
+
"result": value("agent.activity.result", "activity.result") or "unknown",
|
|
95
|
+
"actor": value("agent.activity.actor", "agent.actor"),
|
|
96
|
+
"service": str(service), "source": source, "source_id": str(source_id),
|
|
97
|
+
"span_id": span_id, "detail": body if isinstance(body, str) else "",
|
|
98
|
+
"mcp_server": value("mcp.server.name"), "mcp_tool": value("mcp.tool.name"),
|
|
99
|
+
"duration_ms": attrs.get("duration_ms")}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _progress_from_log(log):
|
|
103
|
+
attrs = log.get("attributes") if isinstance(log.get("attributes"), dict) else {}
|
|
104
|
+
event_name = log.get("event_name") or attrs.get("event.name")
|
|
105
|
+
if event_name not in {"agent.progress", "progress"}:
|
|
106
|
+
return None
|
|
107
|
+
body = log.get("body")
|
|
108
|
+
body_map = body if isinstance(body, dict) else {}
|
|
109
|
+
def first(*values):
|
|
110
|
+
return next((value for value in values if value is not None and value != ""), None)
|
|
111
|
+
percent = first(attrs.get("agent.progress.percent"), attrs.get("progress.percent"),
|
|
112
|
+
body_map.get("percent"))
|
|
113
|
+
try:
|
|
114
|
+
percent = None if isinstance(percent, bool) else (float(percent) if percent is not None else None)
|
|
115
|
+
if percent is not None and (not math.isfinite(percent) or not 0 <= percent <= 100):
|
|
116
|
+
percent = None
|
|
117
|
+
except (TypeError, ValueError, OverflowError):
|
|
118
|
+
percent = None
|
|
119
|
+
if isinstance(body, str):
|
|
120
|
+
body_message = body
|
|
121
|
+
elif body_map:
|
|
122
|
+
body_message = first(body_map.get("message"), body_map.get("detail"), "")
|
|
123
|
+
else:
|
|
124
|
+
body_message = ""
|
|
125
|
+
session_id = first(*(attrs.get(key) for key in SESSION_KEYS),
|
|
126
|
+
*(log.get("resource_attributes", {}).get(key) for key in SESSION_KEYS))
|
|
127
|
+
return {"time_ns": log.get("time_unix_nano", 0),
|
|
128
|
+
"stage": str(first(attrs.get("agent.progress.stage"), attrs.get("progress.stage"),
|
|
129
|
+
body_map.get("stage"), "progress")),
|
|
130
|
+
"message": str(first(attrs.get("agent.progress.message"), attrs.get("progress.message"),
|
|
131
|
+
body_message, "")),
|
|
132
|
+
"percent": percent, "session_id": _session_value(session_id),
|
|
133
|
+
"service": log.get("service")}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tool_activity(span):
|
|
137
|
+
attrs = span.get("attributes") if isinstance(span.get("attributes"), dict) else {}
|
|
138
|
+
operation = str(attrs.get("gen_ai.operation.name") or "").lower()
|
|
139
|
+
openinference_tool = attrs.get("openinference.span.kind") == "TOOL"
|
|
140
|
+
tool_name = (attrs.get("gen_ai.tool.name") or attrs.get("mcp.tool.name") or
|
|
141
|
+
attrs.get("tool.name"))
|
|
142
|
+
if not tool_name and operation != "execute_tool" and not openinference_tool:
|
|
143
|
+
return None
|
|
144
|
+
tool_name = str(tool_name or span.get("name") or "tool call")
|
|
145
|
+
domain = (attrs.get("gen_ai.tool.type") or attrs.get("tool.type") or
|
|
146
|
+
("mcp" if attrs.get("mcp.server.name") or attrs.get("mcp.method.name") else
|
|
147
|
+
"openinference" if openinference_tool else "tool"))
|
|
148
|
+
action = (attrs.get("tool.action") or attrs.get("mcp.method.name") or
|
|
149
|
+
operation or "call")
|
|
150
|
+
status = str(attrs.get("tool.result_status") or "").lower()
|
|
151
|
+
if span.get("status") == "ERROR" or attrs.get("error.type") or attrs.get("tool.error"):
|
|
152
|
+
result = "error"
|
|
153
|
+
elif status in {"error", "failed", "failure"}:
|
|
154
|
+
result = "error"
|
|
155
|
+
elif status in {"success", "ok", "completed"} or span.get("status") == "OK":
|
|
156
|
+
result = "success"
|
|
157
|
+
else:
|
|
158
|
+
result = "unknown"
|
|
159
|
+
normalized = {
|
|
160
|
+
"agent.activity.domain": str(domain),
|
|
161
|
+
"agent.activity.action": str(action),
|
|
162
|
+
"agent.activity.subject": tool_name,
|
|
163
|
+
"agent.activity.result": result,
|
|
164
|
+
"agent.activity.actor": str(attrs.get("gen_ai.agent.name") or attrs.get("agent.name") or ""),
|
|
165
|
+
}
|
|
166
|
+
return _activity_row(normalized, span.get("end_unix_nano") or span.get("start_unix_nano"),
|
|
167
|
+
"span", span["span_id"], span["span_id"], span.get("service", "unknown"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _activity_for(spans, logs):
|
|
171
|
+
activity = []
|
|
172
|
+
explicit_span_ids = set()
|
|
173
|
+
for span in spans:
|
|
174
|
+
for event in span["events"]:
|
|
175
|
+
if not isinstance(event, dict) or event.get("name") != "agent.activity":
|
|
176
|
+
continue
|
|
177
|
+
explicit_span_ids.add(span["span_id"])
|
|
178
|
+
activity.append(_activity_row(event.get("attributes"),
|
|
179
|
+
event.get("time_unix_nano") or span["start_unix_nano"],
|
|
180
|
+
"span", span["span_id"], span["span_id"], span["service"]))
|
|
181
|
+
linked_logs = {log.get("span_id") for log in logs if log["event_name"] == "agent.activity"}
|
|
182
|
+
for span in spans:
|
|
183
|
+
if span["span_id"] not in explicit_span_ids and span["span_id"] not in linked_logs:
|
|
184
|
+
inferred = _tool_activity(span)
|
|
185
|
+
if inferred is not None:
|
|
186
|
+
activity.append(inferred)
|
|
187
|
+
for log in logs:
|
|
188
|
+
if log["event_name"] != "agent.activity":
|
|
189
|
+
continue
|
|
190
|
+
activity.append(_activity_row(log["attributes"], log["time_unix_nano"], "log",
|
|
191
|
+
log["id"], log.get("span_id"), log["service"],
|
|
192
|
+
log["body"] if isinstance(log["body"], str) else ""))
|
|
193
|
+
return sorted(activity, key=lambda item: (item["time_unix_nano"], item["source_id"]))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _attrs(items):
|
|
197
|
+
result = {}
|
|
198
|
+
for item in items or []:
|
|
199
|
+
result[item["key"]] = _value(item.get("value", {}))
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _value(value):
|
|
204
|
+
if "arrayValue" in value:
|
|
205
|
+
return [_value(x) for x in value["arrayValue"].get("values", [])]
|
|
206
|
+
if "kvlistValue" in value:
|
|
207
|
+
return _attrs(value["kvlistValue"].get("values", []))
|
|
208
|
+
for key in ("stringValue", "intValue", "doubleValue", "boolValue", "bytesValue"):
|
|
209
|
+
if key in value:
|
|
210
|
+
v = value[key]
|
|
211
|
+
return int(v) if key == "intValue" else v
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def normalize_native(raw, service="agent-harness"):
|
|
216
|
+
trace_id = _id(raw.get("trace_id"), TRACE_ID, "trace_id")
|
|
217
|
+
span_id = _id(raw.get("span_id"), SPAN_ID, "span_id")
|
|
218
|
+
parent = raw.get("parent_span_id")
|
|
219
|
+
if parent:
|
|
220
|
+
parent = _id(parent, SPAN_ID, "parent_span_id")
|
|
221
|
+
start, end = int(raw["start_unix_nano"]), int(raw["end_unix_nano"])
|
|
222
|
+
if start < 0 or end < start:
|
|
223
|
+
raise ValueError("invalid span timestamps")
|
|
224
|
+
attrs = raw.get("attributes") or {}
|
|
225
|
+
events = raw.get("events") or []
|
|
226
|
+
links = raw.get("links") or []
|
|
227
|
+
resource = raw.get("resource_attributes") or {}
|
|
228
|
+
if not isinstance(attrs, dict) or not isinstance(events, list) or not isinstance(links, list) or not isinstance(resource, dict):
|
|
229
|
+
raise ValueError("attributes/resource must be objects and events/links arrays")
|
|
230
|
+
normalized_links = []
|
|
231
|
+
for link in links:
|
|
232
|
+
normalized_links.append({"trace_id": _id(link.get("trace_id"), TRACE_ID, "link trace_id"),
|
|
233
|
+
"span_id": _id(link.get("span_id"), SPAN_ID, "link span_id"),
|
|
234
|
+
"attributes": link.get("attributes") or {}})
|
|
235
|
+
return {"trace_id": trace_id, "span_id": span_id, "parent_span_id": parent,
|
|
236
|
+
"name": str(raw["name"]), "start_unix_nano": start,
|
|
237
|
+
"end_unix_nano": end, "status": str(raw.get("status", "UNSET")),
|
|
238
|
+
"service": service, "attributes": attrs, "events": events,
|
|
239
|
+
"links": normalized_links, "resource_attributes": resource,
|
|
240
|
+
"scope_name": str(raw.get("scope_name") or "")}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def normalize_otlp(body):
|
|
244
|
+
spans = []
|
|
245
|
+
for group in body.get("resourceSpans", []):
|
|
246
|
+
resource = _attrs(group.get("resource", {}).get("attributes"))
|
|
247
|
+
service = str(resource.get("service.name") or "unknown")
|
|
248
|
+
for scope in group.get("scopeSpans", []):
|
|
249
|
+
for raw in scope.get("spans", []):
|
|
250
|
+
status_code = int(raw.get("status", {}).get("code", 0))
|
|
251
|
+
native = {
|
|
252
|
+
"trace_id": raw.get("traceId"), "span_id": raw.get("spanId"),
|
|
253
|
+
"parent_span_id": raw.get("parentSpanId") or None,
|
|
254
|
+
"name": raw.get("name", "unnamed"),
|
|
255
|
+
"start_unix_nano": raw.get("startTimeUnixNano"),
|
|
256
|
+
"end_unix_nano": raw.get("endTimeUnixNano"),
|
|
257
|
+
"status": {0: "UNSET", 1: "OK", 2: "ERROR"}.get(status_code, "UNSET"),
|
|
258
|
+
"attributes": _attrs(raw.get("attributes")),
|
|
259
|
+
"resource_attributes": resource,
|
|
260
|
+
"scope_name": scope.get("scope", {}).get("name", ""),
|
|
261
|
+
"events": [{"name": e.get("name", "event"),
|
|
262
|
+
"time_unix_nano": int(e.get("timeUnixNano", 0)),
|
|
263
|
+
"attributes": _attrs(e.get("attributes"))}
|
|
264
|
+
for e in raw.get("events", [])],
|
|
265
|
+
"links": [{"trace_id": link.get("traceId"),
|
|
266
|
+
"span_id": link.get("spanId"),
|
|
267
|
+
"attributes": _attrs(link.get("attributes"))}
|
|
268
|
+
for link in raw.get("links", [])],
|
|
269
|
+
}
|
|
270
|
+
spans.append(normalize_native(native, service))
|
|
271
|
+
return spans
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class Store:
|
|
275
|
+
def __init__(self, path, async_derived_refresh=False):
|
|
276
|
+
self.path = str(path)
|
|
277
|
+
self._async_derived_refresh = bool(async_derived_refresh)
|
|
278
|
+
self._derived_condition = threading.Condition()
|
|
279
|
+
self._derived_pending = {}
|
|
280
|
+
self._derived_stopping = False
|
|
281
|
+
self._derived_thread = None
|
|
282
|
+
self.last_derived_error = None
|
|
283
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
with self._connect() as db:
|
|
285
|
+
db.executescript("""
|
|
286
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
287
|
+
trace_id TEXT NOT NULL, span_id TEXT NOT NULL,
|
|
288
|
+
parent_span_id TEXT, name TEXT NOT NULL,
|
|
289
|
+
start_ns INTEGER NOT NULL, end_ns INTEGER NOT NULL,
|
|
290
|
+
status TEXT NOT NULL, service TEXT NOT NULL,
|
|
291
|
+
attributes TEXT NOT NULL, events TEXT NOT NULL,
|
|
292
|
+
links TEXT NOT NULL DEFAULT '[]',
|
|
293
|
+
resource_attributes TEXT NOT NULL DEFAULT '{}',
|
|
294
|
+
scope_name TEXT NOT NULL DEFAULT '',
|
|
295
|
+
PRIMARY KEY (trace_id, span_id));
|
|
296
|
+
CREATE INDEX IF NOT EXISTS spans_time ON spans(end_ns DESC);
|
|
297
|
+
CREATE INDEX IF NOT EXISTS spans_service_time ON spans(service,end_ns);
|
|
298
|
+
CREATE TABLE IF NOT EXISTS agent_connections (
|
|
299
|
+
source_id TEXT PRIMARY KEY, service TEXT NOT NULL, runtime TEXT NOT NULL,
|
|
300
|
+
integrations TEXT NOT NULL, capture_content INTEGER NOT NULL,
|
|
301
|
+
process_id INTEGER, started_ns INTEGER NOT NULL, last_seen_ns INTEGER NOT NULL,
|
|
302
|
+
state TEXT NOT NULL);
|
|
303
|
+
CREATE TABLE IF NOT EXISTS progress (
|
|
304
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, trace_id TEXT NOT NULL,
|
|
305
|
+
time_ns INTEGER NOT NULL, stage TEXT NOT NULL,
|
|
306
|
+
message TEXT NOT NULL, percent REAL, session_id TEXT, service TEXT);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS progress_trace ON progress(trace_id, time_ns);
|
|
308
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
309
|
+
digest TEXT PRIMARY KEY, trace_id TEXT, span_id TEXT,
|
|
310
|
+
time_ns INTEGER NOT NULL, service TEXT NOT NULL,
|
|
311
|
+
severity_number INTEGER NOT NULL, severity_text TEXT NOT NULL,
|
|
312
|
+
event_name TEXT NOT NULL, body TEXT NOT NULL,
|
|
313
|
+
attributes TEXT NOT NULL, resource_attributes TEXT NOT NULL,
|
|
314
|
+
scope_name TEXT NOT NULL);
|
|
315
|
+
CREATE INDEX IF NOT EXISTS logs_trace ON logs(trace_id,time_ns);
|
|
316
|
+
CREATE INDEX IF NOT EXISTS logs_time ON logs(time_ns DESC);
|
|
317
|
+
CREATE INDEX IF NOT EXISTS logs_native_record ON logs(json_extract(attributes,'$."observatory.record_id"'))
|
|
318
|
+
WHERE scope_name='observatory.local_agents';
|
|
319
|
+
CREATE TABLE IF NOT EXISTS metric_points (
|
|
320
|
+
digest TEXT PRIMARY KEY, time_ns INTEGER NOT NULL,
|
|
321
|
+
start_ns INTEGER NOT NULL, service TEXT NOT NULL,
|
|
322
|
+
name TEXT NOT NULL, description TEXT NOT NULL,
|
|
323
|
+
unit TEXT NOT NULL, kind TEXT NOT NULL,
|
|
324
|
+
temporality INTEGER NOT NULL, attributes TEXT NOT NULL,
|
|
325
|
+
resource_attributes TEXT NOT NULL, scope_name TEXT NOT NULL,
|
|
326
|
+
data TEXT NOT NULL);
|
|
327
|
+
CREATE INDEX IF NOT EXISTS metric_points_time ON metric_points(time_ns DESC);
|
|
328
|
+
CREATE TABLE IF NOT EXISTS outcomes (
|
|
329
|
+
trace_id TEXT PRIMARY KEY, task_id TEXT NOT NULL,
|
|
330
|
+
passed INTEGER NOT NULL, scores TEXT NOT NULL,
|
|
331
|
+
error TEXT NOT NULL, provider_model TEXT NOT NULL,
|
|
332
|
+
source TEXT NOT NULL, details TEXT NOT NULL,
|
|
333
|
+
ingested_ns INTEGER NOT NULL);
|
|
334
|
+
CREATE TABLE IF NOT EXISTS trace_activity (
|
|
335
|
+
trace_id TEXT PRIMARY KEY, last_ns INTEGER NOT NULL);
|
|
336
|
+
CREATE INDEX IF NOT EXISTS trace_activity_time ON trace_activity(last_ns DESC,trace_id DESC);
|
|
337
|
+
CREATE TABLE IF NOT EXISTS endpoints (
|
|
338
|
+
endpoint_id TEXT PRIMARY KEY, identity_key TEXT NOT NULL,
|
|
339
|
+
identity_value TEXT NOT NULL, resource_attributes TEXT NOT NULL,
|
|
340
|
+
last_ns INTEGER NOT NULL);
|
|
341
|
+
CREATE TABLE IF NOT EXISTS endpoint_services (
|
|
342
|
+
endpoint_id TEXT NOT NULL, service TEXT NOT NULL, last_ns INTEGER NOT NULL,
|
|
343
|
+
PRIMARY KEY(endpoint_id,service));
|
|
344
|
+
CREATE TABLE IF NOT EXISTS endpoint_traces (
|
|
345
|
+
endpoint_id TEXT NOT NULL, trace_id TEXT NOT NULL, last_ns INTEGER NOT NULL,
|
|
346
|
+
PRIMARY KEY(endpoint_id,trace_id));
|
|
347
|
+
CREATE INDEX IF NOT EXISTS endpoint_traces_recent ON endpoint_traces(endpoint_id,last_ns DESC);
|
|
348
|
+
CREATE TABLE IF NOT EXISTS trace_summaries (
|
|
349
|
+
trace_id TEXT PRIMARY KEY, session_id TEXT, summary_json TEXT NOT NULL,
|
|
350
|
+
activity_json TEXT NOT NULL DEFAULT '[]');
|
|
351
|
+
CREATE TABLE IF NOT EXISTS finding_cache (
|
|
352
|
+
finding_id TEXT PRIMARY KEY, trace_id TEXT NOT NULL,
|
|
353
|
+
time_ns INTEGER NOT NULL, finding_json TEXT NOT NULL);
|
|
354
|
+
CREATE INDEX IF NOT EXISTS finding_cache_trace ON finding_cache(trace_id,time_ns DESC);
|
|
355
|
+
CREATE INDEX IF NOT EXISTS finding_cache_time ON finding_cache(time_ns DESC,finding_id DESC);
|
|
356
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
357
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
358
|
+
""")
|
|
359
|
+
columns = {row[1] for row in db.execute("PRAGMA table_info(spans)")}
|
|
360
|
+
for name, declaration in (("links", "TEXT NOT NULL DEFAULT '[]'"),
|
|
361
|
+
("resource_attributes", "TEXT NOT NULL DEFAULT '{}'"),
|
|
362
|
+
("scope_name", "TEXT NOT NULL DEFAULT ''")):
|
|
363
|
+
if name not in columns:
|
|
364
|
+
db.execute(f"ALTER TABLE spans ADD COLUMN {name} {declaration}")
|
|
365
|
+
for table, name in (("progress", "session_id"), ("progress", "service"), ("trace_summaries", "session_id")):
|
|
366
|
+
table_columns = {row[1] for row in db.execute(f"PRAGMA table_info({table})")}
|
|
367
|
+
if name not in table_columns:
|
|
368
|
+
db.execute(f"ALTER TABLE {table} ADD COLUMN {name} TEXT")
|
|
369
|
+
summary_columns = {row[1] for row in db.execute("PRAGMA table_info(trace_summaries)")}
|
|
370
|
+
if "activity_json" not in summary_columns:
|
|
371
|
+
db.execute("ALTER TABLE trace_summaries ADD COLUMN activity_json TEXT NOT NULL DEFAULT '[]'")
|
|
372
|
+
db.execute("CREATE INDEX IF NOT EXISTS trace_summaries_session ON trace_summaries(session_id,trace_id)")
|
|
373
|
+
if not db.execute("SELECT 1 FROM schema_meta WHERE key='trace_activity_v1'").fetchone():
|
|
374
|
+
db.execute("""INSERT INTO trace_activity(trace_id,last_ns)
|
|
375
|
+
SELECT trace_id,MAX(activity_ns) FROM (
|
|
376
|
+
SELECT trace_id,end_ns AS activity_ns FROM spans
|
|
377
|
+
UNION ALL SELECT trace_id,time_ns FROM progress
|
|
378
|
+
UNION ALL SELECT trace_id,time_ns FROM logs WHERE trace_id IS NOT NULL
|
|
379
|
+
UNION ALL SELECT trace_id,ingested_ns FROM outcomes
|
|
380
|
+
) GROUP BY trace_id""")
|
|
381
|
+
db.execute("INSERT INTO schema_meta(key,value) VALUES('trace_activity_v1','1')")
|
|
382
|
+
os.chmod(self.path, 0o600)
|
|
383
|
+
self._backfill_summaries()
|
|
384
|
+
self._backfill_endpoints()
|
|
385
|
+
if self._async_derived_refresh:
|
|
386
|
+
self._derived_thread = threading.Thread(target=self._derived_refresh_worker,
|
|
387
|
+
name="observatory-derived-refresh", daemon=True)
|
|
388
|
+
self._derived_thread.start()
|
|
389
|
+
|
|
390
|
+
def _connect(self):
|
|
391
|
+
db = sqlite3.connect(self.path, timeout=10)
|
|
392
|
+
db.row_factory = sqlite3.Row
|
|
393
|
+
return db
|
|
394
|
+
|
|
395
|
+
def register_connection(self, record):
|
|
396
|
+
source_id = _id(record.get("source_id"), TRACE_ID, "source_id")
|
|
397
|
+
service = record.get("service")
|
|
398
|
+
integrations = record.get("integrations")
|
|
399
|
+
runtime = record.get("runtime")
|
|
400
|
+
state = record.get("state", "running")
|
|
401
|
+
capture_content = record.get("capture_content", False)
|
|
402
|
+
process_id = record.get("process_id")
|
|
403
|
+
if not isinstance(service, str) or not 1 <= len(service.strip()) <= 128:
|
|
404
|
+
raise ValueError("service must contain 1 to 128 characters")
|
|
405
|
+
if not isinstance(runtime, str) or not 1 <= len(runtime) <= 80:
|
|
406
|
+
raise ValueError("invalid runtime")
|
|
407
|
+
if (not isinstance(integrations, list) or not 1 <= len(integrations) <= 20 or
|
|
408
|
+
any(not isinstance(item, str) or not 1 <= len(item) <= 80 for item in integrations)):
|
|
409
|
+
raise ValueError("invalid integrations")
|
|
410
|
+
if state not in {"running", "stopped"} or not isinstance(capture_content, bool):
|
|
411
|
+
raise ValueError("invalid connection state")
|
|
412
|
+
if process_id is not None and (isinstance(process_id, bool) or not isinstance(process_id, int) or not 0 < process_id < 2**31):
|
|
413
|
+
raise ValueError("invalid process_id")
|
|
414
|
+
now = time.time_ns()
|
|
415
|
+
with self._connect() as db:
|
|
416
|
+
db.execute("""INSERT INTO agent_connections VALUES(?,?,?,?,?,?,?,?,?)
|
|
417
|
+
ON CONFLICT(source_id) DO UPDATE SET last_seen_ns=excluded.last_seen_ns,
|
|
418
|
+
state=excluded.state,service=excluded.service,runtime=excluded.runtime,integrations=excluded.integrations,
|
|
419
|
+
capture_content=excluded.capture_content,process_id=excluded.process_id""", (source_id, service.strip(), runtime,
|
|
420
|
+
json.dumps(integrations), int(capture_content), process_id, now, now, state))
|
|
421
|
+
|
|
422
|
+
def redact_internal_source(self, source_id):
|
|
423
|
+
"""Keep internal approval reviews as metadata, including older captures."""
|
|
424
|
+
source_id = _id(source_id, TRACE_ID, "source_id")
|
|
425
|
+
affected = set()
|
|
426
|
+
with self._connect() as db:
|
|
427
|
+
rows = db.execute("SELECT digest,trace_id,attributes FROM logs WHERE instr(resource_attributes,?)>0", (source_id,)).fetchall()
|
|
428
|
+
for row in rows:
|
|
429
|
+
attrs = json.loads(row["attributes"])
|
|
430
|
+
attrs["agent.activity.subject"] = "Approval review"
|
|
431
|
+
db.execute("UPDATE logs SET body=?,attributes=?,service='codex-review' WHERE digest=?",
|
|
432
|
+
(json.dumps(""), json.dumps(attrs), row["digest"]))
|
|
433
|
+
if row["trace_id"]:
|
|
434
|
+
affected.add(row["trace_id"])
|
|
435
|
+
rows = db.execute("SELECT trace_id,span_id,attributes,name FROM spans WHERE instr(resource_attributes,?)>0", (source_id,)).fetchall()
|
|
436
|
+
for row in rows:
|
|
437
|
+
attrs = json.loads(row["attributes"])
|
|
438
|
+
if row["name"] == "agent.run":
|
|
439
|
+
attrs["agent.task"] = "Approval review"
|
|
440
|
+
db.execute("UPDATE spans SET attributes=?,service='codex-review' WHERE trace_id=? AND span_id=?",
|
|
441
|
+
(json.dumps(attrs), row["trace_id"], row["span_id"]))
|
|
442
|
+
affected.add(row["trace_id"])
|
|
443
|
+
if affected:
|
|
444
|
+
self._refresh_after_write(affected)
|
|
445
|
+
|
|
446
|
+
def connections(self):
|
|
447
|
+
now = time.time_ns()
|
|
448
|
+
with self._connect() as db:
|
|
449
|
+
rows = db.execute("""SELECT c.*,
|
|
450
|
+
(SELECT COUNT(*) FROM spans s WHERE s.service=c.service
|
|
451
|
+
AND instr(s.resource_attributes,c.source_id)>0) AS span_count
|
|
452
|
+
FROM agent_connections c ORDER BY c.last_seen_ns DESC LIMIT 50""").fetchall()
|
|
453
|
+
return [{"source_id": r["source_id"], "service": r["service"], "runtime": r["runtime"],
|
|
454
|
+
"integrations": json.loads(r["integrations"]), "capture_content": bool(r["capture_content"]),
|
|
455
|
+
"process_id": r["process_id"], "started_ns": r["started_ns"], "last_seen_ns": r["last_seen_ns"],
|
|
456
|
+
"state": "offline" if r["state"] == "running" and now-r["last_seen_ns"]>35_000_000_000 else r["state"],
|
|
457
|
+
"span_count": r["span_count"]} for r in rows]
|
|
458
|
+
|
|
459
|
+
def _backfill_summaries(self):
|
|
460
|
+
with self._connect() as db:
|
|
461
|
+
complete = db.execute("SELECT 1 FROM schema_meta WHERE key='trace_derived_v9'").fetchone()
|
|
462
|
+
if not complete:
|
|
463
|
+
before = None
|
|
464
|
+
while True:
|
|
465
|
+
sql = "SELECT trace_id,last_ns FROM trace_activity"
|
|
466
|
+
params = []
|
|
467
|
+
if before:
|
|
468
|
+
sql += " WHERE last_ns < ? OR (last_ns = ? AND trace_id < ?)"
|
|
469
|
+
params.extend((before[0], before[0], before[1]))
|
|
470
|
+
sql += " ORDER BY last_ns DESC,trace_id DESC LIMIT 100"
|
|
471
|
+
with self._connect() as db:
|
|
472
|
+
rows = db.execute(sql, params).fetchall()
|
|
473
|
+
if not rows:
|
|
474
|
+
break
|
|
475
|
+
self._refresh_summaries(row["trace_id"] for row in rows)
|
|
476
|
+
before = (rows[-1]["last_ns"], rows[-1]["trace_id"])
|
|
477
|
+
if len(rows) < 100:
|
|
478
|
+
break
|
|
479
|
+
if not complete:
|
|
480
|
+
with self._connect() as db:
|
|
481
|
+
db.execute("INSERT OR REPLACE INTO schema_meta(key,value) VALUES('trace_derived_v9','1')")
|
|
482
|
+
|
|
483
|
+
def _backfill_endpoints(self):
|
|
484
|
+
with self._connect() as db:
|
|
485
|
+
complete = db.execute("SELECT 1 FROM schema_meta WHERE key='endpoint_inventory_v1'").fetchone()
|
|
486
|
+
if complete:
|
|
487
|
+
return
|
|
488
|
+
queries = (
|
|
489
|
+
"SELECT trace_id,service,resource_attributes,MAX(end_ns) AS time_unix_nano "
|
|
490
|
+
"FROM spans GROUP BY trace_id,service,resource_attributes",
|
|
491
|
+
"SELECT trace_id,service,resource_attributes,MAX(time_ns) AS time_unix_nano "
|
|
492
|
+
"FROM logs GROUP BY trace_id,service,resource_attributes",
|
|
493
|
+
"SELECT NULL AS trace_id,service,resource_attributes,MAX(time_ns) AS time_unix_nano "
|
|
494
|
+
"FROM metric_points GROUP BY service,resource_attributes",
|
|
495
|
+
)
|
|
496
|
+
for query in queries:
|
|
497
|
+
with self._connect() as db:
|
|
498
|
+
cursor = db.execute(query)
|
|
499
|
+
while True:
|
|
500
|
+
rows = cursor.fetchmany(500)
|
|
501
|
+
if not rows:
|
|
502
|
+
break
|
|
503
|
+
records = [{"trace_id": row["trace_id"], "service": row["service"],
|
|
504
|
+
"resource_attributes": json.loads(row["resource_attributes"]),
|
|
505
|
+
"time_unix_nano": row["time_unix_nano"]}
|
|
506
|
+
for row in rows]
|
|
507
|
+
self._update_endpoints(db, records)
|
|
508
|
+
with self._connect() as db:
|
|
509
|
+
db.execute("INSERT OR REPLACE INTO schema_meta(key,value) VALUES('endpoint_inventory_v1','1')")
|
|
510
|
+
|
|
511
|
+
def _refresh_summaries(self, trace_ids):
|
|
512
|
+
updates = []
|
|
513
|
+
findings = []
|
|
514
|
+
replaced = []
|
|
515
|
+
for trace_id in set(trace_ids):
|
|
516
|
+
trace = self.trace(trace_id)
|
|
517
|
+
summary = trace["summary"]
|
|
518
|
+
if summary is None:
|
|
519
|
+
continue
|
|
520
|
+
session_id = summary.get("session_id")
|
|
521
|
+
updates.append((trace_id, session_id, json.dumps(summary, separators=(",", ":")),
|
|
522
|
+
json.dumps(trace["activity"], separators=(",", ":"))))
|
|
523
|
+
replaced.append((trace_id,))
|
|
524
|
+
evidence_logs = self._finding_logs(trace_id)
|
|
525
|
+
timestamp = max([span["end_unix_nano"] for span in trace["spans"]] +
|
|
526
|
+
[log["time_unix_nano"] for log in evidence_logs] +
|
|
527
|
+
([trace["outcome"]["ingested_ns"]] if trace["outcome"] else []),
|
|
528
|
+
default=summary["end_unix_nano"])
|
|
529
|
+
findings.extend((item["id"], trace_id, timestamp,
|
|
530
|
+
json.dumps({**item, "time_unix_nano": timestamp}, separators=(",", ":")))
|
|
531
|
+
for item in analyze(trace["spans"], evidence_logs, trace["outcome"]))
|
|
532
|
+
if updates or replaced:
|
|
533
|
+
with self._connect() as db:
|
|
534
|
+
if updates:
|
|
535
|
+
db.executemany("INSERT INTO trace_summaries(trace_id,session_id,summary_json,activity_json) VALUES(?,?,?,?) "
|
|
536
|
+
"ON CONFLICT(trace_id) DO UPDATE SET session_id=excluded.session_id,"
|
|
537
|
+
"summary_json=excluded.summary_json,activity_json=excluded.activity_json",
|
|
538
|
+
updates)
|
|
539
|
+
if replaced:
|
|
540
|
+
db.executemany("DELETE FROM finding_cache WHERE trace_id=?", replaced)
|
|
541
|
+
db.executemany("INSERT INTO finding_cache(finding_id,trace_id,time_ns,finding_json) "
|
|
542
|
+
"VALUES(?,?,?,?)", findings)
|
|
543
|
+
|
|
544
|
+
def _refresh_after_write(self, trace_ids):
|
|
545
|
+
trace_ids = set(trace_ids)
|
|
546
|
+
if not trace_ids:
|
|
547
|
+
return
|
|
548
|
+
if not self._async_derived_refresh or self._derived_thread is None:
|
|
549
|
+
self._refresh_summaries(trace_ids)
|
|
550
|
+
return
|
|
551
|
+
now = time.monotonic()
|
|
552
|
+
with self._derived_condition:
|
|
553
|
+
for trace_id in trace_ids:
|
|
554
|
+
pending = self._derived_pending.get(trace_id)
|
|
555
|
+
self._derived_pending[trace_id] = (pending[0] if pending else now, now,
|
|
556
|
+
pending[2] if pending else 0)
|
|
557
|
+
self._derived_condition.notify()
|
|
558
|
+
|
|
559
|
+
def _derived_refresh_worker(self):
|
|
560
|
+
quiet_period = 0.25
|
|
561
|
+
max_delay = 1.0
|
|
562
|
+
while True:
|
|
563
|
+
with self._derived_condition:
|
|
564
|
+
while not self._derived_pending and not self._derived_stopping:
|
|
565
|
+
self._derived_condition.wait()
|
|
566
|
+
if self._derived_stopping and not self._derived_pending:
|
|
567
|
+
return
|
|
568
|
+
now = time.monotonic()
|
|
569
|
+
ready = [trace_id for trace_id, (first, last, _) in self._derived_pending.items()
|
|
570
|
+
if self._derived_stopping or now - last >= quiet_period or now - first >= max_delay]
|
|
571
|
+
if not ready:
|
|
572
|
+
wait_for = min(min(quiet_period - (now - last), max_delay - (now - first))
|
|
573
|
+
for first, last, _ in self._derived_pending.values())
|
|
574
|
+
self._derived_condition.wait(timeout=max(0.01, wait_for))
|
|
575
|
+
continue
|
|
576
|
+
pending = {trace_id: self._derived_pending.pop(trace_id) for trace_id in ready}
|
|
577
|
+
try:
|
|
578
|
+
self._refresh_summaries(ready)
|
|
579
|
+
self.last_derived_error = None
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
self.last_derived_error = str(exc)
|
|
582
|
+
if not self._derived_stopping:
|
|
583
|
+
now = time.monotonic()
|
|
584
|
+
with self._derived_condition:
|
|
585
|
+
for trace_id, (first, _, attempts) in pending.items():
|
|
586
|
+
if attempts < 4 and trace_id not in self._derived_pending:
|
|
587
|
+
self._derived_pending[trace_id] = (first, now, attempts + 1)
|
|
588
|
+
self._derived_condition.notify()
|
|
589
|
+
|
|
590
|
+
def close(self):
|
|
591
|
+
if self._derived_thread is None:
|
|
592
|
+
return
|
|
593
|
+
with self._derived_condition:
|
|
594
|
+
self._derived_stopping = True
|
|
595
|
+
self._derived_condition.notify_all()
|
|
596
|
+
self._derived_thread.join()
|
|
597
|
+
|
|
598
|
+
def _finding_logs(self, trace_id):
|
|
599
|
+
with self._connect() as db:
|
|
600
|
+
rows = db.execute("SELECT digest,trace_id,time_ns,severity_number,body,event_name,attributes FROM logs "
|
|
601
|
+
"WHERE trace_id=? "
|
|
602
|
+
"ORDER BY time_ns",
|
|
603
|
+
(trace_id,)).fetchall()
|
|
604
|
+
return [{"id": row["digest"], "trace_id": row["trace_id"],
|
|
605
|
+
"time_unix_nano": row["time_ns"], "severity_number": row["severity_number"],
|
|
606
|
+
"body": json.loads(row["body"]), "event_name": row["event_name"],
|
|
607
|
+
"attributes": json.loads(row["attributes"])} for row in rows]
|
|
608
|
+
|
|
609
|
+
@staticmethod
|
|
610
|
+
def _update_endpoints(db, records):
|
|
611
|
+
endpoints = {}
|
|
612
|
+
services = {}
|
|
613
|
+
traces = {}
|
|
614
|
+
for record in records:
|
|
615
|
+
resource = record.get("resource_attributes") or {}
|
|
616
|
+
identity = _endpoint_identity(resource)
|
|
617
|
+
if identity is None:
|
|
618
|
+
continue
|
|
619
|
+
identity_key, identity_value = identity
|
|
620
|
+
endpoint_id = hashlib.sha256(f"{identity_key}\0{identity_value}".encode()).hexdigest()[:32]
|
|
621
|
+
timestamp = int(record.get("end_unix_nano") or record.get("time_unix_nano") or
|
|
622
|
+
record.get("start_unix_nano") or time.time_ns())
|
|
623
|
+
attributes = {key: value for key, value in resource.items()
|
|
624
|
+
if key in ENDPOINT_RESOURCE_KEYS and isinstance(value, (str, int, float, bool))}
|
|
625
|
+
current = endpoints.get(endpoint_id)
|
|
626
|
+
if current is None or timestamp >= current[3]:
|
|
627
|
+
endpoints[endpoint_id] = (endpoint_id, identity_key, identity_value,
|
|
628
|
+
timestamp, json.dumps(attributes, separators=(",", ":")))
|
|
629
|
+
service = str(record.get("service") or "unknown")
|
|
630
|
+
services[(endpoint_id, service)] = max(timestamp, services.get((endpoint_id, service), 0))
|
|
631
|
+
trace_id = record.get("trace_id")
|
|
632
|
+
if trace_id:
|
|
633
|
+
traces[(endpoint_id, str(trace_id))] = max(timestamp, traces.get((endpoint_id, str(trace_id)), 0))
|
|
634
|
+
if endpoints:
|
|
635
|
+
db.executemany("""INSERT INTO endpoints(endpoint_id,identity_key,identity_value,last_ns,resource_attributes)
|
|
636
|
+
VALUES(?,?,?,?,?) ON CONFLICT(endpoint_id) DO UPDATE SET
|
|
637
|
+
last_ns=MAX(last_ns,excluded.last_ns),
|
|
638
|
+
resource_attributes=CASE WHEN excluded.last_ns>=last_ns
|
|
639
|
+
THEN excluded.resource_attributes ELSE resource_attributes END""",
|
|
640
|
+
[(key, identity_key, identity_value, last_ns, attrs)
|
|
641
|
+
for key, identity_key, identity_value, last_ns, attrs in endpoints.values()])
|
|
642
|
+
db.executemany("""INSERT INTO endpoint_services(endpoint_id,service,last_ns) VALUES(?,?,?)
|
|
643
|
+
ON CONFLICT(endpoint_id,service) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
644
|
+
[(endpoint_id, service, last_ns)
|
|
645
|
+
for (endpoint_id, service), last_ns in services.items()])
|
|
646
|
+
db.executemany("""INSERT INTO endpoint_traces(endpoint_id,trace_id,last_ns) VALUES(?,?,?)
|
|
647
|
+
ON CONFLICT(endpoint_id,trace_id) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
648
|
+
[(endpoint_id, trace_id, last_ns)
|
|
649
|
+
for (endpoint_id, trace_id), last_ns in traces.items()])
|
|
650
|
+
|
|
651
|
+
def endpoints(self, limit=100):
|
|
652
|
+
if not 1 <= int(limit) <= 500:
|
|
653
|
+
raise ValueError("limit must be between 1 and 500")
|
|
654
|
+
with self._connect() as db:
|
|
655
|
+
rows = db.execute("""SELECT e.*,
|
|
656
|
+
(SELECT COUNT(*) FROM endpoint_traces t WHERE t.endpoint_id=e.endpoint_id) AS trace_count,
|
|
657
|
+
(SELECT trace_id FROM endpoint_traces t WHERE t.endpoint_id=e.endpoint_id
|
|
658
|
+
ORDER BY last_ns DESC,trace_id DESC LIMIT 1) AS latest_trace_id
|
|
659
|
+
FROM endpoints e ORDER BY e.last_ns DESC,e.endpoint_id LIMIT ?""", (int(limit),)).fetchall()
|
|
660
|
+
endpoint_ids = [row["endpoint_id"] for row in rows]
|
|
661
|
+
services = (db.execute("SELECT endpoint_id,service FROM endpoint_services WHERE endpoint_id IN (" +
|
|
662
|
+
",".join("?" for _ in endpoint_ids) + ") ORDER BY service", endpoint_ids).fetchall()
|
|
663
|
+
if endpoint_ids else [])
|
|
664
|
+
service_map = {}
|
|
665
|
+
for row in services:
|
|
666
|
+
service_map.setdefault(row["endpoint_id"], []).append(row["service"])
|
|
667
|
+
result = []
|
|
668
|
+
for row in rows:
|
|
669
|
+
attrs = json.loads(row["resource_attributes"])
|
|
670
|
+
result.append({"endpoint_id": row["endpoint_id"],
|
|
671
|
+
"name": attrs.get("host.name") or attrs.get("device.id") or row["identity_value"],
|
|
672
|
+
"identity_key": row["identity_key"],
|
|
673
|
+
"identity_value": row["identity_value"],
|
|
674
|
+
"os_type": attrs.get("os.type") or attrs.get("os.name") or "",
|
|
675
|
+
"os_version": attrs.get("os.version") or "",
|
|
676
|
+
"architecture": attrs.get("host.arch") or "",
|
|
677
|
+
"user": attrs.get("user.name") or attrs.get("user.id") or "",
|
|
678
|
+
"environment": attrs.get("deployment.environment.name") or "",
|
|
679
|
+
"cloud_region": attrs.get("cloud.region") or "",
|
|
680
|
+
"services": service_map.get(row["endpoint_id"], []),
|
|
681
|
+
"trace_count": row["trace_count"],
|
|
682
|
+
"latest_trace_id": row["latest_trace_id"],
|
|
683
|
+
"last_seen_unix_nano": row["last_ns"]})
|
|
684
|
+
return result
|
|
685
|
+
|
|
686
|
+
def usage_series(self, days=7, step="day", service=None):
|
|
687
|
+
days = int(days)
|
|
688
|
+
if not 1 <= days <= 90:
|
|
689
|
+
raise ValueError("days must be between 1 and 90")
|
|
690
|
+
if step not in {"hour", "day"}:
|
|
691
|
+
raise ValueError("step must be hour or day")
|
|
692
|
+
step_ns = 3_600_000_000_000 if step == "hour" else 86_400_000_000_000
|
|
693
|
+
now_ns = time.time_ns()
|
|
694
|
+
cutoff_ns = now_ns - days * 86_400_000_000_000
|
|
695
|
+
first_bucket = cutoff_ns // step_ns * step_ns
|
|
696
|
+
last_bucket = now_ns // step_ns * step_ns
|
|
697
|
+
buckets = {bucket: {"start_unix_nano": bucket, "runs": 0, "errors": 0,
|
|
698
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
699
|
+
"known_cost_usd": 0.0, "unknown_cost_runs": 0}
|
|
700
|
+
for bucket in range(first_bucket, last_bucket + 1, step_ns)}
|
|
701
|
+
with self._connect() as db:
|
|
702
|
+
rows = db.execute("""SELECT s.summary_json FROM trace_summaries s
|
|
703
|
+
JOIN trace_activity a USING(trace_id) WHERE a.last_ns>=?""", (cutoff_ns,)).fetchall()
|
|
704
|
+
for row in rows:
|
|
705
|
+
summary = json.loads(row["summary_json"])
|
|
706
|
+
if service and service not in summary.get("services", []):
|
|
707
|
+
continue
|
|
708
|
+
end_ns = int(summary.get("end_unix_nano") or 0)
|
|
709
|
+
if end_ns < cutoff_ns or end_ns > now_ns:
|
|
710
|
+
continue
|
|
711
|
+
bucket = end_ns // step_ns * step_ns
|
|
712
|
+
item = buckets.get(bucket)
|
|
713
|
+
if item is None:
|
|
714
|
+
continue
|
|
715
|
+
item["runs"] += 1
|
|
716
|
+
item["errors"] += summary.get("status") in {"ERROR", "FAILED"}
|
|
717
|
+
item["input_tokens"] += int(summary.get("input_tokens") or 0)
|
|
718
|
+
item["output_tokens"] += int(summary.get("output_tokens") or 0)
|
|
719
|
+
item["known_cost_usd"] += float(summary.get("known_cost_usd") or 0)
|
|
720
|
+
if summary.get("models") and not summary.get("cost_complete", False):
|
|
721
|
+
item["unknown_cost_runs"] += 1
|
|
722
|
+
points = list(buckets.values())
|
|
723
|
+
for item in points:
|
|
724
|
+
item["known_cost_usd"] = round(item["known_cost_usd"], 8)
|
|
725
|
+
return {"days": days, "step": step, "service": service,
|
|
726
|
+
"points": points}
|
|
727
|
+
|
|
728
|
+
def ingest(self, spans):
|
|
729
|
+
spans = list(spans)
|
|
730
|
+
with self._connect() as db:
|
|
731
|
+
db.executemany("""INSERT INTO spans
|
|
732
|
+
(trace_id,span_id,parent_span_id,name,start_ns,end_ns,status,service,
|
|
733
|
+
attributes,events,links,resource_attributes,scope_name)
|
|
734
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
735
|
+
ON CONFLICT(trace_id,span_id) DO UPDATE SET
|
|
736
|
+
parent_span_id=excluded.parent_span_id, name=excluded.name,
|
|
737
|
+
start_ns=excluded.start_ns, end_ns=excluded.end_ns,
|
|
738
|
+
status=excluded.status, service=excluded.service,
|
|
739
|
+
attributes=excluded.attributes, events=excluded.events,
|
|
740
|
+
links=excluded.links, resource_attributes=excluded.resource_attributes,
|
|
741
|
+
scope_name=excluded.scope_name""",
|
|
742
|
+
[(s["trace_id"], s["span_id"], s["parent_span_id"], s["name"],
|
|
743
|
+
s["start_unix_nano"], s["end_unix_nano"], s["status"], s["service"],
|
|
744
|
+
json.dumps(s["attributes"]), json.dumps(s["events"]),
|
|
745
|
+
json.dumps(s.get("links", [])), json.dumps(s.get("resource_attributes", {})),
|
|
746
|
+
s.get("scope_name", "")) for s in spans])
|
|
747
|
+
db.executemany("""INSERT INTO trace_activity(trace_id,last_ns) VALUES(?,?)
|
|
748
|
+
ON CONFLICT(trace_id) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
749
|
+
[(s["trace_id"], s["end_unix_nano"]) for s in spans])
|
|
750
|
+
self._update_endpoints(db, spans)
|
|
751
|
+
self._refresh_after_write(s["trace_id"] for s in spans)
|
|
752
|
+
return len(spans)
|
|
753
|
+
|
|
754
|
+
def add_progress(self, trace_id, stage, message, percent=None, session_id=None, service=None):
|
|
755
|
+
trace_id = _id(trace_id, TRACE_ID, "trace_id")
|
|
756
|
+
if service is not None and (not isinstance(service, str) or not 1 <= len(service.strip()) <= 128):
|
|
757
|
+
raise ValueError("service must contain 1 to 128 characters")
|
|
758
|
+
if percent is not None:
|
|
759
|
+
try:
|
|
760
|
+
if isinstance(percent, bool):
|
|
761
|
+
raise ValueError("percent must be numeric")
|
|
762
|
+
percent = float(percent)
|
|
763
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
764
|
+
raise ValueError("percent must be between 0 and 100") from exc
|
|
765
|
+
if not math.isfinite(percent) or not 0 <= percent <= 100:
|
|
766
|
+
raise ValueError("percent must be between 0 and 100")
|
|
767
|
+
session_id = _session_value(session_id)
|
|
768
|
+
with self._connect() as db:
|
|
769
|
+
timestamp = time.time_ns()
|
|
770
|
+
db.execute("INSERT INTO progress(trace_id,time_ns,stage,message,percent,session_id,service) VALUES(?,?,?,?,?,?,?)",
|
|
771
|
+
(trace_id, timestamp, str(stage), str(message), percent, session_id, service.strip() if service else None))
|
|
772
|
+
self._touch_activity(db, trace_id, timestamp)
|
|
773
|
+
self._refresh_after_write([trace_id])
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def _touch_activity(db, trace_id, timestamp):
|
|
777
|
+
db.execute("""INSERT INTO trace_activity(trace_id,last_ns) VALUES(?,?)
|
|
778
|
+
ON CONFLICT(trace_id) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
779
|
+
(trace_id, timestamp))
|
|
780
|
+
|
|
781
|
+
def prune(self, retention_days, now_ns=None):
|
|
782
|
+
"""Delete complete old traces and uncorrelated telemetry before a cutoff."""
|
|
783
|
+
if retention_days is None or not math.isfinite(float(retention_days)) or float(retention_days) <= 0:
|
|
784
|
+
raise ValueError("retention_days must be positive")
|
|
785
|
+
now_ns = time.time_ns() if now_ns is None else int(now_ns)
|
|
786
|
+
cutoff_ns = now_ns - int(float(retention_days) * 86_400_000_000_000)
|
|
787
|
+
deleted = {}
|
|
788
|
+
with self._connect() as db:
|
|
789
|
+
db.execute("BEGIN IMMEDIATE")
|
|
790
|
+
stale = """WITH activity(trace_id,activity_ns) AS (
|
|
791
|
+
SELECT trace_id,end_ns FROM spans
|
|
792
|
+
UNION ALL SELECT trace_id,time_ns FROM progress
|
|
793
|
+
UNION ALL SELECT trace_id,time_ns FROM logs WHERE trace_id IS NOT NULL
|
|
794
|
+
UNION ALL SELECT trace_id,ingested_ns FROM outcomes
|
|
795
|
+
) SELECT trace_id FROM activity GROUP BY trace_id HAVING MAX(activity_ns) < ?"""
|
|
796
|
+
db.execute("CREATE TEMP TABLE stale_traces(trace_id TEXT PRIMARY KEY)")
|
|
797
|
+
db.execute(f"INSERT INTO stale_traces {stale}", (cutoff_ns,))
|
|
798
|
+
for table in ("spans", "progress", "logs", "outcomes", "endpoint_traces"):
|
|
799
|
+
cursor = db.execute(f"DELETE FROM {table} WHERE trace_id IN (SELECT trace_id FROM stale_traces)")
|
|
800
|
+
deleted[table] = cursor.rowcount
|
|
801
|
+
deleted["trace_activity"] = db.execute(
|
|
802
|
+
"DELETE FROM trace_activity WHERE trace_id IN (SELECT trace_id FROM stale_traces)").rowcount
|
|
803
|
+
deleted["trace_summaries"] = db.execute(
|
|
804
|
+
"DELETE FROM trace_summaries WHERE trace_id IN (SELECT trace_id FROM stale_traces)").rowcount
|
|
805
|
+
deleted["findings"] = db.execute(
|
|
806
|
+
"DELETE FROM finding_cache WHERE trace_id IN (SELECT trace_id FROM stale_traces)").rowcount
|
|
807
|
+
cursor = db.execute("DELETE FROM logs WHERE trace_id IS NULL AND time_ns < ?", (cutoff_ns,))
|
|
808
|
+
deleted["unlinked_logs"] = cursor.rowcount
|
|
809
|
+
cursor = db.execute("DELETE FROM metric_points WHERE time_ns < ?", (cutoff_ns,))
|
|
810
|
+
deleted["metric_points"] = cursor.rowcount
|
|
811
|
+
cursor = db.execute("DELETE FROM endpoint_services WHERE last_ns < ?", (cutoff_ns,))
|
|
812
|
+
deleted["endpoint_services"] = cursor.rowcount
|
|
813
|
+
cursor = db.execute("DELETE FROM endpoints WHERE last_ns < ?", (cutoff_ns,))
|
|
814
|
+
deleted["endpoints"] = cursor.rowcount
|
|
815
|
+
cursor = db.execute("DELETE FROM agent_connections WHERE last_seen_ns < ?", (cutoff_ns,))
|
|
816
|
+
deleted["agent_connections"] = cursor.rowcount
|
|
817
|
+
deleted["cutoff_unix_nano"] = cutoff_ns
|
|
818
|
+
return deleted
|
|
819
|
+
|
|
820
|
+
def ingest_logs(self, records):
|
|
821
|
+
records = list(records)
|
|
822
|
+
rows = []
|
|
823
|
+
native_ids = []
|
|
824
|
+
for log in records:
|
|
825
|
+
canonical = json.dumps(log, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
826
|
+
digest = hashlib.sha256(canonical.encode()).hexdigest()
|
|
827
|
+
record_id = log['attributes'].get('observatory.record_id')
|
|
828
|
+
if log['scope_name']=='observatory.local_agents' and isinstance(record_id,str) and TRACE_ID.fullmatch(record_id):
|
|
829
|
+
digest = hashlib.sha256(('local-session:'+record_id).encode()).hexdigest()
|
|
830
|
+
native_ids.append((record_id,digest))
|
|
831
|
+
rows.append((digest, log["trace_id"], log["span_id"], log["time_unix_nano"],
|
|
832
|
+
log["service"], log["severity_number"], log["severity_text"],
|
|
833
|
+
log["event_name"], json.dumps(log["body"]),
|
|
834
|
+
json.dumps(log["attributes"]), json.dumps(log["resource_attributes"]),
|
|
835
|
+
log["scope_name"]))
|
|
836
|
+
with self._connect() as db:
|
|
837
|
+
db.executemany("""DELETE FROM logs WHERE scope_name='observatory.local_agents'
|
|
838
|
+
AND json_extract(attributes,'$."observatory.record_id"')=? AND digest<>?""", native_ids)
|
|
839
|
+
for log in records:
|
|
840
|
+
attrs=log['attributes']
|
|
841
|
+
if log['scope_name']=='observatory.local_agents' and log.get('span_id') and attrs.get('mcp.server.name'):
|
|
842
|
+
db.execute("""DELETE FROM logs WHERE scope_name='observatory.local_agents'
|
|
843
|
+
AND trace_id=? AND time_ns=? AND span_id IS NULL
|
|
844
|
+
AND json_extract(attributes,'$."mcp.server.name"')=?
|
|
845
|
+
AND json_extract(attributes,'$."agent.activity.action"')=?""",
|
|
846
|
+
(log['trace_id'],log['time_unix_nano'],attrs['mcp.server.name'],attrs.get('mcp.tool.name')))
|
|
847
|
+
db.executemany("""INSERT INTO logs
|
|
848
|
+
(digest,trace_id,span_id,time_ns,service,severity_number,severity_text,
|
|
849
|
+
event_name,body,attributes,resource_attributes,scope_name)
|
|
850
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(digest) DO UPDATE SET
|
|
851
|
+
span_id=excluded.span_id,service=excluded.service,body=excluded.body,
|
|
852
|
+
attributes=excluded.attributes,resource_attributes=excluded.resource_attributes""", rows)
|
|
853
|
+
db.executemany("""INSERT INTO trace_activity(trace_id,last_ns) VALUES(?,?)
|
|
854
|
+
ON CONFLICT(trace_id) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
855
|
+
[(log["trace_id"], log["time_unix_nano"]) for log in records if log["trace_id"]])
|
|
856
|
+
self._update_endpoints(db, records)
|
|
857
|
+
self._refresh_after_write(log["trace_id"] for log in records if log["trace_id"])
|
|
858
|
+
return len(rows)
|
|
859
|
+
|
|
860
|
+
def logs(self, trace_id=None, limit=100):
|
|
861
|
+
with self._connect() as db:
|
|
862
|
+
if trace_id:
|
|
863
|
+
rows = db.execute("SELECT * FROM logs WHERE trace_id=? ORDER BY time_ns DESC LIMIT ?",
|
|
864
|
+
(_id(trace_id, TRACE_ID, "trace_id"), limit)).fetchall()
|
|
865
|
+
else:
|
|
866
|
+
rows = db.execute("SELECT * FROM logs ORDER BY time_ns DESC LIMIT ?", (limit,)).fetchall()
|
|
867
|
+
return [{"id": r["digest"], "trace_id": r["trace_id"], "span_id": r["span_id"],
|
|
868
|
+
"time_unix_nano": r["time_ns"], "service": r["service"],
|
|
869
|
+
"severity_number": r["severity_number"], "severity_text": r["severity_text"],
|
|
870
|
+
"event_name": r["event_name"], "body": json.loads(r["body"]),
|
|
871
|
+
"attributes": json.loads(r["attributes"]),
|
|
872
|
+
"resource_attributes": json.loads(r["resource_attributes"]),
|
|
873
|
+
"scope_name": r["scope_name"]} for r in rows]
|
|
874
|
+
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _event_cursor(cursor):
|
|
877
|
+
if not cursor:
|
|
878
|
+
return None
|
|
879
|
+
try:
|
|
880
|
+
stamp, key = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
881
|
+
if not isinstance(stamp, int) or not isinstance(key, str) or len(key) > 512:
|
|
882
|
+
raise ValueError
|
|
883
|
+
return stamp, key
|
|
884
|
+
except (ValueError, TypeError, UnicodeDecodeError) as exc:
|
|
885
|
+
raise ValueError("invalid cursor") from exc
|
|
886
|
+
|
|
887
|
+
@staticmethod
|
|
888
|
+
def _next_event_cursor(stamp, key):
|
|
889
|
+
return base64.urlsafe_b64encode(json.dumps([stamp, key]).encode()).decode().rstrip("=")
|
|
890
|
+
|
|
891
|
+
def recent_activity(self, limit=100, service=None, domain=None, query=None, session_id=None):
|
|
892
|
+
return self.activity_page(limit, service, domain, query, session_id)["activity"]
|
|
893
|
+
|
|
894
|
+
def activity_page(self, limit=100, service=None, domain=None, query=None, session_id=None,
|
|
895
|
+
cursor=None, include_reviews=True, errors_only=False):
|
|
896
|
+
"""Browse saved activity, including logs older than the trace preview window."""
|
|
897
|
+
limit = max(1, min(500, int(limit)))
|
|
898
|
+
before = self._event_cursor(cursor)
|
|
899
|
+
conditions, params = [], []
|
|
900
|
+
for expression, value in (("s.session_id", session_id),
|
|
901
|
+
("json_extract(s.value,'$.service')", service),
|
|
902
|
+
("json_extract(s.value,'$.domain')", domain)):
|
|
903
|
+
if value:
|
|
904
|
+
conditions.append(expression + "=?")
|
|
905
|
+
params.append(value)
|
|
906
|
+
if not include_reviews:
|
|
907
|
+
conditions.append("COALESCE(json_extract(s.value,'$.service'),'')!='codex-review'")
|
|
908
|
+
if errors_only:
|
|
909
|
+
conditions.append("lower(json_extract(s.value,'$.result')) IN ('error','failed','denied','blocked','policy_denied','incomplete')")
|
|
910
|
+
if query:
|
|
911
|
+
conditions.append("instr(lower(s.value),lower(?))>0")
|
|
912
|
+
params.append(query)
|
|
913
|
+
stamp = "json_extract(s.value,'$.time_unix_nano')"
|
|
914
|
+
key = "COALESCE(s.trace_id,'log') || ':' || json_extract(s.value,'$.source_id')"
|
|
915
|
+
if before:
|
|
916
|
+
conditions.append(f"({stamp},{key}) < (?,?)")
|
|
917
|
+
params.extend(before)
|
|
918
|
+
where = ' WHERE ' + ' AND '.join(conditions) if conditions else ''
|
|
919
|
+
# Trace summaries contain span-derived activity. Read log activity from
|
|
920
|
+
# its source table so a busy trace's preview limit cannot hide history.
|
|
921
|
+
def attr(*keys, default="''"):
|
|
922
|
+
fields=[f"NULLIF(json_extract(l.attributes,'$.\"{key}\"'),'')" for key in keys]
|
|
923
|
+
return 'COALESCE(' + ','.join([*fields,default]) + ')'
|
|
924
|
+
fields={
|
|
925
|
+
'time_unix_nano':'l.time_ns', 'service':'l.service', 'source':"'log'",
|
|
926
|
+
'source_id':'l.digest', 'span_id':'l.span_id',
|
|
927
|
+
'domain':attr('agent.activity.domain','activity.domain',default="'other'"),
|
|
928
|
+
'action':attr('agent.activity.action','activity.action',default="'activity'"),
|
|
929
|
+
'subject':attr('agent.activity.subject','agent.activity.target','activity.subject','activity.target'),
|
|
930
|
+
'result':attr('agent.activity.result','activity.result',default="'unknown'"),
|
|
931
|
+
'actor':attr('agent.activity.actor','agent.actor'),
|
|
932
|
+
'mcp_server':attr('mcp.server.name'), 'mcp_tool':attr('mcp.tool.name'),
|
|
933
|
+
'duration_ms':"json_extract(l.attributes,'$.duration_ms')",
|
|
934
|
+
'detail':"CASE WHEN json_type(l.body)='text' THEN json_extract(l.body,'$') ELSE '' END",
|
|
935
|
+
}
|
|
936
|
+
log_value='json_object(' + ','.join(f"'{key}',{value}" for key,value in fields.items()) + ')'
|
|
937
|
+
session=attr(*SESSION_KEYS,default='NULL')
|
|
938
|
+
activity_sql=f"""WITH activity AS (
|
|
939
|
+
SELECT cached.trace_id,cached.session_id,e.value FROM trace_summaries cached,
|
|
940
|
+
json_each(cached.activity_json) e WHERE json_extract(e.value,'$.source')!='log'
|
|
941
|
+
UNION ALL
|
|
942
|
+
SELECT l.trace_id,COALESCE(cached.session_id,{session}),{log_value}
|
|
943
|
+
FROM logs l LEFT JOIN trace_summaries cached ON cached.trace_id=l.trace_id
|
|
944
|
+
WHERE l.event_name='agent.activity'
|
|
945
|
+
)"""
|
|
946
|
+
with self._connect() as db:
|
|
947
|
+
rows = db.execute(activity_sql + f"""SELECT s.trace_id,s.session_id,s.value,{stamp} AS stamp,{key} AS event_key
|
|
948
|
+
FROM activity s
|
|
949
|
+
{where} ORDER BY stamp DESC,event_key DESC LIMIT ?""", [*params, limit+1]).fetchall()
|
|
950
|
+
items = [{**json.loads(row['value']), 'id': row['event_key'],
|
|
951
|
+
'trace_id': row['trace_id'], 'session_id': row['session_id']} for row in rows[:limit]]
|
|
952
|
+
next_cursor = self._next_event_cursor(rows[limit-1]['stamp'],rows[limit-1]['event_key']) if len(rows)>limit else None
|
|
953
|
+
return {'activity': items, 'next_cursor': next_cursor, 'scope': 'stored activity'}
|
|
954
|
+
|
|
955
|
+
def mcp_calls(self, limit=100, service=None, session_id=None, query=None):
|
|
956
|
+
return self.mcp_page(limit, service, session_id, query)['calls']
|
|
957
|
+
|
|
958
|
+
def mcp_page(self, limit=100, service=None, session_id=None, query=None, cursor=None,
|
|
959
|
+
include_reviews=True):
|
|
960
|
+
"""Page complete MCP calls, keeping all evidence for each call together."""
|
|
961
|
+
limit = max(1, min(200, int(limit)))
|
|
962
|
+
before = self._event_cursor(cursor)
|
|
963
|
+
match = "(attributes LIKE '%\"mcp.%' OR attributes LIKE '%\"rpc.system\": \"mcp\"%')"
|
|
964
|
+
clauses, params = [match], []
|
|
965
|
+
if service:
|
|
966
|
+
clauses.append("service=?")
|
|
967
|
+
params.append(service)
|
|
968
|
+
if session_id:
|
|
969
|
+
clauses.append("json_extract(attributes,'$.\"session.id\"')=?")
|
|
970
|
+
params.append(session_id)
|
|
971
|
+
if not include_reviews:
|
|
972
|
+
clauses.append("service!='codex-review'")
|
|
973
|
+
where = ' AND '.join(clauses)
|
|
974
|
+
span_key = "trace_id || ':' || span_id"
|
|
975
|
+
log_key = "CASE WHEN trace_id IS NOT NULL AND span_id IS NOT NULL THEN trace_id || ':' || span_id ELSE digest END"
|
|
976
|
+
def mcp_identity(alias):
|
|
977
|
+
return (f"COALESCE(json_extract({alias}.attributes,'$.\"mcp.server.name\"'),'Not reported')",
|
|
978
|
+
f"COALESCE(json_extract({alias}.attributes,'$.\"mcp.tool.name\"'),"
|
|
979
|
+
f"json_extract({alias}.attributes,'$.\"tool.name\"'),"
|
|
980
|
+
f"json_extract({alias}.attributes,'$.\"agent.activity.action\"'),'Not reported')")
|
|
981
|
+
server,tool=mcp_identity('logs')
|
|
982
|
+
linked_server,linked_tool=mcp_identity('linked')
|
|
983
|
+
unique_log=f"""(span_id IS NOT NULL OR NOT EXISTS (
|
|
984
|
+
SELECT 1 FROM logs linked WHERE linked.trace_id=logs.trace_id
|
|
985
|
+
AND linked.time_ns=logs.time_ns AND linked.span_id IS NOT NULL
|
|
986
|
+
AND {linked_server}={server} AND {linked_tool}={tool}))"""
|
|
987
|
+
query_filter = ' HAVING MAX(instr(lower(evidence),lower(?)))>0' if query else ''
|
|
988
|
+
cursor_filter = ' WHERE (stamp,call_key) < (?,?)' if before else ''
|
|
989
|
+
values = [*params, *params, *([query] if query else []), *(before or ()), limit+1]
|
|
990
|
+
with self._connect() as db:
|
|
991
|
+
candidates = db.execute(f"""WITH evidence AS (
|
|
992
|
+
SELECT {span_key} AS call_key,end_ns AS stamp,attributes || name || service AS evidence FROM spans WHERE {where}
|
|
993
|
+
UNION ALL
|
|
994
|
+
SELECT {log_key} AS call_key,time_ns AS stamp,attributes || body || service AS evidence FROM logs WHERE {where} AND {unique_log}
|
|
995
|
+
), calls AS (
|
|
996
|
+
SELECT call_key,MAX(stamp) AS stamp FROM evidence GROUP BY call_key {query_filter}
|
|
997
|
+
) SELECT * FROM calls {cursor_filter} ORDER BY stamp DESC,call_key DESC LIMIT ?""", values).fetchall()
|
|
998
|
+
selected_keys = [row['call_key'] for row in candidates[:limit]]
|
|
999
|
+
if not selected_keys:
|
|
1000
|
+
return {'calls': [], 'next_cursor': None}
|
|
1001
|
+
placeholders = ','.join('?' for _ in selected_keys)
|
|
1002
|
+
spans = db.execute(f"SELECT * FROM spans WHERE {span_key} IN ({placeholders})",selected_keys).fetchall()
|
|
1003
|
+
logs = db.execute(f"SELECT * FROM logs WHERE {log_key} IN ({placeholders}) ORDER BY time_ns DESC,digest DESC",selected_keys).fetchall()
|
|
1004
|
+
calls = {}
|
|
1005
|
+
for span in spans:
|
|
1006
|
+
attrs = json.loads(span['attributes'])
|
|
1007
|
+
key = span['trace_id']+':'+span['span_id']
|
|
1008
|
+
calls[key] = {'id': key, 'trace_id': span['trace_id'], 'span_id': span['span_id'],
|
|
1009
|
+
'session_id': attrs.get('session.id'), 'service': span['service'],
|
|
1010
|
+
'server': attrs.get('mcp.server.name') or attrs.get('server.address') or 'Not reported',
|
|
1011
|
+
'tool': attrs.get('mcp.tool.name') or attrs.get('gen_ai.tool.name') or attrs.get('tool.name') or span['name'],
|
|
1012
|
+
'method': attrs.get('mcp.method.name') or 'tools/call', 'status': span['status'],
|
|
1013
|
+
'time_unix_nano': span['end_ns'],
|
|
1014
|
+
'duration_ms': None if attrs.get('observatory.duration_unknown') else max(0, (span['end_ns']-span['start_ns'])/1e6),
|
|
1015
|
+
'arguments': attrs.get('input.value') or '', 'output': attrs.get('output.value') or '',
|
|
1016
|
+
'source': 'span', 'log_id': None}
|
|
1017
|
+
for log in reversed(logs):
|
|
1018
|
+
attrs = json.loads(log['attributes'])
|
|
1019
|
+
key = log['trace_id']+':'+log['span_id'] if log['trace_id'] and log['span_id'] else log['digest']
|
|
1020
|
+
body = json.loads(log['body'])
|
|
1021
|
+
try:
|
|
1022
|
+
details = json.loads(body) if isinstance(body, str) else body
|
|
1023
|
+
except ValueError:
|
|
1024
|
+
details = {}
|
|
1025
|
+
if not isinstance(details, dict):
|
|
1026
|
+
details = {}
|
|
1027
|
+
old = calls.get(key, {})
|
|
1028
|
+
result = str(attrs.get('agent.activity.result') or '').lower()
|
|
1029
|
+
calls[key] = {**old, 'id': key, 'trace_id': log['trace_id'], 'span_id': log['span_id'],
|
|
1030
|
+
'session_id': attrs.get('session.id'), 'service': log['service'],
|
|
1031
|
+
'server': attrs.get('mcp.server.name') or old.get('server') or 'Not reported',
|
|
1032
|
+
'tool': attrs.get('mcp.tool.name') or attrs.get('tool.name') or old.get('tool') or attrs.get('agent.activity.action') or 'Not reported',
|
|
1033
|
+
'method': attrs.get('mcp.method.name') or old.get('method') or 'tools/call',
|
|
1034
|
+
'status': 'ERROR' if result in {'error','failed','denied','blocked'} else 'RUNNING' if result=='started' else old.get('status') or 'OK',
|
|
1035
|
+
'time_unix_nano': log['time_ns'], 'duration_ms': attrs.get('duration_ms', old.get('duration_ms')),
|
|
1036
|
+
'arguments': details.get('arguments') or old.get('arguments') or '',
|
|
1037
|
+
'output': details.get('result') or old.get('output') or '',
|
|
1038
|
+
'source': 'session log + span' if old else 'session log', 'log_id': log['digest']}
|
|
1039
|
+
# Older adapters recorded a log without its span ID. Prefer the linked
|
|
1040
|
+
# record for the same exact event; preserve distinct explicit spans.
|
|
1041
|
+
linked = {(item['trace_id'],item['server'],item['tool'],item['time_unix_nano'])
|
|
1042
|
+
for item in calls.values() if item.get('span_id')}
|
|
1043
|
+
items = sorted((item for item in calls.values() if item.get('span_id') or
|
|
1044
|
+
(item['trace_id'],item['server'],item['tool'],item['time_unix_nano']) not in linked),
|
|
1045
|
+
key=lambda item:item['time_unix_nano'], reverse=True)
|
|
1046
|
+
by_id = {item['id']: item for item in items}
|
|
1047
|
+
items = [by_id[key] for key in selected_keys if key in by_id]
|
|
1048
|
+
next_cursor = self._next_event_cursor(candidates[limit-1]['stamp'],candidates[limit-1]['call_key']) if len(candidates)>limit else None
|
|
1049
|
+
return {'calls': items, 'next_cursor': next_cursor}
|
|
1050
|
+
|
|
1051
|
+
def ingest_metrics(self, points):
|
|
1052
|
+
rows = []
|
|
1053
|
+
for point in points:
|
|
1054
|
+
canonical = json.dumps(point, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
1055
|
+
digest = hashlib.sha256(canonical.encode()).hexdigest()
|
|
1056
|
+
rows.append((digest, point["time_unix_nano"], point["start_unix_nano"],
|
|
1057
|
+
point["service"], point["name"], point["description"],
|
|
1058
|
+
point["unit"], point["kind"], point["temporality"],
|
|
1059
|
+
json.dumps(point["attributes"]), json.dumps(point["resource_attributes"]),
|
|
1060
|
+
point["scope_name"], json.dumps(point["data"])))
|
|
1061
|
+
with self._connect() as db:
|
|
1062
|
+
db.executemany("""INSERT OR IGNORE INTO metric_points
|
|
1063
|
+
(digest,time_ns,start_ns,service,name,description,unit,kind,
|
|
1064
|
+
temporality,attributes,resource_attributes,scope_name,data)
|
|
1065
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", rows)
|
|
1066
|
+
self._update_endpoints(db, points)
|
|
1067
|
+
return len(rows)
|
|
1068
|
+
|
|
1069
|
+
def metric_points(self, limit=100, service=None, name=None, kind=None,
|
|
1070
|
+
temporality=None, scope_name=None, unit=None, description=None):
|
|
1071
|
+
where = []
|
|
1072
|
+
values = []
|
|
1073
|
+
if service:
|
|
1074
|
+
where.append("service=?")
|
|
1075
|
+
values.append(service)
|
|
1076
|
+
if name:
|
|
1077
|
+
where.append("name=?")
|
|
1078
|
+
values.append(name)
|
|
1079
|
+
if kind:
|
|
1080
|
+
where.append("kind=?")
|
|
1081
|
+
values.append(kind)
|
|
1082
|
+
if temporality is not None:
|
|
1083
|
+
where.append("temporality=?")
|
|
1084
|
+
values.append(int(temporality))
|
|
1085
|
+
if scope_name is not None:
|
|
1086
|
+
where.append("scope_name=?")
|
|
1087
|
+
values.append(scope_name)
|
|
1088
|
+
if unit is not None:
|
|
1089
|
+
where.append("unit=?")
|
|
1090
|
+
values.append(unit)
|
|
1091
|
+
if description is not None:
|
|
1092
|
+
where.append("description=?")
|
|
1093
|
+
values.append(description)
|
|
1094
|
+
clause = " WHERE " + " AND ".join(where) if where else ""
|
|
1095
|
+
with self._connect() as db:
|
|
1096
|
+
rows = db.execute("SELECT * FROM metric_points" + clause +
|
|
1097
|
+
" ORDER BY time_ns DESC LIMIT ?", (*values, limit)).fetchall()
|
|
1098
|
+
return [{"id": r["digest"], "time_unix_nano": r["time_ns"],
|
|
1099
|
+
"start_unix_nano": r["start_ns"], "service": r["service"],
|
|
1100
|
+
"name": r["name"], "description": r["description"],
|
|
1101
|
+
"unit": r["unit"], "kind": r["kind"], "temporality": r["temporality"],
|
|
1102
|
+
"attributes": json.loads(r["attributes"]),
|
|
1103
|
+
"resource_attributes": json.loads(r["resource_attributes"]),
|
|
1104
|
+
"scope_name": r["scope_name"], "data": json.loads(r["data"])} for r in rows]
|
|
1105
|
+
|
|
1106
|
+
def metric_catalog(self, limit=200):
|
|
1107
|
+
if not 1 <= int(limit) <= 500:
|
|
1108
|
+
raise ValueError("limit must be between 1 and 500")
|
|
1109
|
+
with self._connect() as db:
|
|
1110
|
+
rows = db.execute("SELECT service,name,description,unit,kind,temporality,scope_name,"
|
|
1111
|
+
"COUNT(*) AS point_count,MAX(time_ns) AS last_ns FROM metric_points "
|
|
1112
|
+
"GROUP BY service,name,description,unit,kind,temporality,scope_name "
|
|
1113
|
+
"ORDER BY last_ns DESC,service,name LIMIT ?", (int(limit),)).fetchall()
|
|
1114
|
+
return [dict(row) for row in rows]
|
|
1115
|
+
|
|
1116
|
+
def ingest_outcomes(self, records, source="api"):
|
|
1117
|
+
records = list(records)
|
|
1118
|
+
now = time.time_ns()
|
|
1119
|
+
rows = []
|
|
1120
|
+
for record in records:
|
|
1121
|
+
trace_id = _id(record.get("trace_id"), TRACE_ID, "trace_id")
|
|
1122
|
+
passed = record.get("passed")
|
|
1123
|
+
if not isinstance(passed, bool):
|
|
1124
|
+
raise ValueError("outcome passed must be a boolean")
|
|
1125
|
+
scores = record.get("scores") or []
|
|
1126
|
+
if not isinstance(scores, list):
|
|
1127
|
+
raise ValueError("outcome scores must be an array")
|
|
1128
|
+
rows.append((trace_id, str(record.get("task_id") or ""), int(passed),
|
|
1129
|
+
json.dumps(scores), str(record.get("error") or ""),
|
|
1130
|
+
str(record.get("provider_model") or ""), str(source),
|
|
1131
|
+
json.dumps(record), now))
|
|
1132
|
+
with self._connect() as db:
|
|
1133
|
+
db.executemany("""INSERT INTO outcomes VALUES (?,?,?,?,?,?,?,?,?)
|
|
1134
|
+
ON CONFLICT(trace_id) DO UPDATE SET task_id=excluded.task_id,
|
|
1135
|
+
passed=excluded.passed,scores=excluded.scores,error=excluded.error,
|
|
1136
|
+
provider_model=excluded.provider_model,source=excluded.source,
|
|
1137
|
+
details=excluded.details,ingested_ns=excluded.ingested_ns""", rows)
|
|
1138
|
+
db.executemany("""INSERT INTO trace_activity(trace_id,last_ns) VALUES(?,?)
|
|
1139
|
+
ON CONFLICT(trace_id) DO UPDATE SET last_ns=MAX(last_ns,excluded.last_ns)""",
|
|
1140
|
+
[(row[0], now) for row in rows])
|
|
1141
|
+
self._refresh_after_write(row[0] for row in rows)
|
|
1142
|
+
return len(rows)
|
|
1143
|
+
|
|
1144
|
+
def outcomes(self):
|
|
1145
|
+
with self._connect() as db:
|
|
1146
|
+
rows = db.execute("SELECT * FROM outcomes").fetchall()
|
|
1147
|
+
return {r["trace_id"]: self._outcome_row(r) for r in rows}
|
|
1148
|
+
|
|
1149
|
+
@staticmethod
|
|
1150
|
+
def _outcome_row(row):
|
|
1151
|
+
return {"trace_id": row["trace_id"], "task_id": row["task_id"],
|
|
1152
|
+
"passed": bool(row["passed"]), "scores": json.loads(row["scores"]),
|
|
1153
|
+
"error": row["error"], "provider_model": row["provider_model"],
|
|
1154
|
+
"source": row["source"], "details": json.loads(row["details"]),
|
|
1155
|
+
"ingested_ns": row["ingested_ns"]}
|
|
1156
|
+
|
|
1157
|
+
def _outcome(self, trace_id):
|
|
1158
|
+
with self._connect() as db:
|
|
1159
|
+
row = db.execute("SELECT * FROM outcomes WHERE trace_id=?", (trace_id,)).fetchone()
|
|
1160
|
+
return self._outcome_row(row) if row else None
|
|
1161
|
+
|
|
1162
|
+
def _spans(self, trace_id=None):
|
|
1163
|
+
with self._connect() as db:
|
|
1164
|
+
if trace_id:
|
|
1165
|
+
rows = db.execute("SELECT * FROM spans WHERE trace_id=? ORDER BY start_ns,span_id", (trace_id,)).fetchall()
|
|
1166
|
+
else:
|
|
1167
|
+
rows = db.execute("SELECT * FROM spans ORDER BY start_ns,span_id").fetchall()
|
|
1168
|
+
return [{"trace_id": r["trace_id"], "span_id": r["span_id"],
|
|
1169
|
+
"parent_span_id": r["parent_span_id"], "name": r["name"],
|
|
1170
|
+
"start_unix_nano": r["start_ns"], "end_unix_nano": r["end_ns"],
|
|
1171
|
+
"status": r["status"], "service": r["service"],
|
|
1172
|
+
"attributes": json.loads(r["attributes"]), "events": json.loads(r["events"]),
|
|
1173
|
+
"links": json.loads(r["links"]),
|
|
1174
|
+
"resource_attributes": json.loads(r["resource_attributes"]),
|
|
1175
|
+
"scope_name": r["scope_name"]} for r in rows]
|
|
1176
|
+
|
|
1177
|
+
def span_page(self, trace_id, limit=200, cursor=None, query="", span_filter="all"):
|
|
1178
|
+
"""Return a bounded, searchable page of spans for one trace."""
|
|
1179
|
+
trace_id = _id(trace_id, TRACE_ID, "trace_id")
|
|
1180
|
+
limit = int(limit)
|
|
1181
|
+
if not 1 <= limit <= 500:
|
|
1182
|
+
raise ValueError("limit must be between 1 and 500")
|
|
1183
|
+
if span_filter not in {"all", "errors", "models", "tools", "agents"}:
|
|
1184
|
+
raise ValueError("invalid span filter")
|
|
1185
|
+
before = None
|
|
1186
|
+
if cursor:
|
|
1187
|
+
try:
|
|
1188
|
+
decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode()
|
|
1189
|
+
start_ns, span_id = decoded.split(",", 1)
|
|
1190
|
+
before = (int(start_ns), _id(span_id, SPAN_ID, "span_id"))
|
|
1191
|
+
if before[0] < 0:
|
|
1192
|
+
raise ValueError
|
|
1193
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
1194
|
+
raise ValueError("invalid span cursor") from exc
|
|
1195
|
+
clauses = ["trace_id=?"]
|
|
1196
|
+
params = [trace_id]
|
|
1197
|
+
if query:
|
|
1198
|
+
clauses.append("(lower(span_id) LIKE ? ESCAPE '\\' OR lower(name) LIKE ? ESCAPE '\\' OR lower(service) LIKE ? ESCAPE '\\' OR lower(attributes) LIKE ? ESCAPE '\\')")
|
|
1199
|
+
needle = "%" + query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
1200
|
+
params.extend((needle, needle, needle, needle))
|
|
1201
|
+
if span_filter == "errors":
|
|
1202
|
+
clauses.append("(status='ERROR' OR attributes LIKE '%\"error.type\"%' OR attributes LIKE '%\"exception.type\"%' OR attributes LIKE '%\"tool.error\"%' OR lower(attributes) LIKE '%\"tool.result_status\": \"error%')")
|
|
1203
|
+
elif span_filter == "models":
|
|
1204
|
+
clauses.append("(attributes LIKE '%\"gen_ai.request.model\"%' OR attributes LIKE '%\"llm.response.model_name\"%' OR attributes LIKE '%\"llm.request.model_name\"%' OR attributes LIKE '%\"llm.model_name\"%' OR attributes LIKE '%\"embedding.model_name\"%' OR attributes LIKE '%\"llm.model\"%' OR attributes LIKE '%\"openinference.span.kind\": \"LLM\"%' OR attributes LIKE '%\"openinference.span.kind\": \"EMBEDDING\"%' OR lower(attributes) LIKE '%\"gen_ai.operation.name\": \"chat%' OR lower(attributes) LIKE '%\"gen_ai.operation.name\": \"text_completion%' OR lower(attributes) LIKE '%\"gen_ai.operation.name\": \"embeddings%')")
|
|
1205
|
+
elif span_filter == "tools":
|
|
1206
|
+
clauses.append("(attributes LIKE '%\"tool.name\"%' OR attributes LIKE '%\"gen_ai.tool.name\"%' OR attributes LIKE '%\"mcp.tool.name\"%' OR attributes LIKE '%\"openinference.span.kind\": \"TOOL\"%' OR lower(attributes) LIKE '%\"gen_ai.operation.name\": \"execute_tool%' OR lower(name) LIKE '%tool%')")
|
|
1207
|
+
elif span_filter == "agents":
|
|
1208
|
+
clauses.append("(attributes LIKE '%\"openinference.span.kind\": \"AGENT\"%' OR lower(name) LIKE 'agent.%' OR lower(name) LIKE 'agent-%' OR lower(name) LIKE 'agent_%')")
|
|
1209
|
+
where = " AND ".join(clauses)
|
|
1210
|
+
cursor_clause = ""
|
|
1211
|
+
cursor_params = []
|
|
1212
|
+
if before:
|
|
1213
|
+
cursor_clause = " AND (start_ns>? OR (start_ns=? AND span_id>?))"
|
|
1214
|
+
cursor_params.extend((before[0], before[0], before[1]))
|
|
1215
|
+
with self._connect() as db:
|
|
1216
|
+
total = db.execute(f"SELECT COUNT(*) FROM spans WHERE {where}", params).fetchone()[0]
|
|
1217
|
+
rows = db.execute(f"SELECT * FROM spans WHERE {where}{cursor_clause} ORDER BY start_ns,span_id LIMIT ?",
|
|
1218
|
+
[*params, *cursor_params, limit + 1]).fetchall()
|
|
1219
|
+
trace_total = db.execute("SELECT COUNT(*) FROM spans WHERE trace_id=?", (trace_id,)).fetchone()[0]
|
|
1220
|
+
bounds = db.execute("SELECT MIN(start_ns),MAX(end_ns) FROM spans WHERE trace_id=?", (trace_id,)).fetchone()
|
|
1221
|
+
has_more = len(rows) > limit
|
|
1222
|
+
visible = rows[:limit]
|
|
1223
|
+
next_cursor = None
|
|
1224
|
+
if has_more and visible:
|
|
1225
|
+
last = visible[-1]
|
|
1226
|
+
next_cursor = base64.urlsafe_b64encode(f"{last['start_ns']},{last['span_id']}".encode()).decode().rstrip("=")
|
|
1227
|
+
spans = [{"trace_id": r["trace_id"], "span_id": r["span_id"],
|
|
1228
|
+
"parent_span_id": r["parent_span_id"], "name": r["name"],
|
|
1229
|
+
"start_unix_nano": r["start_ns"], "end_unix_nano": r["end_ns"],
|
|
1230
|
+
"status": r["status"], "service": r["service"],
|
|
1231
|
+
"attributes": json.loads(r["attributes"]), "events": json.loads(r["events"]),
|
|
1232
|
+
"links": json.loads(r["links"]),
|
|
1233
|
+
"resource_attributes": json.loads(r["resource_attributes"]),
|
|
1234
|
+
"scope_name": r["scope_name"]} for r in visible]
|
|
1235
|
+
return {"spans": spans, "total": total, "next_cursor": next_cursor,
|
|
1236
|
+
"trace_total": trace_total,
|
|
1237
|
+
"start_unix_nano": bounds[0] or 0, "end_unix_nano": bounds[1] or 0}
|
|
1238
|
+
|
|
1239
|
+
def trace(self, trace_id):
|
|
1240
|
+
trace_id = _id(trace_id, TRACE_ID, "trace_id")
|
|
1241
|
+
spans = self._spans(trace_id)
|
|
1242
|
+
with self._connect() as db:
|
|
1243
|
+
rows = db.execute("SELECT time_ns,stage,message,percent,session_id,service FROM progress WHERE trace_id=? ORDER BY time_ns,id", (trace_id,)).fetchall()
|
|
1244
|
+
progress = [dict(r) for r in rows]
|
|
1245
|
+
logs = self.logs(trace_id, limit=1000)
|
|
1246
|
+
activity = _activity_for(spans, logs)
|
|
1247
|
+
outcome = self._outcome(trace_id)
|
|
1248
|
+
for log in logs:
|
|
1249
|
+
progress_event = _progress_from_log(log)
|
|
1250
|
+
if progress_event is not None:
|
|
1251
|
+
progress.append(progress_event)
|
|
1252
|
+
progress.sort(key=lambda x: x["time_ns"])
|
|
1253
|
+
timeline = progress or [{"time_ns": log["time_unix_nano"]} for log in reversed(logs)]
|
|
1254
|
+
summary = summarize(spans) if spans else summarize_progress(trace_id, timeline) if timeline else None
|
|
1255
|
+
if summary and not spans and logs:
|
|
1256
|
+
summary["services"] = sorted(set(summary["services"]) | {log["service"] for log in logs})
|
|
1257
|
+
if outcome:
|
|
1258
|
+
summary = apply_outcome(summary or summarize_progress(trace_id, [{"time_ns": outcome["ingested_ns"]}]), outcome)
|
|
1259
|
+
session_id = _session_for(spans, progress, logs)
|
|
1260
|
+
if summary:
|
|
1261
|
+
summary["session_id"] = session_id
|
|
1262
|
+
summary["activity_terms"] = sorted({str(value)[:200] for item in activity
|
|
1263
|
+
for value in (item["domain"], item["action"], item["subject"], item["actor"]) if value})
|
|
1264
|
+
searchable_attributes = ("tool.name", "tool.type", "gen_ai.tool.name", "gen_ai.tool.type",
|
|
1265
|
+
"exception.type", "error.type", "gen_ai.provider.name",
|
|
1266
|
+
"gen_ai.operation.name",
|
|
1267
|
+
"openai_agents.workflow", "mcp.method.name", "mcp.server.name",
|
|
1268
|
+
"db.system", "http.request.method")
|
|
1269
|
+
summary["span_terms"] = sorted({str(value)[:200] for span in spans
|
|
1270
|
+
for value in (span["name"], *(span["attributes"].get(key)
|
|
1271
|
+
for key in searchable_attributes))
|
|
1272
|
+
if isinstance(value, (str, int, float)) and str(value).strip()})
|
|
1273
|
+
summary["log_terms"] = sorted({str(value)[:200] for log in logs
|
|
1274
|
+
for value in (log.get("event_name"), log.get("severity_text")) if value})
|
|
1275
|
+
return {"trace_id": trace_id, "spans": spans, "progress": progress, "logs": logs,
|
|
1276
|
+
"activity": activity,
|
|
1277
|
+
"outcome": outcome, "findings": self._trace_findings(trace_id),
|
|
1278
|
+
"session_id": session_id, "summary": summary}
|
|
1279
|
+
|
|
1280
|
+
def trace_overview(self, trace_id):
|
|
1281
|
+
"""Load trace panels from derived indexes without decoding every span."""
|
|
1282
|
+
trace_id = _id(trace_id, TRACE_ID, "trace_id")
|
|
1283
|
+
with self._connect() as db:
|
|
1284
|
+
row = db.execute("SELECT summary_json,activity_json FROM trace_summaries WHERE trace_id=?",
|
|
1285
|
+
(trace_id,)).fetchone()
|
|
1286
|
+
if row is None:
|
|
1287
|
+
self._refresh_summaries([trace_id])
|
|
1288
|
+
with self._connect() as db:
|
|
1289
|
+
row = db.execute("SELECT summary_json,activity_json FROM trace_summaries WHERE trace_id=?",
|
|
1290
|
+
(trace_id,)).fetchone()
|
|
1291
|
+
with self._connect() as db:
|
|
1292
|
+
rows = db.execute("SELECT time_ns,stage,message,percent,session_id,service FROM progress "
|
|
1293
|
+
"WHERE trace_id=? ORDER BY time_ns,id", (trace_id,)).fetchall()
|
|
1294
|
+
progress = [dict(item) for item in rows]
|
|
1295
|
+
logs = self.logs(trace_id, limit=1000)
|
|
1296
|
+
for log in logs:
|
|
1297
|
+
event = _progress_from_log(log)
|
|
1298
|
+
if event is not None:
|
|
1299
|
+
progress.append(event)
|
|
1300
|
+
progress.sort(key=lambda item: item["time_ns"])
|
|
1301
|
+
summary = json.loads(row["summary_json"]) if row else None
|
|
1302
|
+
outcome = self._outcome(trace_id)
|
|
1303
|
+
if outcome:
|
|
1304
|
+
summary = apply_outcome(summary or summarize_progress(trace_id,
|
|
1305
|
+
[{"time_ns": outcome["ingested_ns"]}]), outcome)
|
|
1306
|
+
return {"trace_id": trace_id, "spans": [], "progress": progress, "logs": logs,
|
|
1307
|
+
"activity": json.loads(row["activity_json"]) if row else [],
|
|
1308
|
+
"outcome": outcome, "findings": self._trace_findings(trace_id),
|
|
1309
|
+
"session_id": summary.get("session_id") if summary else None,
|
|
1310
|
+
"summary": summary}
|
|
1311
|
+
|
|
1312
|
+
def findings(self, limit=100):
|
|
1313
|
+
return self.finding_page(limit=limit)["findings"]
|
|
1314
|
+
|
|
1315
|
+
def finding_page(self, limit=100, cursor=None):
|
|
1316
|
+
if not 1 <= int(limit) <= 500:
|
|
1317
|
+
raise ValueError("limit must be between 1 and 500")
|
|
1318
|
+
before = None
|
|
1319
|
+
if cursor:
|
|
1320
|
+
try:
|
|
1321
|
+
decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode()
|
|
1322
|
+
before_ns, before_id = decoded.split(",", 1)
|
|
1323
|
+
before = (int(before_ns), before_id)
|
|
1324
|
+
if before[0] < 0 or not re.fullmatch(r"[0-9a-f]{16}", before_id):
|
|
1325
|
+
raise ValueError
|
|
1326
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
1327
|
+
raise ValueError("invalid cursor") from exc
|
|
1328
|
+
sql = "SELECT finding_id,time_ns,finding_json FROM finding_cache"
|
|
1329
|
+
params = []
|
|
1330
|
+
if before:
|
|
1331
|
+
sql += " WHERE time_ns < ? OR (time_ns = ? AND finding_id < ?)"
|
|
1332
|
+
params.extend((before[0], before[0], before[1]))
|
|
1333
|
+
sql += " ORDER BY time_ns DESC,finding_id DESC LIMIT ?"
|
|
1334
|
+
params.append(int(limit) + 1)
|
|
1335
|
+
with self._connect() as db:
|
|
1336
|
+
rows = db.execute(sql, params).fetchall()
|
|
1337
|
+
has_more = len(rows) > int(limit)
|
|
1338
|
+
visible = rows[:int(limit)]
|
|
1339
|
+
next_cursor = None
|
|
1340
|
+
if has_more and visible:
|
|
1341
|
+
row = visible[-1]
|
|
1342
|
+
next_cursor = base64.urlsafe_b64encode(
|
|
1343
|
+
f"{row['time_ns']},{row['finding_id']}".encode()).decode().rstrip("=")
|
|
1344
|
+
return {"findings": [json.loads(row["finding_json"]) for row in visible],
|
|
1345
|
+
"next_cursor": next_cursor, "has_more": has_more}
|
|
1346
|
+
|
|
1347
|
+
def _trace_findings(self, trace_id):
|
|
1348
|
+
with self._connect() as db:
|
|
1349
|
+
rows = db.execute("SELECT finding_json FROM finding_cache WHERE trace_id=? "
|
|
1350
|
+
"ORDER BY time_ns DESC,finding_id", (trace_id,)).fetchall()
|
|
1351
|
+
return [json.loads(row["finding_json"]) for row in rows]
|
|
1352
|
+
|
|
1353
|
+
def sessions(self, limit=100):
|
|
1354
|
+
if not 1 <= int(limit) <= 500:
|
|
1355
|
+
raise ValueError("limit must be between 1 and 500")
|
|
1356
|
+
with self._connect() as db:
|
|
1357
|
+
rows = db.execute("""SELECT s.session_id,s.trace_id,s.summary_json,a.last_ns
|
|
1358
|
+
FROM trace_summaries AS s JOIN trace_activity AS a USING(trace_id)
|
|
1359
|
+
WHERE s.session_id IN (
|
|
1360
|
+
SELECT s2.session_id FROM trace_summaries AS s2
|
|
1361
|
+
JOIN trace_activity AS a2 USING(trace_id)
|
|
1362
|
+
WHERE s2.session_id IS NOT NULL GROUP BY s2.session_id
|
|
1363
|
+
ORDER BY MAX(a2.last_ns) DESC,s2.session_id DESC LIMIT ?)
|
|
1364
|
+
ORDER BY a.last_ns DESC,s.session_id DESC""", (int(limit),)).fetchall()
|
|
1365
|
+
grouped = {}
|
|
1366
|
+
for row in rows:
|
|
1367
|
+
item = grouped.setdefault(row["session_id"], {
|
|
1368
|
+
"session_id": row["session_id"], "trace_count": 0, "errors": 0,
|
|
1369
|
+
"failed": 0, "incomplete": 0, "issues": 0,
|
|
1370
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
1371
|
+
"known_cost_usd": 0.0, "unknown_cost_runs": 0, "cost_complete": True,
|
|
1372
|
+
"services": set(), "models": set(), "latest_trace_id": row["trace_id"],
|
|
1373
|
+
"last_unix_nano": row["last_ns"]})
|
|
1374
|
+
summary = json.loads(row["summary_json"])
|
|
1375
|
+
item["trace_count"] += 1
|
|
1376
|
+
status = summary.get("status")
|
|
1377
|
+
item["errors"] += status == "ERROR"
|
|
1378
|
+
item["failed"] += status == "FAILED" or summary.get("outcome_passed") is False
|
|
1379
|
+
item["incomplete"] += status == "INCOMPLETE"
|
|
1380
|
+
item["issues"] += status in {"ERROR", "FAILED", "INCOMPLETE"} or summary.get("outcome_passed") is False
|
|
1381
|
+
item["input_tokens"] += int(summary.get("input_tokens") or 0)
|
|
1382
|
+
item["output_tokens"] += int(summary.get("output_tokens") or 0)
|
|
1383
|
+
item["known_cost_usd"] += float(summary.get("known_cost_usd") or 0)
|
|
1384
|
+
cost_complete = bool(summary.get("cost_complete", False))
|
|
1385
|
+
item["unknown_cost_runs"] += not cost_complete
|
|
1386
|
+
item["cost_complete"] &= cost_complete
|
|
1387
|
+
item["services"].update(summary.get("services", []))
|
|
1388
|
+
item["models"].update(summary.get("models", []))
|
|
1389
|
+
result = []
|
|
1390
|
+
for item in grouped.values():
|
|
1391
|
+
item["known_cost_usd"] = round(item["known_cost_usd"], 8)
|
|
1392
|
+
item["services"] = sorted(item["services"])
|
|
1393
|
+
item["models"] = sorted(item["models"])
|
|
1394
|
+
result.append(item)
|
|
1395
|
+
return result
|
|
1396
|
+
|
|
1397
|
+
def related_traces(self, trace_id):
|
|
1398
|
+
trace_id = _id(trace_id, TRACE_ID, "trace_id")
|
|
1399
|
+
relations = {}
|
|
1400
|
+
groups = {}
|
|
1401
|
+
for span in self._spans():
|
|
1402
|
+
groups.setdefault(span["trace_id"], []).append(span)
|
|
1403
|
+
for link in span["links"]:
|
|
1404
|
+
target = link["trace_id"]
|
|
1405
|
+
if span["trace_id"] == trace_id and target != trace_id:
|
|
1406
|
+
relations.setdefault((target, "outgoing"), []).append({
|
|
1407
|
+
"source_span_id": span["span_id"], "linked_span_id": link["span_id"]})
|
|
1408
|
+
if target == trace_id and span["trace_id"] != trace_id:
|
|
1409
|
+
relations.setdefault((span["trace_id"], "incoming"), []).append({
|
|
1410
|
+
"source_span_id": span["span_id"], "linked_span_id": link["span_id"]})
|
|
1411
|
+
return [{"trace_id": target, "direction": direction, "edges": edges,
|
|
1412
|
+
"summary": summarize(groups[target]) if target in groups else None}
|
|
1413
|
+
for (target, direction), edges in sorted(relations.items())]
|
|
1414
|
+
|
|
1415
|
+
def trace_page(self, limit=100, service=None, status=None, cursor=None, session_id=None,
|
|
1416
|
+
search=None, endpoint_id=None, include_reviews=True):
|
|
1417
|
+
if not 1 <= int(limit) <= 500:
|
|
1418
|
+
raise ValueError("limit must be between 1 and 500")
|
|
1419
|
+
if session_id is not None:
|
|
1420
|
+
session_id = _session_value(session_id)
|
|
1421
|
+
if session_id is None:
|
|
1422
|
+
raise ValueError("invalid session_id")
|
|
1423
|
+
search = str(search or "").strip()[:200].casefold()
|
|
1424
|
+
if endpoint_id is not None:
|
|
1425
|
+
if not isinstance(endpoint_id, str) or not re.fullmatch(r"[0-9a-fA-F]{32}", endpoint_id):
|
|
1426
|
+
raise ValueError("invalid endpoint_id")
|
|
1427
|
+
endpoint_id = endpoint_id.lower()
|
|
1428
|
+
before = None
|
|
1429
|
+
if cursor:
|
|
1430
|
+
try:
|
|
1431
|
+
decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode()
|
|
1432
|
+
before_ns, before_id = decoded.split(",", 1)
|
|
1433
|
+
before = (int(before_ns), _id(before_id, TRACE_ID, "cursor trace_id"))
|
|
1434
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
1435
|
+
raise ValueError("invalid cursor") from exc
|
|
1436
|
+
|
|
1437
|
+
selected = []
|
|
1438
|
+
page_cursor = before
|
|
1439
|
+
batch_size = max(50, min(250, int(limit) * 2))
|
|
1440
|
+
exhausted = False
|
|
1441
|
+
while len(selected) <= int(limit):
|
|
1442
|
+
sql = "SELECT a.trace_id,a.last_ns,s.summary_json FROM trace_activity AS a " \
|
|
1443
|
+
"LEFT JOIN trace_summaries AS s ON s.trace_id=a.trace_id"
|
|
1444
|
+
conditions = []
|
|
1445
|
+
params = []
|
|
1446
|
+
if page_cursor:
|
|
1447
|
+
conditions.append("(a.last_ns < ? OR (a.last_ns = ? AND a.trace_id < ?))")
|
|
1448
|
+
params.extend((page_cursor[0], page_cursor[0], page_cursor[1]))
|
|
1449
|
+
if session_id is not None:
|
|
1450
|
+
conditions.append("s.session_id=?")
|
|
1451
|
+
params.append(session_id)
|
|
1452
|
+
if endpoint_id is not None:
|
|
1453
|
+
conditions.append("EXISTS (SELECT 1 FROM endpoint_traces et "
|
|
1454
|
+
"WHERE et.trace_id=a.trace_id AND et.endpoint_id=?)")
|
|
1455
|
+
params.append(endpoint_id)
|
|
1456
|
+
if conditions:
|
|
1457
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
1458
|
+
sql += " ORDER BY a.last_ns DESC,a.trace_id DESC LIMIT ?"
|
|
1459
|
+
params.append(batch_size)
|
|
1460
|
+
with self._connect() as db:
|
|
1461
|
+
rows = db.execute(sql, params).fetchall()
|
|
1462
|
+
if not rows:
|
|
1463
|
+
exhausted = True
|
|
1464
|
+
break
|
|
1465
|
+
for row in rows:
|
|
1466
|
+
activity = (row["last_ns"], row["trace_id"])
|
|
1467
|
+
page_cursor = activity
|
|
1468
|
+
if row["summary_json"] is None:
|
|
1469
|
+
if self._async_derived_refresh:
|
|
1470
|
+
self._refresh_after_write([row["trace_id"]])
|
|
1471
|
+
summary = None
|
|
1472
|
+
else:
|
|
1473
|
+
self._refresh_summaries([row["trace_id"]])
|
|
1474
|
+
with self._connect() as db:
|
|
1475
|
+
cached = db.execute("SELECT summary_json FROM trace_summaries WHERE trace_id=?",
|
|
1476
|
+
(row["trace_id"],)).fetchone()
|
|
1477
|
+
summary = json.loads(cached[0]) if cached else None
|
|
1478
|
+
else:
|
|
1479
|
+
summary = json.loads(row["summary_json"])
|
|
1480
|
+
if not summary:
|
|
1481
|
+
continue
|
|
1482
|
+
if session_id is not None and summary.get("session_id") != session_id:
|
|
1483
|
+
continue
|
|
1484
|
+
if service and service not in summary["services"]:
|
|
1485
|
+
continue
|
|
1486
|
+
if not include_reviews and 'codex-review' in summary['services']:
|
|
1487
|
+
continue
|
|
1488
|
+
if status and summary["status"] != status:
|
|
1489
|
+
continue
|
|
1490
|
+
if search:
|
|
1491
|
+
searchable = " ".join(str(value) for value in (
|
|
1492
|
+
summary.get("trace_id"), summary.get("name"), summary.get("task"),
|
|
1493
|
+
summary.get("session_id"), *summary.get("services", []),
|
|
1494
|
+
*summary.get("models", []), *summary.get("activity_terms", []),
|
|
1495
|
+
*summary.get("span_terms", []), *summary.get("log_terms", [])))
|
|
1496
|
+
if search not in searchable.casefold():
|
|
1497
|
+
continue
|
|
1498
|
+
selected.append((summary, activity))
|
|
1499
|
+
if len(selected) > int(limit):
|
|
1500
|
+
break
|
|
1501
|
+
if len(selected) > int(limit):
|
|
1502
|
+
break
|
|
1503
|
+
if len(rows) < batch_size:
|
|
1504
|
+
exhausted = True
|
|
1505
|
+
break
|
|
1506
|
+
has_more = len(selected) > int(limit)
|
|
1507
|
+
visible = selected[:int(limit)]
|
|
1508
|
+
next_cursor = None
|
|
1509
|
+
if has_more and visible:
|
|
1510
|
+
ns, trace_id = visible[-1][1]
|
|
1511
|
+
next_cursor = base64.urlsafe_b64encode(f"{ns},{trace_id}".encode()).decode().rstrip("=")
|
|
1512
|
+
return {"traces": [item[0] for item in visible],
|
|
1513
|
+
"next_cursor": next_cursor, "has_more": has_more or not exhausted}
|
|
1514
|
+
|
|
1515
|
+
def traces(self, limit=100, service=None, status=None, cursor=None, session_id=None,
|
|
1516
|
+
search=None, endpoint_id=None):
|
|
1517
|
+
return self.trace_page(limit, service, status, cursor, session_id, search,
|
|
1518
|
+
endpoint_id)["traces"]
|
|
1519
|
+
|
|
1520
|
+
def metrics(self):
|
|
1521
|
+
totals = {"runs": 0, "errors": 0, "incomplete": 0, "running": 0,
|
|
1522
|
+
"spans": 0, "input_tokens": 0, "output_tokens": 0,
|
|
1523
|
+
"known_cost_usd": 0.0, "unknown_cost_runs": 0}
|
|
1524
|
+
cursor = None
|
|
1525
|
+
while True:
|
|
1526
|
+
page = self.trace_page(limit=500, cursor=cursor)
|
|
1527
|
+
for run in page["traces"]:
|
|
1528
|
+
totals["runs"] += 1
|
|
1529
|
+
totals["errors"] += run["status"] == "ERROR"
|
|
1530
|
+
totals["incomplete"] += run["status"] == "INCOMPLETE"
|
|
1531
|
+
totals["running"] += run["status"] == "RUNNING"
|
|
1532
|
+
totals["spans"] += run["span_count"]
|
|
1533
|
+
totals["input_tokens"] += run["input_tokens"]
|
|
1534
|
+
totals["output_tokens"] += run["output_tokens"]
|
|
1535
|
+
totals["known_cost_usd"] += run["known_cost_usd"]
|
|
1536
|
+
totals["unknown_cost_runs"] += not run["cost_complete"]
|
|
1537
|
+
cursor = page["next_cursor"]
|
|
1538
|
+
if not cursor:
|
|
1539
|
+
break
|
|
1540
|
+
totals["known_cost_usd"] = round(totals["known_cost_usd"], 8)
|
|
1541
|
+
with self._connect() as db:
|
|
1542
|
+
totals["failed_outcomes"] = db.execute(
|
|
1543
|
+
"SELECT COUNT(*) FROM outcomes WHERE passed=0").fetchone()[0]
|
|
1544
|
+
totals.update({
|
|
1545
|
+
"logs": self._log_count(),
|
|
1546
|
+
"metric_points": self._metric_count(),
|
|
1547
|
+
"services": self._services()}
|
|
1548
|
+
)
|
|
1549
|
+
return totals
|
|
1550
|
+
|
|
1551
|
+
def _log_count(self):
|
|
1552
|
+
with self._connect() as db:
|
|
1553
|
+
return db.execute("SELECT COUNT(*) FROM logs").fetchone()[0]
|
|
1554
|
+
|
|
1555
|
+
def _metric_count(self):
|
|
1556
|
+
with self._connect() as db:
|
|
1557
|
+
return db.execute("SELECT COUNT(*) FROM metric_points").fetchone()[0]
|
|
1558
|
+
|
|
1559
|
+
def _services(self):
|
|
1560
|
+
with self._connect() as db:
|
|
1561
|
+
rows = db.execute("""SELECT service FROM spans UNION
|
|
1562
|
+
SELECT service FROM logs UNION SELECT service FROM metric_points UNION
|
|
1563
|
+
SELECT service FROM progress WHERE service IS NOT NULL""").fetchall()
|
|
1564
|
+
return sorted(row[0] for row in rows)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def summarize_progress(trace_id, progress):
|
|
1568
|
+
start, end = progress[0]["time_ns"], progress[-1]["time_ns"]
|
|
1569
|
+
return {"trace_id": trace_id, "name": "agent.run", "task": None,
|
|
1570
|
+
"outcome": None, "steps": None, "summary": None,
|
|
1571
|
+
"services": sorted({p["service"] for p in progress if p.get("service")}),
|
|
1572
|
+
"start_unix_nano": start, "end_unix_nano": end,
|
|
1573
|
+
"duration_ms": round((end - start) / 1e6, 3), "status": "RUNNING",
|
|
1574
|
+
"span_count": 0, "input_tokens": 0, "output_tokens": 0,
|
|
1575
|
+
"known_cost_usd": 0.0, "cost_complete": False, "models": []}
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def apply_outcome(summary, outcome):
|
|
1579
|
+
summary["task_id"] = outcome["task_id"]
|
|
1580
|
+
summary["outcome_passed"] = outcome["passed"]
|
|
1581
|
+
if not outcome["passed"] and summary["status"] != "ERROR":
|
|
1582
|
+
summary["status"] = "FAILED"
|
|
1583
|
+
elif outcome["passed"] and summary["status"] == "RUNNING":
|
|
1584
|
+
summary["status"] = "OK"
|
|
1585
|
+
return summary
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def summarize(spans):
|
|
1589
|
+
roots = [s for s in spans if not s["parent_span_id"]]
|
|
1590
|
+
root = min(roots or spans, key=lambda s: s["start_unix_nano"])
|
|
1591
|
+
starts = [s["start_unix_nano"] for s in spans]
|
|
1592
|
+
ends = [s["end_unix_nano"] for s in spans]
|
|
1593
|
+
# Run-level totals duplicate child LLM usage; only count leaf model calls.
|
|
1594
|
+
usage_keys = ("gen_ai.usage.input_tokens", "llm.usage.input_tokens",
|
|
1595
|
+
"gen_ai.usage.output_tokens", "llm.usage.output_tokens",
|
|
1596
|
+
"llm.token_count.prompt", "llm.token_count.completion")
|
|
1597
|
+
llms = [s for s in spans if s["name"] != "agent.run" and
|
|
1598
|
+
(any(k in s["attributes"] for k in usage_keys) or
|
|
1599
|
+
any(k.startswith("llm.cost.") for k in s["attributes"]) or
|
|
1600
|
+
"llm.cost_usd" in s["attributes"] or "gen_ai.usage.cost" in s["attributes"])]
|
|
1601
|
+
def numeric(a, key):
|
|
1602
|
+
try:
|
|
1603
|
+
value = float(a[key])
|
|
1604
|
+
return value if math.isfinite(value) else None
|
|
1605
|
+
except (KeyError, TypeError, ValueError, OverflowError):
|
|
1606
|
+
return None
|
|
1607
|
+
def num(a, keys):
|
|
1608
|
+
for key in keys:
|
|
1609
|
+
if key in a:
|
|
1610
|
+
value = numeric(a, key)
|
|
1611
|
+
return value if value is not None else 0
|
|
1612
|
+
return 0
|
|
1613
|
+
def cost_info(attrs):
|
|
1614
|
+
for key in ("llm.cost.total", "llm.cost_usd", "gen_ai.usage.cost"):
|
|
1615
|
+
if key in attrs:
|
|
1616
|
+
value = numeric(attrs, key)
|
|
1617
|
+
return value, value is not None and attrs.get("llm.cost_known", True)
|
|
1618
|
+
component_keys = ("llm.cost.prompt", "llm.cost.completion")
|
|
1619
|
+
if any(key in attrs for key in component_keys):
|
|
1620
|
+
values = [numeric(attrs, key) for key in component_keys]
|
|
1621
|
+
valid = [value for value in values if value is not None]
|
|
1622
|
+
return (sum(valid) if valid else None,
|
|
1623
|
+
len(valid) == len(component_keys) and attrs.get("llm.cost_known", True))
|
|
1624
|
+
return None, False
|
|
1625
|
+
input_tokens = sum(num(s["attributes"], ("gen_ai.usage.input_tokens", "llm.usage.input_tokens", "llm.token_count.prompt")) for s in llms)
|
|
1626
|
+
output_tokens = sum(num(s["attributes"], ("gen_ai.usage.output_tokens", "llm.usage.output_tokens", "llm.token_count.completion")) for s in llms)
|
|
1627
|
+
call_costs = [cost_info(s["attributes"]) for s in llms]
|
|
1628
|
+
cost_complete = len(call_costs) == len(llms) and all(complete for _, complete in call_costs)
|
|
1629
|
+
outcome = root["attributes"].get("agent.stopped_reason")
|
|
1630
|
+
state = ("ERROR" if any(s["status"] == "ERROR" or
|
|
1631
|
+
s["attributes"].get("tool.result_status") == "error" or
|
|
1632
|
+
bool(s["attributes"].get("tool.error")) for s in spans) else
|
|
1633
|
+
"INCOMPLETE" if outcome and outcome != "completed" else
|
|
1634
|
+
"RUNNING" if any(s["name"] == "agent.run" and s["attributes"].get("agent.run.state") == "running" for s in spans) else "OK")
|
|
1635
|
+
return {"trace_id": root["trace_id"], "name": root["name"],
|
|
1636
|
+
"active": any(s['name']=='agent.run' and s['attributes'].get('agent.run.state')=='running' for s in spans),
|
|
1637
|
+
"task": root["attributes"].get("agent.task"),
|
|
1638
|
+
"outcome": outcome, "steps": root["attributes"].get("agent.steps"),
|
|
1639
|
+
"summary": root["attributes"].get("agent.summary"),
|
|
1640
|
+
"services": sorted({s["service"] for s in spans}),
|
|
1641
|
+
"start_unix_nano": min(starts), "end_unix_nano": max(ends),
|
|
1642
|
+
"duration_ms": round((max(ends) - min(starts)) / 1e6, 3),
|
|
1643
|
+
"status": state,
|
|
1644
|
+
"span_count": len(spans), "input_tokens": int(input_tokens),
|
|
1645
|
+
"output_tokens": int(output_tokens),
|
|
1646
|
+
"known_cost_usd": round(sum(value for value, _ in call_costs if value is not None), 8),
|
|
1647
|
+
"cost_complete": cost_complete,
|
|
1648
|
+
"models": sorted({str(model) for s in spans
|
|
1649
|
+
if (model := s["attributes"].get("gen_ai.request.model") or
|
|
1650
|
+
s["attributes"].get("llm.response.model_name") or
|
|
1651
|
+
s["attributes"].get("llm.request.model_name") or
|
|
1652
|
+
s["attributes"].get("llm.model_name") or
|
|
1653
|
+
s["attributes"].get("embedding.model_name") or
|
|
1654
|
+
s["attributes"].get("llm.model")) is not None})}
|