Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions dashboard/backend/api/routers/admin_traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,92 @@ def list_trace_events(
return trace_store.list_events(
trace_id, after_sequence=after_sequence, limit=limit
)


@router.get("/{trace_id}/performance")
def get_trace_performance(trace_id: str):
"""Resolve equity by the exact run ID, never by agent or nearest date."""
import math
from dashboard.backend.database import db
from dashboard.backend.domain.backtesting.metrics import calculate_max_drawdown

trace = trace_store.get_trace(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
run_id = trace.get("run_id")
run = db.get_run(run_id) if run_id else None
points = []
if run:
for row in db.get_equity_curve(run_id):
equity = row.get("equity")
if isinstance(equity, (int, float)) and not isinstance(equity, bool) and math.isfinite(equity):
points.append({"timestamp": row["timestamp"], "equity": equity})
# Report the run's own stored figures, so this chart and the backtest panel
# beside it (and the results page) show one return for one run. The stored
# total_return is measured against initial capital; agent_runs.initial_equity
# is the first equity mark, which already carries first-bar fees.
def finite(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)

stored_return = (run or {}).get("total_return")
stored_drawdown = (run or {}).get("max_drawdown")
if finite(stored_drawdown):
drawdown = abs(stored_drawdown)
elif points:
# Metrics use the full series; drawing uses a bounded sample including endpoints.
drawdown = abs(calculate_max_drawdown(points))
else:
drawdown = None
count = len(points)
metrics = {
"return_pct": stored_return * 100 if points and finite(stored_return) else None,
"max_drawdown_pct": drawdown * 100 if points and drawdown is not None else None,
}
if count > 1000:
points = [points[round(i * (count - 1) / 999)] for i in range(1000)]
return {
"run_id": run_id, "source": "run_equity" if points else "unavailable",
"points": points, "point_count": count, "sampled": count > 1000,
"metrics": metrics, "partial": trace.get("status") != "completed",
}


@router.get("/{trace_id}/export")
def export_trace(trace_id: str, format: str = Query(default="json", pattern="^(json|markdown)$")):
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from dashboard.backend.domain.traces.export import build_export

trace = trace_store.get_trace(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
file = build_export(trace_store, _public_trace(trace), format)
def chunks():
try:
while chunk := file.read(65536):
yield chunk
finally:
file.close()
extension = 'json' if format == 'json' else 'md'
return StreamingResponse(chunks(), media_type='application/json' if format == 'json' else 'text/markdown',
headers={'Content-Disposition': f'attachment; filename="agent-trace.{extension}"', 'Cache-Control': 'no-store'},
background=BackgroundTask(file.close))


@router.get("/{trace_id}/backtest")
def get_trace_backtest(trace_id: str):
"""Administrator view across owners, resolved only by the trace's run ID."""
from dashboard.backend.database import db
from dashboard.backend.domain.traces.export import redact
trace = trace_store.get_trace(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
run = db.get_run(trace.get('run_id')) if trace.get('run_id') else None
if run is None:
return {'available': False, 'run_id': trace.get('run_id')}
fields = ('run_id', 'agent_name', 'llm_model', 'mode', 'start_date', 'end_date',
'initial_equity', 'final_equity', 'total_return', 'max_drawdown', 'sharpe_ratio',
'num_trades', 'llm_calls', 'llm_decisions', 'input_tokens', 'output_tokens', 'est_cost_usd')
return {'available': True, 'run_id': trace['run_id'],
'run': {key: run.get(key) for key in fields},
'configuration': redact(run.get('metadata') or {})}
169 changes: 169 additions & 0 deletions dashboard/backend/domain/traces/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Versioned, redacted trace exports with a fixed event-sequence boundary."""
import html
import json
import re
import shutil
from datetime import datetime, timezone
from tempfile import SpooledTemporaryFile

# Key matching is per word, not per substring: ``token`` must catch
# ``refresh_token`` and ``accessToken`` without blanking ``input_tokens`` or
# ``llm_max_output_tokens``, and ``session``/``browser`` are identities only
# as a whole key or with ``id`` -- ``open_session_excluded`` is configuration.
# Stems are sensitive anywhere in a key once separators and case are dropped
# (``x-api-key``, ``XAPIKey``, ``client_secret``).
_SENSITIVE_STEMS = (
'apikey', 'authorization', 'bearer', 'cookie', 'credential', 'email',
'passwd', 'password', 'privatekey', 'secret',
)
# Sensitive only as the end of a word or of two adjacent words, so
# ``session_id``, ``sessionId`` and ``sessionid`` all match.
_SENSITIVE_SUFFIXES = ('token', 'sessionid', 'browserid', 'userid', 'actorid')
_SENSITIVE_KEYS = frozenset({'session', 'browser'})
_CAMEL = re.compile(r'([a-z0-9])([A-Z])')
# The lookbehind starts a match only at the head of a run: without it a long
# run with no ``@`` is re-scanned from every offset, quadratic in its length,
# and agent text reaches this up to the store's 64 KiB payload cap.
_EMAIL = re.compile(r'(?<![\w.+-])[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}')
_BEARER = re.compile(r'(?i)\bBearer\s+[^\s"<>]+')
_KEY = re.compile(r'\b(?:sk-|sk_)[A-Za-z0-9_-]{12,}')
# Inline constructs an agent could use to plant a link, an image beacon or
# formatting in an admin's Markdown viewer. Line-start constructs (headings,
# lists, setext rules) are prevented by folding newlines, since every line this
# module writes starts with its own label. ``&``, ``<`` and ``>`` are left to
# ``html.escape``, whose entities must not be broken apart.
_MARKDOWN_PUNCTUATION = re.compile(r'([\\`*_\[\]()!~])')
_TERMINAL_EVENTS = {'run_completed': 'completed', 'run_failed': 'failed'}
_RUN_STATUS_LINE = {
'completed': 'Run completed.',
'failed': 'Run failed.',
'running': 'Run is incomplete: it was still running at capture.',
}


def _key_words(key):
return [word for word in re.split(r'[^a-z0-9]+', _CAMEL.sub(r'\1_\2', str(key)).lower()) if word]


def _is_sensitive_key(key):
words = _key_words(key)
compact = ''.join(words)
if compact in _SENSITIVE_KEYS or any(stem in compact for stem in _SENSITIVE_STEMS):
return True
candidates = words + [first + second for first, second in zip(words, words[1:])]
return any(word.endswith(_SENSITIVE_SUFFIXES) for word in candidates)


def redact(value):
if isinstance(value, dict):
return {key: '[REDACTED]' if _is_sensitive_key(key) else redact(child) for key, child in value.items()}
if isinstance(value, list):
return [redact(child) for child in value]
if isinstance(value, str):
return _KEY.sub('[REDACTED]', _BEARER.sub('[REDACTED]', _EMAIL.sub('[REDACTED]', value)))
return value


def markdown_text(value):
"""Agent-supplied text as inert Markdown: no links, images, or headings.
The exact text stays in the event's escaped ``<pre>`` block."""
flat = ' '.join(str(value).splitlines())
return _MARKDOWN_PUNCTUATION.sub(r'\\\1', html.escape(flat, quote=False))


def run_status(trace, events_status):
"""The run's state as of the export boundary.

A terminal event inside the boundary is authoritative: the trace row is read
before the boundary is fixed, and the store appends the terminal event before
it updates the row, so a row can still say ``running`` for an export that
holds ``run_completed``. A terminal row with no terminal event (a store that
never wrote one) is believed as read."""
if events_status:
return events_status
status = (trace or {}).get('status')
return status if status in ('completed', 'failed') else 'running'


def build_export(store, trace, format):
"""Build completely before sending headers; spill larger files to disk."""
captured_at = datetime.now(timezone.utc).isoformat()
cutoff = store.event_high_watermark(trace['trace_id'])
body = SpooledTemporaryFile(max_size=1024 * 1024, mode='w+b')
file = SpooledTemporaryFile(max_size=1024 * 1024, mode='w+b')

def write(target, text):
target.write(text.encode('utf-8'))

def dump(value):
return json.dumps(value, ensure_ascii=False, indent=2)

def block(value):
# An HTML-safe code block even when a message contains backticks.
return '\n<pre>\n' + html.escape(dump(value)) + '\n</pre>\n'

try:
cursor = 0
first = True
events_status = None
while cursor < cutoff:
page = store.list_events(trace['trace_id'], after_sequence=cursor, limit=100)
rows = [e for e in page['items'] if cursor < e['sequence_no'] <= cutoff]
if not rows:
raise RuntimeError('Trace changed or event page missing during export')
for event in rows:
events_status = _TERMINAL_EVENTS.get(event.get('event_type'), events_status)
clean = redact(event)
if format == 'json':
write(body, ('' if first else ',\n') + dump(clean))
else:
write(body, f'\n### Event {event["sequence_no"]}: {markdown_text(clean.get("event_type", "event"))}\n\n')
write(body, 'Recorded: ' + markdown_text(clean.get('occurred_at', 'Not recorded')) + '\n\n')
payload = clean.get('payload') or {}
if isinstance(payload, dict):
reasons = payload.get('reasoning_summaries') or [payload.get('reasoning_summary')]
if isinstance(reasons, list):
for reason in reasons:
if isinstance(reason, str) and reason:
write(body, 'Reason: ' + markdown_text(reason) + '\n\n')
for label in ('error_code', 'message', 'fills', 'actions', 'rejected'):
detail = payload.get(label)
if not detail:
continue
name = label.replace('_', ' ').title()
if isinstance(detail, str):
write(body, f'{name}: {markdown_text(detail)}\n\n')
else:
# Structured values carry agent text too (an
# action's reasoning), so they go in a block.
write(body, f'{name}:\n{block(detail)}\n')
write(body, '<details><summary>Technical details</summary>\n' + block(clean) + '\n</details>\n')
first = False
cursor = rows[-1]['sequence_no']
# The header is written after the walk so its status describes exactly
# the events through ``cutoff``, not the row as it was read earlier.
status = run_status(trace, events_status)
exported_trace = redact({**trace, 'status': status})
header = {'schema_version': 1, 'captured_at': captured_at,
'through_sequence': cutoff, 'run_status': status,
'incomplete_run': status == 'running',
'artifact_policy': 'References and metadata only; attachments are not bundled.',
'trace': exported_trace}
if format == 'json':
write(file, dump(header)[:-2] + ',\n "events": [\n')
else:
write(file, '# Agent trace report\n\n')
write(file, f'Captured at: {markdown_text(captured_at)}\n\nThrough event sequence: {cutoff}\n\n')
write(file, _RUN_STATUS_LINE[status] + '\n\n')
write(file, 'Artifact references only; attachments are not bundled.\n\n## Run overview\n' + block(exported_trace) + '\n## Timeline\n')
body.seek(0)
shutil.copyfileobj(body, file)
if format == 'json':
write(file, '\n]}\n')
file.seek(0)
return file
except Exception:
file.close()
raise
finally:
body.close()
6 changes: 6 additions & 0 deletions dashboard/backend/domain/traces/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ def append_events(self, *, trace_id: str, events: Sequence[Dict[str, Any]]) -> i
)
return len(fresh)

def event_high_watermark(self, trace_id: str) -> int:
"""Freeze the export boundary before walking append-only event pages."""
with self._get_connection() as conn:
row = conn.execute("SELECT COALESCE(MAX(sequence_no), 0) AS sequence_no FROM agent_trace_events WHERE trace_id = ?", (trace_id,)).fetchone()
return int(row["sequence_no"])

def list_events(
self, trace_id: str, *, after_sequence: int = 0, limit: int = 100
) -> Dict[str, Any]:
Expand Down
7 changes: 7 additions & 0 deletions dashboard/backend/domain/traces/repository_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@ def append_events(self, *, trace_id: str, events: Sequence[Dict[str, Any]]) -> i
)
return len(fresh)

def event_high_watermark(self, trace_id: str) -> int:
"""Freeze the export boundary before walking append-only event pages."""
with self._get_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sequence_no), 0) AS sequence_no FROM agent_trace_events WHERE trace_id = %s", (trace_id,))
return int(cur.fetchone()["sequence_no"])

