diff --git a/dashboard/backend/api/routers/admin_traces.py b/dashboard/backend/api/routers/admin_traces.py index 1a770aa3..3c94ad2f 100644 --- a/dashboard/backend/api/routers/admin_traces.py +++ b/dashboard/backend/api/routers/admin_traces.py @@ -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 {})} diff --git a/dashboard/backend/domain/traces/export.py b/dashboard/backend/domain/traces/export.py new file mode 100644 index 00000000..0ac5e4c1 --- /dev/null +++ b/dashboard/backend/domain/traces/export.py @@ -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'(?]+') +_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 ``
`` 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\n' + html.escape(dump(value)) + '\n
\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, 'Technical details
\n' + block(clean) + '\n\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()
diff --git a/dashboard/backend/domain/traces/repository.py b/dashboard/backend/domain/traces/repository.py
index 778aaad4..297ce3ae 100644
--- a/dashboard/backend/domain/traces/repository.py
+++ b/dashboard/backend/domain/traces/repository.py
@@ -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]:
diff --git a/dashboard/backend/domain/traces/repository_postgres.py b/dashboard/backend/domain/traces/repository_postgres.py
index 1675a336..2cc566fd 100644
--- a/dashboard/backend/domain/traces/repository_postgres.py
+++ b/dashboard/backend/domain/traces/repository_postgres.py
@@ -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]:
diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py
index de1d4a7d..6e03b521 100644
--- a/dashboard/backend/tests/test_admin_analytics_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_frontend.py
@@ -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"',
diff --git a/dashboard/backend/tests/test_admin_page_modules.py b/dashboard/backend/tests/test_admin_page_modules.py
index e715b6c2..d76ec6fb 100644
--- a/dashboard/backend/tests/test_admin_page_modules.py
+++ b/dashboard/backend/tests/test_admin_page_modules.py
@@ -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.
@@ -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)
@@ -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():
diff --git a/dashboard/backend/tests/test_admin_page_shell.py b/dashboard/backend/tests/test_admin_page_shell.py
index bd144084..5d0bffa4 100644
--- a/dashboard/backend/tests/test_admin_page_shell.py
+++ b/dashboard/backend/tests/test_admin_page_shell.py
@@ -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
@@ -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 '' in ADMIN_HTML
+ assert '' in ADMIN_HTML
assert "styles.css" not in ADMIN_HTML
assert "@import" not in ADMIN_CSS
assert "cdn.jsdelivr.net" not in ADMIN_HTML
diff --git a/dashboard/backend/tests/test_admin_trace_export.py b/dashboard/backend/tests/test_admin_trace_export.py
new file mode 100644
index 00000000..3cd9555c
--- /dev/null
+++ b/dashboard/backend/tests/test_admin_trace_export.py
@@ -0,0 +1,164 @@
+import json
+import pytest
+from fastapi.testclient import TestClient
+from dashboard.backend.app import app
+from dashboard.backend.api.auth import require_admin
+from dashboard.backend.api.routers import admin_traces as api
+from dashboard.backend.domain.traces.repository import TraceStore
+from dashboard.backend.domain.traces.export import build_export, redact
+
+
+def test_export_reads_all_pages_but_excludes_new_events(tmp_path, monkeypatch):
+ store = TraceStore(tmp_path / 'traces.db')
+ trace = store.create_trace(trace_id='t', run_id='r')
+ for i in range(205):
+ store.append_event(trace_id='t', event_type='decision_recorded', actor_type='agent', payload={'reason':'hold'})
+ original = store.event_high_watermark
+ def boundary(trace_id):
+ cutoff = original(trace_id)
+ store.append_event(trace_id='t', event_type='run_completed', actor_type='system', payload={})
+ return cutoff
+ monkeypatch.setattr(store, 'event_high_watermark', boundary)
+ with build_export(store, trace, 'json') as file:
+ result = json.load(file)
+ assert len(result['events']) == 205
+ assert result['through_sequence'] == 205
+ assert result['incomplete_run'] is True
+ assert [e['sequence_no'] for e in result['events']] == list(range(1, 206))
+
+
+def test_redaction_and_markdown_escape(tmp_path):
+ assert redact({'api_key':'abc', 'user_id':2, 'message':'contact a@example.com Bearer abc123'}) == {'api_key':'[REDACTED]', 'user_id':'[REDACTED]', 'message':'contact [REDACTED] [REDACTED]'}
+ store = TraceStore(tmp_path / 'traces.db')
+ trace = store.create_trace(trace_id='t', run_id='r')
+ store.append_event(trace_id='t', event_type='run_failed', actor_type='system', payload={'message':'```'})
+ with build_export(store, trace, 'markdown') as file:
+ text = file.read().decode()
+ assert '
-
+
+
+