Skip to content

Commit 386cf1e

Browse files
Merge pull request #14 from agentevals-dev/feature/bug-report
Implement bug-report feature
2 parents 65e47f1 + aaa8d3e commit 386cf1e

10 files changed

Lines changed: 976 additions & 38 deletions

File tree

src/agentevals/api/app.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from fastapi.responses import StreamingResponse
1212

1313
from .routes import router
14+
from .debug_routes import debug_router, set_trace_manager as set_debug_trace_manager
15+
from ..utils.log_buffer import log_buffer
1416
from agentevals import __version__
1517

1618
try:
@@ -37,6 +39,7 @@
3739
)
3840

3941
app.include_router(router, prefix="/api")
42+
app.include_router(debug_router, prefix="/api/debug")
4043

4144
_live_mode = os.getenv("AGENTEVALS_LIVE") == "1"
4245
_trace_manager = None
@@ -49,6 +52,7 @@
4952
app.include_router(streaming_router, prefix="/api/streaming")
5053
_trace_manager = StreamingTraceManager()
5154
set_trace_manager(_trace_manager)
55+
set_debug_trace_manager(_trace_manager)
5256

5357
@app.websocket("/ws/traces")
5458
async def websocket_endpoint(websocket: WebSocket):
@@ -106,7 +110,10 @@ async def on_startup():
106110
format="%(levelname)s:%(name)s:%(message)s",
107111
force=True,
108112
)
109-
logging.getLogger("agentevals").setLevel(log_level)
113+
ae_logger = logging.getLogger("agentevals")
114+
ae_logger.setLevel(log_level)
115+
log_buffer.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
116+
ae_logger.addHandler(log_buffer)
110117
if _trace_manager:
111118
_trace_manager.start_cleanup_task()
112119

