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 ' - + + +