def list_events(
self, trace_id: str, *, after_sequence: int = 0, limit: int = 100
) -> Dict[str, Any]:
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_admin_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert 'app.js?v=158' in APP_HTML
assert 'js/admin-tabs.js?v=12' in APP_HTML
for tag in (
'href="admin.css?v=11"',
'href="admin.css?v=13"',
'src="js/admin-shell.js?v=8"',
'src="js/credit-format.js?v=1"',
'src="js/admin-live.js?v=1"',
Expand Down
10 changes: 8 additions & 2 deletions dashboard/backend/tests/test_admin_page_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# exact, not a subset, so adding a verb is a test failure rather than a silent
# pass. Deleting the verb list and dropping api_key from the prohibited names
# would have left two tests that run, pass, and protect nothing.
READ_MODULES = ("admin-shell.js", "admin-live.js", "admin-overview.js", "admin-users.js", "admin-traces.js")
READ_MODULES = ("admin-shell.js", "admin-live.js", "admin-overview.js", "admin-users.js", "admin-traces.js", "admin-trace-performance.js", "admin-trace-timeline.js")
WRITE_MODULES = {
"admin-providers.js": {"PUT", "POST", "DELETE"},
# The absorbed credits console mutates groups, roles, grants and the pool.
Expand All @@ -48,6 +48,8 @@
"admin-providers.js": "AdminProviders",
"admin-credits.js": "AdminCredits",
"admin-traces.js": "AdminTraces",
"admin-trace-performance.js": "AdminTracePerformance",
"admin-trace-timeline.js": "AdminTraceTimeline",
}

CREDENTIAL = re.compile(r"\w*(?:api_key|secret|credential|token|password)\w*", re.I)
Expand Down Expand Up @@ -194,8 +196,12 @@ def test_exact_endpoints_and_query_names():
):
assert query_name in ALL, query_name
assert "section, limit:" in MODULES["admin-users.js"]
# The trace detail reads a recorded backtest config (`config.start_date`):
# a property of stored data, not a query name. Only that read is exempt --
# every read module, the trace modules included, is still scanned.
query_sources = ALL.replace("config.start_date", "")
for rejected in ("'status'", "start_date", "provider_id'", "model_id'", "'cohort'", "analyticsUser", "adminTab=analytics"):
assert rejected not in ALL, rejected
assert rejected not in query_sources, rejected


def test_no_live_route_and_no_chartjs():
Expand Down
6 changes: 4 additions & 2 deletions dashboard/backend/tests/test_admin_page_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"js/admin-providers.js?v=1",
# The absorbed credits console (design N2/PR2).
"js/admin-credits.js?v=4",
"js/admin-traces.js?v=1",
"js/admin-trace-timeline.js?v=1",
"js/admin-trace-performance.js?v=2",
"js/admin-traces.js?v=3",
]

# This guard's whole job is "no inline script anywhere in this page", so a
Expand Down Expand Up @@ -92,7 +94,7 @@ def test_gate_module_loads_first_and_every_script_is_pinned():


def test_stylesheet_is_admin_css_and_the_page_does_not_inherit_styles_css():
assert '<link rel="stylesheet" href="admin.css?v=11">' in ADMIN_HTML
assert '<link rel="stylesheet" href="admin.css?v=13">' in ADMIN_HTML
assert "styles.css" not in ADMIN_HTML
assert "@import" not in ADMIN_CSS
assert "cdn.jsdelivr.net" not in ADMIN_HTML
Expand Down
Loading
Loading