agent-observatory-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_observatory_cli-0.1.0.dist-info/METADATA +115 -0
- agent_observatory_cli-0.1.0.dist-info/RECORD +31 -0
- agent_observatory_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_observatory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_observatory_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_observatory_cli-0.1.0.dist-info/top_level.txt +1 -0
- observatory/__init__.py +5 -0
- observatory/__main__.py +105 -0
- observatory/activity.css +10 -0
- observatory/activity.js +116 -0
- observatory/auto.py +212 -0
- observatory/client.py +253 -0
- observatory/core.py +1654 -0
- observatory/distribution.py +67 -0
- observatory/findings.py +176 -0
- observatory/index.html +84 -0
- observatory/integrations/__init__.py +1 -0
- observatory/integrations/langchain.py +234 -0
- observatory/integrations/openai_agents.py +177 -0
- observatory/local_activity.py +207 -0
- observatory/local_agents.py +394 -0
- observatory/local_runtime.py +49 -0
- observatory/logs.py +109 -0
- observatory/metrics.py +281 -0
- observatory/otlp_proto.py +210 -0
- observatory/server.py +368 -0
- observatory/setup.css +40 -0
- observatory/setup.js +145 -0
- observatory/terminal.py +525 -0
- observatory/terminal_ui.py +1299 -0
- observatory/watch.py +62 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""LangChain and LangGraph callback adapter for Observatory traces."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
9
|
+
except ImportError as exc: # pragma: no cover - depends on optional integration extra
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"LangChainCallbackHandler requires the optional dependency: "
|
|
12
|
+
"pip install 'agent-observatory-cli[langchain]'"
|
|
13
|
+
) from exc
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LangChainCallbackHandler(BaseCallbackHandler):
|
|
17
|
+
"""Capture chain, tool, retriever, and model callbacks inside one active run.
|
|
18
|
+
|
|
19
|
+
Attach an instance to a :class:`observatory.client.Run` before invoking a
|
|
20
|
+
LangChain runnable or LangGraph app. Prompt, tool input, and output content
|
|
21
|
+
are omitted unless ``capture_content=True``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, capture_content=False, max_content_chars=1000):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.capture_content = bool(capture_content)
|
|
27
|
+
self.max_content_chars = max(1, int(max_content_chars))
|
|
28
|
+
self.run = None
|
|
29
|
+
self._spans = {}
|
|
30
|
+
|
|
31
|
+
def attach(self, run):
|
|
32
|
+
if self.run is not None:
|
|
33
|
+
raise RuntimeError("callback handler is already attached to a run")
|
|
34
|
+
if run._closed:
|
|
35
|
+
raise RuntimeError("cannot attach to a closed Observatory run")
|
|
36
|
+
self.run = run
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def _content(self, value):
|
|
40
|
+
return str(value)[:self.max_content_chars]
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _run_key(run_id):
|
|
44
|
+
return str(run_id) if run_id is not None else None
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _name(serialized, kwargs, fallback):
|
|
48
|
+
return str((serialized or {}).get("name") or kwargs.get("name") or fallback)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _model(serialized, kwargs):
|
|
52
|
+
values = [kwargs.get("invocation_params"), (serialized or {}).get("kwargs"), kwargs]
|
|
53
|
+
for mapping in values:
|
|
54
|
+
if not isinstance(mapping, Mapping):
|
|
55
|
+
continue
|
|
56
|
+
for key in ("model", "model_name", "model_id", "deployment_name"):
|
|
57
|
+
value = mapping.get(key)
|
|
58
|
+
if value:
|
|
59
|
+
return str(value)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def _start(self, kind, name, run_id, parent_run_id=None, attributes=None):
|
|
63
|
+
if self.run is None:
|
|
64
|
+
return None
|
|
65
|
+
parent = self._spans.get(self._run_key(parent_run_id))
|
|
66
|
+
parent_span_id = parent.span_id if parent else self.run.root.span_id
|
|
67
|
+
attrs = {"langchain.run_type": kind}
|
|
68
|
+
attrs.update(attributes or {})
|
|
69
|
+
span = self.run.span(name, attributes=attrs, parent_span_id=parent_span_id)
|
|
70
|
+
key = self._run_key(run_id)
|
|
71
|
+
if key is not None:
|
|
72
|
+
self._spans[key] = span
|
|
73
|
+
return span
|
|
74
|
+
|
|
75
|
+
def _finish(self, run_id, error=None, attributes=None):
|
|
76
|
+
span = self._spans.get(self._run_key(run_id))
|
|
77
|
+
if span is None:
|
|
78
|
+
return
|
|
79
|
+
for key, value in (attributes or {}).items():
|
|
80
|
+
span.set_attribute(key, value)
|
|
81
|
+
span.finish(error)
|
|
82
|
+
|
|
83
|
+
def _capture_output(self, span, output):
|
|
84
|
+
if self.capture_content and span is not None:
|
|
85
|
+
span.set_attribute("langchain.output", self._content(output))
|
|
86
|
+
|
|
87
|
+
def on_chain_start(self, serialized, inputs, *, run_id, parent_run_id=None,
|
|
88
|
+
tags=None, metadata=None, **kwargs):
|
|
89
|
+
attrs = {}
|
|
90
|
+
if tags:
|
|
91
|
+
attrs["langchain.tags"] = [str(tag) for tag in tags]
|
|
92
|
+
if self.capture_content:
|
|
93
|
+
attrs["langchain.input"] = self._content(inputs)
|
|
94
|
+
self._start("chain", self._name(serialized, kwargs, "chain"),
|
|
95
|
+
run_id, parent_run_id, attrs)
|
|
96
|
+
|
|
97
|
+
def on_chain_end(self, outputs, *, run_id, **kwargs):
|
|
98
|
+
span = self._spans.get(self._run_key(run_id))
|
|
99
|
+
self._capture_output(span, outputs)
|
|
100
|
+
self._finish(run_id)
|
|
101
|
+
|
|
102
|
+
def on_chain_error(self, error, *, run_id, **kwargs):
|
|
103
|
+
self._finish(run_id, error)
|
|
104
|
+
|
|
105
|
+
def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None,
|
|
106
|
+
tags=None, metadata=None, **kwargs):
|
|
107
|
+
name = self._name(serialized, kwargs, "tool")
|
|
108
|
+
attrs = {"tool.name": name}
|
|
109
|
+
if self.capture_content:
|
|
110
|
+
attrs["tool.input"] = self._content(input_str)
|
|
111
|
+
self._start("tool", f"tool.{name}", run_id, parent_run_id, attrs)
|
|
112
|
+
|
|
113
|
+
def on_tool_end(self, output, *, run_id, **kwargs):
|
|
114
|
+
span = self._spans.get(self._run_key(run_id))
|
|
115
|
+
self._capture_output(span, output)
|
|
116
|
+
self._finish(run_id)
|
|
117
|
+
|
|
118
|
+
def on_tool_error(self, error, *, run_id, **kwargs):
|
|
119
|
+
self._finish(run_id, error)
|
|
120
|
+
|
|
121
|
+
def on_retriever_start(self, serialized, query, *, run_id, parent_run_id=None,
|
|
122
|
+
tags=None, metadata=None, **kwargs):
|
|
123
|
+
attrs = {}
|
|
124
|
+
if self.capture_content:
|
|
125
|
+
attrs["retrieval.query"] = self._content(query)
|
|
126
|
+
self._start("retriever", self._name(serialized, kwargs, "retriever.search"),
|
|
127
|
+
run_id, parent_run_id, attrs)
|
|
128
|
+
|
|
129
|
+
def on_retriever_end(self, documents, *, run_id, **kwargs):
|
|
130
|
+
span = self._spans.get(self._run_key(run_id))
|
|
131
|
+
if span is not None:
|
|
132
|
+
span.set_attribute("retrieval.document_count", len(documents))
|
|
133
|
+
self._finish(run_id)
|
|
134
|
+
|
|
135
|
+
def on_retriever_error(self, error, *, run_id, **kwargs):
|
|
136
|
+
self._finish(run_id, error)
|
|
137
|
+
|
|
138
|
+
def on_llm_start(self, serialized, prompts, *, run_id, parent_run_id=None,
|
|
139
|
+
tags=None, metadata=None, **kwargs):
|
|
140
|
+
attrs = {"gen_ai.operation.name": "text_completion"}
|
|
141
|
+
model = self._model(serialized, kwargs)
|
|
142
|
+
if model:
|
|
143
|
+
attrs["gen_ai.request.model"] = model
|
|
144
|
+
if self.capture_content:
|
|
145
|
+
attrs["langchain.input"] = self._content(prompts)
|
|
146
|
+
self._start("llm", "llm.call", run_id, parent_run_id, attrs)
|
|
147
|
+
|
|
148
|
+
def on_chat_model_start(self, serialized, messages, *, run_id, parent_run_id=None,
|
|
149
|
+
tags=None, metadata=None, **kwargs):
|
|
150
|
+
attrs = {"gen_ai.operation.name": "chat"}
|
|
151
|
+
model = self._model(serialized, kwargs)
|
|
152
|
+
if model:
|
|
153
|
+
attrs["gen_ai.request.model"] = model
|
|
154
|
+
if self.capture_content:
|
|
155
|
+
attrs["langchain.input"] = self._content(messages)
|
|
156
|
+
self._start("llm", "chat.call", run_id, parent_run_id, attrs)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _token_usage(response):
|
|
160
|
+
generations = getattr(response, "generations", None) or []
|
|
161
|
+
totals = {"input_tokens": 0, "output_tokens": 0}
|
|
162
|
+
found = False
|
|
163
|
+
for group in generations:
|
|
164
|
+
for generation in group[:1]:
|
|
165
|
+
message = getattr(generation, "message", None)
|
|
166
|
+
usage = getattr(message, "usage_metadata", None)
|
|
167
|
+
if isinstance(usage, Mapping):
|
|
168
|
+
for key in totals:
|
|
169
|
+
value = usage.get(key)
|
|
170
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
|
|
171
|
+
totals[key] += int(value)
|
|
172
|
+
found = True
|
|
173
|
+
if found:
|
|
174
|
+
return totals
|
|
175
|
+
output = getattr(response, "llm_output", None) or {}
|
|
176
|
+
if not isinstance(output, Mapping):
|
|
177
|
+
return {}
|
|
178
|
+
usage = output.get("token_usage") or output.get("usage") or {}
|
|
179
|
+
if not isinstance(usage, Mapping):
|
|
180
|
+
return {}
|
|
181
|
+
return {"input_tokens": usage.get("input_tokens", usage.get("prompt_tokens")),
|
|
182
|
+
"output_tokens": usage.get("output_tokens", usage.get("completion_tokens"))}
|
|
183
|
+
|
|
184
|
+
def on_llm_end(self, response, *, run_id, **kwargs):
|
|
185
|
+
span = self._spans.get(self._run_key(run_id))
|
|
186
|
+
if span is None:
|
|
187
|
+
return
|
|
188
|
+
usage = self._token_usage(response)
|
|
189
|
+
for source, target in (("input_tokens", "gen_ai.usage.input_tokens"),
|
|
190
|
+
("output_tokens", "gen_ai.usage.output_tokens")):
|
|
191
|
+
value = usage.get(source)
|
|
192
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
|
|
193
|
+
span.set_attribute(target, int(value))
|
|
194
|
+
if self.capture_content:
|
|
195
|
+
self._capture_output(span, response)
|
|
196
|
+
self._finish(run_id)
|
|
197
|
+
|
|
198
|
+
def on_llm_error(self, error, *, run_id, **kwargs):
|
|
199
|
+
self._finish(run_id, error)
|
|
200
|
+
|
|
201
|
+
def on_agent_action(self, action, *, run_id, **kwargs):
|
|
202
|
+
span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
|
|
203
|
+
if span is not None:
|
|
204
|
+
attrs = {"tool.name": str(getattr(action, "tool", "agent.action"))}
|
|
205
|
+
if self.capture_content:
|
|
206
|
+
attrs["tool.input"] = self._content(getattr(action, "tool_input", ""))
|
|
207
|
+
span.add_event("agent.action", attrs)
|
|
208
|
+
|
|
209
|
+
def on_agent_finish(self, finish, *, run_id, **kwargs):
|
|
210
|
+
span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
|
|
211
|
+
if span is not None:
|
|
212
|
+
span.add_event("agent.finish")
|
|
213
|
+
|
|
214
|
+
def on_custom_event(self, name, data, *, run_id=None, **kwargs):
|
|
215
|
+
span = self._spans.get(self._run_key(run_id)) or (self.run.root if self.run else None)
|
|
216
|
+
event_name = str(name)
|
|
217
|
+
if span is not None:
|
|
218
|
+
attrs = {"event.name": event_name}
|
|
219
|
+
if self.capture_content:
|
|
220
|
+
attrs["event.data"] = self._content(data)
|
|
221
|
+
span.add_event("langchain.custom", attrs)
|
|
222
|
+
if self.run is None or event_name.lower() not in {
|
|
223
|
+
"progress", "agent.progress", "langgraph.progress"}:
|
|
224
|
+
return
|
|
225
|
+
progress = data if isinstance(data, Mapping) else {}
|
|
226
|
+
stage = progress.get("stage") or progress.get("agent.progress.stage") or "agent"
|
|
227
|
+
message = progress.get("message") or ""
|
|
228
|
+
percent = progress.get("percent", progress.get("agent.progress.percent"))
|
|
229
|
+
self.run.progress(str(stage), str(message), percent)
|
|
230
|
+
|
|
231
|
+
def on_retry(self, retry_state, *, run_id, **kwargs):
|
|
232
|
+
span = self._spans.get(self._run_key(run_id))
|
|
233
|
+
if span is not None:
|
|
234
|
+
span.add_event("langchain.retry")
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""OpenAI Agents SDK tracing exporter for Observatory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from agents.tracing.processor_interface import TracingExporter
|
|
13
|
+
except ImportError as exc: # pragma: no cover - optional integration dependency
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"OpenAIAgentsExporter requires the optional dependency: "
|
|
16
|
+
"pip install 'agent-observatory-cli[openai-agents]'"
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_SAFE_TEXT_KEYS = {"type", "name", "model", "model_name", "model_id", "provider",
|
|
21
|
+
"agent_name", "tool_name", "handoff_name", "operation"}
|
|
22
|
+
_TOKEN_KEYS = {"input_tokens", "output_tokens", "prompt_tokens", "completion_tokens",
|
|
23
|
+
"total_tokens", "cached_input_tokens", "reasoning_tokens"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _stable_id(value: Any, length: int) -> str:
|
|
27
|
+
"""Convert SDK IDs (which may be prefixed) to valid, stable hex IDs."""
|
|
28
|
+
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _timestamp_ns(value: str | None) -> int:
|
|
32
|
+
if not value:
|
|
33
|
+
return 0
|
|
34
|
+
try:
|
|
35
|
+
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1_000_000_000)
|
|
36
|
+
except (TypeError, ValueError, OverflowError):
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_attributes(data: Any, span_kind: str, exported=None) -> dict[str, Any]:
|
|
41
|
+
"""Pick identifying fields and token counts; omit inputs, outputs, and prompts."""
|
|
42
|
+
if exported is None:
|
|
43
|
+
exported = data.export() if callable(getattr(data, "export", None)) else {}
|
|
44
|
+
if not isinstance(exported, Mapping):
|
|
45
|
+
return {}
|
|
46
|
+
result: dict[str, Any] = {}
|
|
47
|
+
|
|
48
|
+
def visit(value: Any, prefix: str = "") -> None:
|
|
49
|
+
if not isinstance(value, Mapping):
|
|
50
|
+
return
|
|
51
|
+
for raw_key, item in value.items():
|
|
52
|
+
key = str(raw_key)
|
|
53
|
+
normalized = key.lower()
|
|
54
|
+
if isinstance(item, Mapping):
|
|
55
|
+
visit(item, f"{prefix}{key}.")
|
|
56
|
+
elif normalized in _SAFE_TEXT_KEYS and isinstance(item, (str, int, float, bool)):
|
|
57
|
+
if normalized in {"model", "model_name", "model_id"}:
|
|
58
|
+
result.setdefault("gen_ai.request.model", item)
|
|
59
|
+
elif normalized == "provider":
|
|
60
|
+
result["gen_ai.provider.name"] = item
|
|
61
|
+
elif normalized == "agent_name" or (normalized == "name" and span_kind == "agent"):
|
|
62
|
+
result["gen_ai.agent.name"] = item
|
|
63
|
+
elif normalized == "name" and span_kind == "function":
|
|
64
|
+
result["tool.name"] = item
|
|
65
|
+
elif normalized == "tool_name":
|
|
66
|
+
result["tool.name"] = item
|
|
67
|
+
else:
|
|
68
|
+
result[f"openai_agents.{prefix}{key}"] = item
|
|
69
|
+
elif (span_kind == "generation" and normalized in _TOKEN_KEYS and
|
|
70
|
+
isinstance(item, (int, float)) and not isinstance(item, bool) and item >= 0):
|
|
71
|
+
token_name = {"prompt_tokens": "input_tokens", "completion_tokens": "output_tokens"}.get(normalized, normalized)
|
|
72
|
+
result[f"gen_ai.usage.{token_name}"] = int(item)
|
|
73
|
+
|
|
74
|
+
visit(exported)
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bounded_content(value: Any, max_chars: int) -> str:
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
content = value
|
|
81
|
+
else:
|
|
82
|
+
content = json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
83
|
+
if len(content) > max_chars:
|
|
84
|
+
return content[:max_chars] + "… [truncated]"
|
|
85
|
+
return content
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OpenAIAgentsExporter(TracingExporter):
|
|
89
|
+
"""Export OpenAI Agents SDK spans to an Observatory :class:`Client`.
|
|
90
|
+
|
|
91
|
+
Pass this exporter to the SDK's ``BatchTraceProcessor`` and register that
|
|
92
|
+
processor with ``agents.tracing.add_trace_processor``. Span input/output
|
|
93
|
+
payloads and arbitrary trace metadata are intentionally omitted.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, client, *, capture_content=False, max_content_chars=4000):
|
|
97
|
+
self.client = client
|
|
98
|
+
self._trace_context: OrderedDict[str, tuple[str, str | None]] = OrderedDict()
|
|
99
|
+
self.capture_content = bool(capture_content)
|
|
100
|
+
self.max_content_chars = max(1, min(100_000, int(max_content_chars)))
|
|
101
|
+
|
|
102
|
+
def export(self, items) -> None:
|
|
103
|
+
spans = []
|
|
104
|
+
for item in items:
|
|
105
|
+
if hasattr(item, "span_data"):
|
|
106
|
+
spans.append(self._span_payload(item))
|
|
107
|
+
elif hasattr(item, "trace_id") and hasattr(item, "name"):
|
|
108
|
+
trace_id = str(item.trace_id)
|
|
109
|
+
group_id = getattr(item, "group_id", None)
|
|
110
|
+
self._trace_context[trace_id] = (str(item.name or "agent.run"),
|
|
111
|
+
str(group_id) if group_id else None)
|
|
112
|
+
self._trace_context.move_to_end(trace_id)
|
|
113
|
+
while len(self._trace_context) > 4096:
|
|
114
|
+
self._trace_context.popitem(last=False)
|
|
115
|
+
if spans:
|
|
116
|
+
post_spans = getattr(self.client, "_post_spans", None)
|
|
117
|
+
if callable(post_spans):
|
|
118
|
+
post_spans(spans)
|
|
119
|
+
else:
|
|
120
|
+
self.client._post("/api/spans", {"service": self.client.service, "spans": spans})
|
|
121
|
+
|
|
122
|
+
def _span_payload(self, span):
|
|
123
|
+
raw_trace_id = str(span.trace_id)
|
|
124
|
+
raw_span_id = str(span.span_id)
|
|
125
|
+
raw_parent_id = span.parent_id
|
|
126
|
+
data = span.span_data
|
|
127
|
+
kind = getattr(data, "type", None) or type(data).__name__.removesuffix("SpanData").lower()
|
|
128
|
+
exported = data.export() if callable(getattr(data, "export", None)) else {}
|
|
129
|
+
attrs = _safe_attributes(data, str(kind), exported)
|
|
130
|
+
if self.capture_content and isinstance(exported, Mapping):
|
|
131
|
+
for field in ("input", "output"):
|
|
132
|
+
if exported.get(field) is not None:
|
|
133
|
+
attrs[f"openai_agents.{field}"] = _bounded_content(
|
|
134
|
+
exported[field], self.max_content_chars)
|
|
135
|
+
attrs["openai_agents.trace_id"] = raw_trace_id
|
|
136
|
+
attrs["openai_agents.span_id"] = raw_span_id
|
|
137
|
+
workflow, group_id = self._trace_context.get(raw_trace_id, ("agent.run", None))
|
|
138
|
+
attrs["openai_agents.workflow"] = workflow
|
|
139
|
+
if not raw_parent_id:
|
|
140
|
+
attrs["agent.task"] = workflow
|
|
141
|
+
if group_id:
|
|
142
|
+
attrs["session.id"] = group_id
|
|
143
|
+
error = span.error
|
|
144
|
+
status = "ERROR" if error else "OK"
|
|
145
|
+
if error:
|
|
146
|
+
attrs["exception.type"] = "OpenAIAgentsError"
|
|
147
|
+
payload = {
|
|
148
|
+
"trace_id": _stable_id(raw_trace_id, 32),
|
|
149
|
+
"span_id": _stable_id(raw_span_id, 16),
|
|
150
|
+
"parent_span_id": _stable_id(raw_parent_id, 16) if raw_parent_id else None,
|
|
151
|
+
"name": f"openai_agents.{kind}",
|
|
152
|
+
"start_unix_nano": _timestamp_ns(span.started_at),
|
|
153
|
+
"end_unix_nano": _timestamp_ns(span.ended_at),
|
|
154
|
+
"status": status,
|
|
155
|
+
"attributes": attrs,
|
|
156
|
+
"events": [],
|
|
157
|
+
"resource_attributes": self.client.resource_attributes,
|
|
158
|
+
}
|
|
159
|
+
if payload["start_unix_nano"] <= 0:
|
|
160
|
+
payload["start_unix_nano"] = payload["end_unix_nano"]
|
|
161
|
+
payload["end_unix_nano"] = max(payload["start_unix_nano"], payload["end_unix_nano"])
|
|
162
|
+
return payload
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_processor(client, *, capture_content=False, max_content_chars=4000,
|
|
166
|
+
**processor_options):
|
|
167
|
+
"""Create the SDK's background batching processor for this Observatory client."""
|
|
168
|
+
try:
|
|
169
|
+
from agents.tracing.processors import BatchTraceProcessor
|
|
170
|
+
except ImportError as exc: # pragma: no cover - optional integration dependency
|
|
171
|
+
raise ImportError(
|
|
172
|
+
"create_processor requires the optional dependency: "
|
|
173
|
+
"pip install 'agent-observatory-cli[openai-agents]'"
|
|
174
|
+
) from exc
|
|
175
|
+
exporter = OpenAIAgentsExporter(client, capture_content=capture_content,
|
|
176
|
+
max_content_chars=max_content_chars)
|
|
177
|
+
return BatchTraceProcessor(exporter, **processor_options)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Bounded, redacted activity extracted from native agent session records."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .findings import _safe_text, _SENSITIVE_FIELD, _TOKEN_FIELD
|
|
10
|
+
|
|
11
|
+
CONTENT_LIMIT = 8000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe(value, limit=CONTENT_LIMIT):
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
value = {str(key): "[REDACTED]" if _SENSITIVE_FIELD.search(str(key)) or _TOKEN_FIELD.search(str(key))
|
|
17
|
+
else "[opaque payload omitted]" if str(key) in {"encrypted_content", "signature", "raw_content"} or str(key) == "data" and isinstance(child, str) and len(child) > 1000
|
|
18
|
+
else safe(child, 2000) for key, child in list(value.items())[:60]}
|
|
19
|
+
value = json.dumps(value, ensure_ascii=False, indent=2)
|
|
20
|
+
elif isinstance(value, list):
|
|
21
|
+
value = json.dumps([safe(child, 2000) for child in value[:60]], ensure_ascii=False, indent=2)
|
|
22
|
+
text = _safe_text(value)
|
|
23
|
+
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
|
|
24
|
+
text = "".join(char for char in text if char in "\n\t" or ord(char) >= 32 and ord(char) != 127)
|
|
25
|
+
return text[:limit] + ("\n[truncated]" if len(text) > limit else "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def plaintext(content):
|
|
29
|
+
if isinstance(content, str):
|
|
30
|
+
return safe(content)
|
|
31
|
+
if not isinstance(content, list):
|
|
32
|
+
return ""
|
|
33
|
+
return safe("\n".join(str(block.get("text") or "") for block in content
|
|
34
|
+
if isinstance(block, dict) and block.get("type") in {"text", "input_text", "output_text"}))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def emit(session, row, stamp, domain, action, subject, detail="", result="observed", extra=None):
|
|
38
|
+
if not stamp:
|
|
39
|
+
return None
|
|
40
|
+
from .local_agents import identity
|
|
41
|
+
attrs = {"session.id": session.session_id, "observatory.project": session.project,
|
|
42
|
+
"observatory.data_source": "local-session-log", "agent.activity.domain": domain,
|
|
43
|
+
"agent.activity.action": action, "agent.activity.subject": safe(subject, 500),
|
|
44
|
+
"agent.activity.result": result, "agent.activity.actor": session.runtime,
|
|
45
|
+
"observatory.record_id": identity(session.source_id, row.get("ordinal") or row.get("uuid") or stamp,
|
|
46
|
+
domain, action, str(subject)[:500]), **(extra or {})}
|
|
47
|
+
payload = row.get("payload") or {}
|
|
48
|
+
item = payload.get("item") or {} if isinstance(payload, dict) else {}
|
|
49
|
+
span_id = None
|
|
50
|
+
if isinstance(item, dict) and item.get("type") in {"CommandExecution", "FileChange", "McpToolCall", "Extension"}:
|
|
51
|
+
span_id = identity(session.source_id, ("tool", item.get("id") or row.get("ordinal") or stamp), size=16)
|
|
52
|
+
return {"trace_id": identity(session.source_id, session.turn), "span_id": span_id,
|
|
53
|
+
"time_unix_nano": stamp, "service": session.service,
|
|
54
|
+
"severity_number": 17 if result in {"error", "failed", "denied"} else 9,
|
|
55
|
+
"severity_text": "ERROR" if result in {"error", "failed", "denied"} else "INFO",
|
|
56
|
+
"event_name": "agent.activity", "body": safe(detail) if session.capture_details else "",
|
|
57
|
+
"attributes": attrs, "resource_attributes": {"observatory.source_id": session.source_id,
|
|
58
|
+
"observatory.capture": "local-session-log"}, "scope_name": "observatory.local_agents"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def duration(item):
|
|
62
|
+
value = item.get("duration")
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
try:
|
|
65
|
+
return float(value.get("secs", 0)) * 1000 + float(value.get("nanos", 0))/1e6
|
|
66
|
+
except (ValueError, TypeError):
|
|
67
|
+
pass
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def codex_activity(session, row, stamp):
|
|
72
|
+
payload = row.get("payload") or {}
|
|
73
|
+
if not isinstance(payload, dict):
|
|
74
|
+
return []
|
|
75
|
+
kind = row.get("type")
|
|
76
|
+
event = payload.get("type")
|
|
77
|
+
out = []
|
|
78
|
+
if kind == "token_usage_record":
|
|
79
|
+
usage = payload.get("usage") or {}
|
|
80
|
+
out.append(emit(session, row, stamp, "model", "usage", session.model or "Model not reported", usage,
|
|
81
|
+
"reported", {"gen_ai.request.model": str(session.model or "unknown")}))
|
|
82
|
+
elif kind == "response_item" and event in {"function_call", "custom_tool_call"}:
|
|
83
|
+
name = str(payload.get("name") or "Tool")
|
|
84
|
+
args = payload.get("arguments") or payload.get("input") or ""
|
|
85
|
+
try:
|
|
86
|
+
parsed = json.loads(args) if isinstance(args, str) else args
|
|
87
|
+
except (ValueError, TypeError):
|
|
88
|
+
parsed = {}
|
|
89
|
+
session.tool_inputs[str(payload.get("call_id") or payload.get("id"))] = (name, safe(args))
|
|
90
|
+
subject = name
|
|
91
|
+
domain = "tool"
|
|
92
|
+
if isinstance(parsed, dict):
|
|
93
|
+
command = parsed.get("cmd") or parsed.get("command")
|
|
94
|
+
if command:
|
|
95
|
+
domain, subject = "shell", str(command)
|
|
96
|
+
elif parsed.get("url"):
|
|
97
|
+
domain, subject = "browser", str(parsed["url"])
|
|
98
|
+
elif parsed.get("path"):
|
|
99
|
+
domain, subject = "file", str(parsed["path"])
|
|
100
|
+
out.append(emit(session, row, stamp, domain, "call", subject if session.capture_details else name,
|
|
101
|
+
args, "started", {"tool.name": name[:128]}))
|
|
102
|
+
elif kind == "response_item" and event in {"function_call_output", "custom_tool_call_output"}:
|
|
103
|
+
key = str(payload.get("call_id") or payload.get("id"))
|
|
104
|
+
name, _ = session.tool_inputs.get(key, ("Tool", ""))
|
|
105
|
+
out.append(emit(session, row, stamp, "tool", "result", name, payload.get("output") or "", "observed"))
|
|
106
|
+
elif kind == "event_msg" and event in {"task_started", "task_complete", "task_aborted"}:
|
|
107
|
+
action = "started" if event == "task_started" else "completed" if event == "task_complete" else "interrupted"
|
|
108
|
+
out.append(emit(session, row, stamp, "agent", action, session.project or session.runtime,
|
|
109
|
+
{key: payload[key] for key in ("duration_ms", "time_to_first_token_ms") if key in payload}, action))
|
|
110
|
+
elif kind == "turn_context":
|
|
111
|
+
# Only selected settings, never the system instructions or environment.
|
|
112
|
+
settings = {key: payload[key] for key in ("model", "effort", "approval_policy", "sandbox_policy") if key in payload}
|
|
113
|
+
out.append(emit(session, row, stamp, "context", "settings", session.model or "Agent settings", settings))
|
|
114
|
+
elif kind == "event_msg" and event == "item_completed":
|
|
115
|
+
item = payload.get("item") or {}
|
|
116
|
+
if not isinstance(item, dict):
|
|
117
|
+
return []
|
|
118
|
+
item_type = item.get("type")
|
|
119
|
+
extra = {"observatory.item_type": item_type}
|
|
120
|
+
elapsed = duration(item)
|
|
121
|
+
if elapsed is not None:
|
|
122
|
+
extra["duration_ms"] = elapsed
|
|
123
|
+
failed = str(item.get("status", "")).lower() in {"failed", "error", "declined", "denied"}
|
|
124
|
+
result = "error" if failed else "success"
|
|
125
|
+
if item_type == "CommandExecution":
|
|
126
|
+
command = item.get("command") or []
|
|
127
|
+
command = shlex.join(map(str, command)) if isinstance(command, list) else str(command)
|
|
128
|
+
code = item.get("exit_code")
|
|
129
|
+
if isinstance(code, int):
|
|
130
|
+
extra["process.exit.code"] = code
|
|
131
|
+
if code:
|
|
132
|
+
result = "error"
|
|
133
|
+
out.append(emit(session, row, stamp, "shell", "execute", command if session.capture_details else "Shell command",
|
|
134
|
+
{"command": command, "cwd": item.get("cwd"), "exit_code": code, "output": item.get("aggregated_output", "")}, result, extra))
|
|
135
|
+
elif item_type == "FileChange":
|
|
136
|
+
changes = item.get("changes") or {}
|
|
137
|
+
if isinstance(changes, dict):
|
|
138
|
+
for path, change in list(changes.items())[:60]:
|
|
139
|
+
operation = change.get("type") or change.get("kind") or "change" if isinstance(change, dict) else "change"
|
|
140
|
+
if isinstance(operation, dict):
|
|
141
|
+
operation = next(iter(operation), "change")
|
|
142
|
+
operation = str(operation)
|
|
143
|
+
out.append(emit(session, row, stamp, "file", operation[:40], path if session.capture_details else Path(path).name,
|
|
144
|
+
change, result, extra))
|
|
145
|
+
elif item_type == "Extension":
|
|
146
|
+
action = item.get("action") or {}
|
|
147
|
+
name = str(action.get("type") or item.get("kind") or "activity") if isinstance(action, dict) else str(item.get("kind") or "activity")
|
|
148
|
+
subject = action.get("url") or item.get("query") or name if isinstance(action, dict) else name
|
|
149
|
+
out.append(emit(session, row, stamp, "browser", name, subject if session.capture_details else name,
|
|
150
|
+
{"action": action, "query": item.get("query"), "results": item.get("results")}, result, extra))
|
|
151
|
+
elif item_type == "McpToolCall":
|
|
152
|
+
name = str(item.get("tool") or "MCP tool")
|
|
153
|
+
extra["mcp.server.name"] = str(item.get("server") or "")[:128]
|
|
154
|
+
extra["mcp.tool.name"] = name
|
|
155
|
+
extra["mcp.method.name"] = "tools/call"
|
|
156
|
+
details = {"arguments": item.get("arguments"), "result": item.get("result")}
|
|
157
|
+
if isinstance(item.get("result"), dict) and item["result"].get("isError"):
|
|
158
|
+
result = "error"
|
|
159
|
+
out.append(emit(session, row, stamp, "mcp", name, str(item.get("server") or "MCP"), details, result, extra))
|
|
160
|
+
elif item_type in {"UserMessage", "AgentMessage"}:
|
|
161
|
+
text = "" if session.internal else plaintext(item.get("content"))
|
|
162
|
+
role = "user" if item_type == "UserMessage" else "assistant"
|
|
163
|
+
if role == "user" and text and session.capture_details:
|
|
164
|
+
session.tasks[session.turn] = text[:4000]
|
|
165
|
+
out.append(emit(session, row, stamp, "message", role,
|
|
166
|
+
"Approval review" if session.internal else text.splitlines()[0][:300] if text and session.capture_details else role + " message", text,
|
|
167
|
+
"observed", {"message.role": role, "message.phase": item.get("phase") or ""}))
|
|
168
|
+
elif item_type in {"Reasoning", "ContextCompaction"}:
|
|
169
|
+
out.append(emit(session, row, stamp, "agent", "processing" if item_type == "Reasoning" else "compaction",
|
|
170
|
+
"Model processing" if item_type == "Reasoning" else "Context compacted"))
|
|
171
|
+
return [entry for entry in out if entry]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def claude_activity(session, row, stamp):
|
|
175
|
+
message = row.get("message") or {}
|
|
176
|
+
if not isinstance(message, dict):
|
|
177
|
+
return []
|
|
178
|
+
content = message.get("content") or []
|
|
179
|
+
blocks = content if isinstance(content, list) else []
|
|
180
|
+
out = []
|
|
181
|
+
if row.get("type") == "assistant" and isinstance(message.get("usage"), dict):
|
|
182
|
+
out.append(emit(session, row, stamp, "model", "usage", session.model or "Model not reported", message["usage"], "reported"))
|
|
183
|
+
text = plaintext(content)
|
|
184
|
+
if text and row.get("type") in {"assistant", "user"}:
|
|
185
|
+
role = row["type"]
|
|
186
|
+
if role == "user" and session.capture_details:
|
|
187
|
+
session.tasks[session.turn] = text[:4000]
|
|
188
|
+
out.append(emit(session, row, stamp, "message", role, text.splitlines()[0][:300] if session.capture_details else role+" message", text))
|
|
189
|
+
for block in blocks:
|
|
190
|
+
if not isinstance(block, dict):
|
|
191
|
+
continue
|
|
192
|
+
if block.get("type") == "tool_use":
|
|
193
|
+
name, args = str(block.get("name") or "Tool"), block.get("input") or {}
|
|
194
|
+
domain = "shell" if name.lower() in {"bash", "shell", "terminal"} else "file" if name.lower() in {"read", "write", "edit", "glob", "grep", "multiedit"} else "browser" if name.lower() in {"webfetch", "websearch"} else "tool"
|
|
195
|
+
subject = args.get("command") or args.get("file_path") or args.get("path") or args.get("url") or args.get("query") or name if isinstance(args, dict) else name
|
|
196
|
+
session.tool_inputs[str(block.get("id"))] = (name, safe(subject, 500))
|
|
197
|
+
out.append(emit(session, row, stamp, domain, name, subject if session.capture_details else name, args, "started", {"tool.name": name}))
|
|
198
|
+
elif block.get("type") == "tool_result":
|
|
199
|
+
name, subject = session.tool_inputs.get(str(block.get("tool_use_id")), ("Tool", "Tool result"))
|
|
200
|
+
out.append(emit(session, row, stamp, "tool", name+" result", subject if session.capture_details else name,
|
|
201
|
+
block.get("content") or "", "error" if block.get("is_error") else "success"))
|
|
202
|
+
elif block.get("type") == "thinking":
|
|
203
|
+
out.append(emit(session, row, stamp, "agent", "processing", "Model processing"))
|
|
204
|
+
if row.get("type") == "system":
|
|
205
|
+
out.append(emit(session, row, stamp, "context", str(row.get("subtype") or "event"), "Session event",
|
|
206
|
+
{key:row[key] for key in ("durationMs", "messageCount", "isMeta") if key in row}))
|
|
207
|
+
return [entry for entry in out if entry]
|