src/agentevals/api/debug_routes.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
from __future__ import annotations
2+
3+
import glob
4+
import importlib.metadata
5+
import io
6+
import json
7+
import logging
8+
import os
9+
import platform
10+
import sys
11+
import tempfile
12+
import zipfile
13+
from datetime import datetime, timezone
14+
from typing import TYPE_CHECKING
15+
16+
from fastapi import APIRouter, UploadFile, File as FastAPIFile, HTTPException
17+
from fastapi.responses import StreamingResponse
18+
from pydantic import BaseModel
19+
20+
from agentevals import __version__
21+
from ..utils.log_buffer import log_buffer
22+
23+
if TYPE_CHECKING:
24+
from ..streaming.ws_server import StreamingTraceManager
25+
26+
logger = logging.getLogger(__name__)
27+
28+
debug_router = APIRouter()
29+
30+
_trace_manager: StreamingTraceManager | None = None
31+
32+
33+
def set_trace_manager(manager: StreamingTraceManager) -> None:
34+
global _trace_manager
35+
_trace_manager = manager
36+
37+
38+
class FrontendDiagnostics(BaseModel):
39+
user_description: str = ""
40+
browser_info: dict = {}
41+
console_logs: list[dict] = []
42+
app_state: dict = {}
43+
network_errors: list[dict] = []
44+
45+
46+
def _get_package_version(name: str) -> str | None:
47+
try:
48+
return importlib.metadata.version(name)
49+
except importlib.metadata.PackageNotFoundError:
50+
return None
51+
52+
53+
def _collect_environment() -> dict:
54+
packages = [
55+
"fastapi", "uvicorn", "google-adk", "google-genai",
56+
"opentelemetry-sdk", "opentelemetry-api", "pydantic",
57+
]
58+
return {
59+
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
60+
"agentevals_version": __version__,
61+
"python_version": sys.version,
62+
"os": platform.system(),
63+
"os_version": platform.release(),
64+
"machine": platform.machine(),
65+
"packages": {
66+
p: _get_package_version(p) for p in packages
67+
},
68+
"config": {
69+
"log_level": os.getenv("AGENTEVALS_LOG_LEVEL", "INFO"),
70+
"live_mode": os.getenv("AGENTEVALS_LIVE") == "1",
71+
},
72+
"api_keys": {
73+
"google": bool(
74+
os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
75+
),
76+
"anthropic": bool(os.getenv("ANTHROPIC_API_KEY")),
77+
"openai": bool(os.getenv("OPENAI_API_KEY")),
78+
},
79+
}
80+
81+
82+
def _collect_sessions() -> list[dict]:
83+
if not _trace_manager:
84+
return []
85+
86+
sessions_data = []
87+
for session in _trace_manager.sessions.values():
88+
sessions_data.append(
89+
{
90+
"session_id": session.session_id,
91+
"trace_id": session.trace_id,
92+
"eval_set_id": session.eval_set_id,
93+
"started_at": session.started_at.isoformat(),
94+
"is_complete": session.is_complete,
95+
"span_count": len(session.spans),
96+
"log_count": len(session.logs),
97+
"metadata": session.metadata,
98+
"spans": session.spans,
99+
"logs": session.logs,
100+
}
101+
)
102+
return sessions_data
103+
104+
105+
def _collect_temp_files() -> dict[str, str]:
106+
tmp_dir = tempfile.gettempdir()
107+
files = {}
108+
for pattern in ["agentevals_*.jsonl", "eval_set_*.json"]:
109+
for path in glob.glob(os.path.join(tmp_dir, pattern)):
110+
try:
111+
with open(path) as f:
112+
files[os.path.basename(path)] = f.read()
113+
except OSError:
114+
logger.debug("Could not read temp file %s", path)
115+
return files
116+
117+
118+
@debug_router.post("/bundle")
119+
async def create_debug_bundle(diagnostics: FrontendDiagnostics):
120+
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S")
121+
prefix = f"bug-report-{timestamp}"
122+
123+
buf = io.BytesIO()
124+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
125+
env = _collect_environment()
126+
metadata = {
127+
**env,
128+
"user_description": diagnostics.user_description,
129+
"browser_info": diagnostics.browser_info,
130+
}
131+
zf.writestr(f"{prefix}/metadata.json", json.dumps(metadata, indent=2))
132+
133+
sessions = _collect_sessions()
134+
for s in sessions:
135+
sid = s["session_id"]
136+
zf.writestr(
137+
f"{prefix}/sessions/{sid}/spans.json",
138+
json.dumps(s["spans"], indent=2),
139+
)
140+
zf.writestr(
141+
f"{prefix}/sessions/{sid}/logs.json",
142+
json.dumps(s["logs"], indent=2),
143+
)
144+
session_meta = {
145+
k: v
146+
for k, v in s.items()
147+
if k not in ("spans", "logs")
148+
}
149+
zf.writestr(
150+
f"{prefix}/sessions/{sid}/session_meta.json",
151+
json.dumps(session_meta, indent=2),
152+
)
153+
154+
zf.writestr(f"{prefix}/backend_logs.txt", log_buffer.get_text())
155+
156+
temp_files = _collect_temp_files()
157+
for filename, content in temp_files.items():
158+
zf.writestr(f"{prefix}/temp_files/{filename}", content)
159+
160+
zf.writestr(
161+
f"{prefix}/frontend_state.json",
162+
json.dumps(diagnostics.app_state, indent=2),
163+
)
164+
zf.writestr(
165+
f"{prefix}/console_logs.json",
166+
json.dumps(diagnostics.console_logs, indent=2),
167+
)
168+
zf.writestr(
169+
f"{prefix}/network_errors.json",
170+
json.dumps(diagnostics.network_errors, indent=2),
171+
)
172+
173+
buf.seek(0)
174+
return StreamingResponse(
175+
buf,
176+
media_type="application/zip",
177+
headers={
178+
"Content-Disposition": f'attachment; filename="bug-report-{timestamp}.zip"'
179+
},
180+
)
181+
182+
183+
@debug_router.post("/load")
184+
async def load_debug_bundle(file: UploadFile = FastAPIFile(...)):
185+
if not _trace_manager:
186+
raise HTTPException(
187+
status_code=400,
188+
detail="Live mode is not enabled. Start with: agentevals serve --dev",
189+
)
190+
191+
content = await file.read()
192+
try:
193+
zf = zipfile.ZipFile(io.BytesIO(content))
194+
except zipfile.BadZipFile:
195+
raise HTTPException(status_code=400, detail="Invalid ZIP file")
196+
197+
session_dirs: dict[str, list[str]] = {}
198+
for name in zf.namelist():
199+
parts = name.split("/")
200+
if len(parts) >= 4 and parts[-3] == "sessions":
201+
sid = parts[-2]
202+
session_dirs.setdefault(sid, []).append(name)
203+
204+
if not session_dirs:
205+
raise HTTPException(status_code=400, detail="No sessions found in ZIP")
206+
207+
from ..streaming.session import TraceSession
208+
209+
loaded = []
210+
for sid, files in session_dirs.items():
211+
meta_file = next((f for f in files if f.endswith("session_meta.json")), None)
212+
spans_file = next((f for f in files if f.endswith("spans.json")), None)
213+
logs_file = next((f for f in files if f.endswith("logs.json")), None)
214+
215+
if not spans_file:
216+
continue
217+
218+
meta = json.loads(zf.read(meta_file)) if meta_file else {}
219+
spans = json.loads(zf.read(spans_file))
220+
logs = json.loads(zf.read(logs_file)) if logs_file else []
221+
222+
session = TraceSession(
223+
session_id=meta.get("session_id", sid),
224+
trace_id=meta.get("trace_id", sid),
225+
eval_set_id=meta.get("eval_set_id"),
226+
spans=spans,
227+
logs=logs,
228+
is_complete=True,
229+
metadata=meta.get("metadata", {}),
230+
)
231+
232+
_trace_manager.sessions[session.session_id] = session
233+
234+
await _trace_manager.broadcast_to_ui({
235+
"type": "session_started",
236+
"session": {
237+
"sessionId": session.session_id,
238+
"traceId": session.trace_id,
239+
"evalSetId": session.eval_set_id,
240+
"metadata": session.metadata,
241+
"startedAt": session.started_at.isoformat(),
242+
},
243+
})
244+
245+
invocations_data = await _trace_manager._extract_invocations(session)
246+
await _trace_manager._save_spans_to_temp_file(session)
247+
248+
await _trace_manager.broadcast_to_ui({
249+
"type": "session_complete",
250+
"sessionId": session.session_id,
251+
"invocations": invocations_data,
252+
})
253+
254+
loaded.append(session.session_id)
255+
logger.info("Loaded session from bug report: %s", session.session_id)
256+
257+
return {"loaded_sessions": loaded, "count": len(loaded)}

src/agentevals/utils/log_buffer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import logging
2+
from collections import deque
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class BufferedLogRecord:
8+
timestamp: str
9+
level: str
10+
logger_name: str
11+
message: str
12+
exc_text: str | None = None
13+
14+
15+
class RingBufferLogHandler(logging.Handler):
16+
17+
def __init__(self, capacity: int = 1000):
18+
super().__init__()
19+
self._buffer: deque[BufferedLogRecord] = deque(maxlen=capacity)
20+
21+
def emit(self, record: logging.LogRecord) -> None:
22+
from datetime import datetime, timezone
23+
24+
self._buffer.append(
25+
BufferedLogRecord(
26+
timestamp=datetime.fromtimestamp(
27+
record.created, tz=timezone.utc
28+
).isoformat(),
29+
level=record.levelname,
30+
logger_name=record.name,
31+
message=self.format(record),
32+
exc_text=record.exc_text,
33+
)
34+
)
35+
36+
def get_text(self) -> str:
37+
lines = []
38+
for r in self._buffer:
39+
lines.append(f"[{r.timestamp}] {r.level} {r.logger_name}: {r.message}")
40+
if r.exc_text:
41+
lines.append(r.exc_text)
42+
return "\n".join(lines)
43+
44+
45+
log_buffer = RingBufferLogHandler(capacity=1000)

0 commit comments

Comments
 (0)