From a0b45bc159820c5c6774927fab90f1a5824b13a1 Mon Sep 17 00:00:00 2001 From: Colin Son Date: Wed, 13 May 2026 16:23:26 -0500 Subject: [PATCH] refactor: split large command modules (Phase 7-8) Phase 7: Split api.py (3471 -> 1230 lines) - Extract HTTP handler infrastructure into api_handler.py (2303 lines) - Remove 15 unused CLI-only imports from handler - Add CorrelationEnricher re-export for backward compat Phase 8: Split commands/ui.py (4995 -> 1032 lines) - Extract _INDEX_HTML template into ui_templates.py (1852 lines) - Extract 43 helper functions into ui_payloads.py (2193 lines) - Fix duplicate _LLM_PROVIDER_DEFAULTS constant - Add 19 missing imports to ui.py, remove 7 unused from ui_payloads - Update test monkeypatches for new module structure All 952 tests pass, ruff clean. --- src/retrace/commands/api.py | 2331 +-------------- src/retrace/commands/api_handler.py | 2303 +++++++++++++++ src/retrace/commands/ui.py | 4101 +------------------------- src/retrace/commands/ui_payloads.py | 2193 ++++++++++++++ src/retrace/commands/ui_templates.py | 1852 ++++++++++++ tests/test_ui_replay_specs.py | 8 +- 6 files changed, 6466 insertions(+), 6322 deletions(-) create mode 100644 src/retrace/commands/api_handler.py create mode 100644 src/retrace/commands/ui_payloads.py create mode 100644 src/retrace/commands/ui_templates.py diff --git a/src/retrace/commands/api.py b/src/retrace/commands/api.py index 9519032..776077e 100644 --- a/src/retrace/commands/api.py +++ b/src/retrace/commands/api.py @@ -2,24 +2,18 @@ import json import logging -from threading import Lock -import time -import uuid from datetime import datetime, timedelta, timezone -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from http.server import ThreadingHTTPServer from pathlib import Path -from typing import Any, Iterable -from urllib.parse import parse_qs, urlsplit +from typing import Any import click from retrace.config import load_config -from retrace.github_app import GitHubWebhookError, handle_github_webhook +from retrace.deploys import correlate_recent_failures_to_deploys, record_deploy from retrace.ingester import PostHogIngester -from retrace.incidents import get_incident_detail from retrace.issue_sink_clients import GitHubClient, IssueSinkError, LinearClient -from retrace.issue_sinks import build_issue_card, compact_issue_card, promote_replay_issue -from retrace.llm.client import LLMClient +from retrace.issue_sinks import compact_issue_card, promote_replay_issue from retrace.notification_sinks import ( NotificationEvent, NotificationPayload, @@ -27,2286 +21,51 @@ close_sinks, dispatch_notification, ) -from retrace.observability import collect_local_observability, record_api_request -from retrace.enrichment import CorrelationEnricher -from retrace.deploys import correlate_recent_failures_to_deploys, record_deploy -from retrace.monitoring_ingest import ingest_monitoring_webhook -from retrace.otel_ingest import ingest_otel_logs, ingest_otel_traces -from retrace.replay_api import ( - MAX_REPLAY_BODY_BYTES, - ReplayIngestError, - ingest_replay_request, -) from retrace.replay_core import process_queued_replay_jobs -from retrace.sdk_keys import ( - authenticate_sdk_key, - authenticate_service_token, - create_sdk_key, - create_service_token, -) -from retrace.sentry_compat import ( - MAX_SENTRY_BODY_BYTES, - SentryCompatIngestError, - build_sentry_dsn, - extract_sentry_ingest_key, - ingest_sentry_compat_request, -) +from retrace.sdk_keys import create_sdk_key, create_service_token +from retrace.sentry_compat import build_sentry_dsn from retrace.source_maps import upload_source_map -from retrace.storage import RateLimitDecision, Storage - - -logger = logging.getLogger(__name__) - -INGEST_RATE_LIMITS: dict[str, tuple[int, int]] = { - "replay": (600, 60), - "sentry": (600, 60), - "monitoring": (300, 60), - "source_maps": (30, 60), - # OTel logs/traces — high-volume by design (every span is a row), - # so we run the same ceiling as replay rather than the tighter - # monitoring webhook quota. Operators with bursty OTel traffic - # should tune via the existing `consume_ingest_rate_limit` knobs - # if they hit the limit on legitimate load. - "otel": (600, 60), - # P3.6 (scaffold) — server-side replay sessions are captured at - # the failure moment (one per SSR exception, not a stream), so - # expected volume is much lower than browser replay. Tight - # default; operators with high failure rates can tune. - "server_replay": (120, 60), -} -HOSTED_ONBOARDING_SCOPES = ( - "ingest", - "source_maps:write", - "app_errors:read", - "app_errors:write", - "issues:read", - "replay:read", +from retrace.storage import Storage + + +# Re-exported from api_handler.py for backward compatibility +from retrace.commands.api_handler import ( # noqa: F401 + INGEST_RATE_LIMITS, + HOSTED_ONBOARDING_SCOPES, + CorrelationEnricher, + _alert_rule_api_dict, + _app_error_notification_payload, + _bearer_token, + _build_enricher, + _consume_rate_limit, + _cors_headers, + _dispatch_app_error_notifications, + _dt_api, + _evidence_api_dict, + _extract_replay_sdk_key, + _failure_api_dict, + _handler, + _header_value, + _hosted_onboarding_manifest, + _incident_api_dict, + _incident_lifecycle_event_api_dict, + _issue_cards_for_items, + _json_response, + _latest_failure, + _maybe_llm_client, + _optional_bool, + _optional_int, + _query_dict, + _rate_limit_headers, + _rate_limited_response, + _repair_task_api_dict, + _require_service_token, + _retention_result_api_dict, + _row_dict, + _sentry_ingest_path_parts, ) - -def _maybe_llm_client(cfg: Any, *, enabled: bool) -> LLMClient | None: - if not enabled: - return None - return LLMClient(cfg.llm) - - -def _json_response( - handler: BaseHTTPRequestHandler, - status: int, - payload: dict[str, Any], - *, - headers: dict[str, str] | None = None, -) -> None: - data = json.dumps(payload, separators=(",", ":")).encode("utf-8") - setattr(handler, "_retrace_response_status", status) - handler.send_response(status) - handler.send_header("Content-Type", "application/json") - trace_id = str(getattr(handler, "_retrace_trace_id", "") or "") - if trace_id: - handler.send_header("X-Retrace-Trace-Id", trace_id) - for name, value in (headers or {}).items(): - handler.send_header(name, value) - _cors_headers(handler) - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - handler.wfile.write(data) - - -def _cors_headers(handler: BaseHTTPRequestHandler) -> None: - handler.send_header("Access-Control-Allow-Origin", "*") - handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - handler.send_header( - "Access-Control-Expose-Headers", - ( - "X-Retrace-Trace-Id, Retry-After, X-RateLimit-Limit, " - "X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Window" - ), - ) - handler.send_header( - "Access-Control-Allow-Headers", - ( - "authorization, content-encoding, content-type, x-github-delivery, " - "x-github-event, x-hub-signature-256, x-retrace-key, x-sentry-auth" - ), - ) - handler.send_header("Access-Control-Max-Age", "86400") - - -def _query_dict(query: str) -> dict[str, str]: - return {k: v[-1] for k, v in parse_qs(query, keep_blank_values=True).items()} - - -def _sentry_ingest_path_parts(path: str) -> tuple[str, str] | None: - if path.startswith("/api/sentry/"): - suffix = path.removeprefix("/api/sentry/").strip("/") - parts = [part for part in suffix.split("/") if part] - if len(parts) == 2: - return parts[0].strip(), parts[1].strip() - return None - if not path.startswith("/api/"): - return None - suffix = path.removeprefix("/api/").strip("/") - parts = [part for part in suffix.split("/") if part] - if len(parts) == 2 and parts[1].strip().lower() in {"store", "envelope"}: - return parts[0].strip(), parts[1].strip() - return None - - -def _bearer_token(headers: Any) -> str: - auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip() - if auth.lower().startswith("bearer "): - return auth[7:].strip() - return "" - - -def _header_value(headers: Any, name: str) -> str: - lname = name.lower() - for key, value in headers.items(): - if str(key).lower() == lname: - return str(value) - return "" - - -def _extract_replay_sdk_key(headers: Any, query: dict[str, str]) -> str: - direct = _header_value(headers, "x-retrace-key").strip() - if direct: - return direct - auth = _header_value(headers, "authorization").strip() - if auth.lower().startswith("bearer "): - return auth[7:].strip() - return str( - query.get("key") or query.get("api_key") or query.get("apiKey") or "" - ).strip() - - -def _rate_limit_headers(decision: RateLimitDecision) -> dict[str, str]: - return { - "Retry-After": str(decision.reset_after_seconds), - "X-RateLimit-Limit": str(decision.limit), - "X-RateLimit-Remaining": str(decision.remaining), - "X-RateLimit-Reset": str(decision.reset_after_seconds), - "X-RateLimit-Window": str(decision.window_seconds), - } - - -def _rate_limited_response( - handler: BaseHTTPRequestHandler, - *, - bucket: str, - decision: RateLimitDecision, -) -> None: - _json_response( - handler, - 429, - { - "error": "rate_limited", - "message": f"{bucket} ingest rate limit exceeded.", - "limit": decision.limit, - "remaining": decision.remaining, - "retry_after_seconds": decision.reset_after_seconds, - "window_seconds": decision.window_seconds, - }, - headers=_rate_limit_headers(decision), - ) - - -def _consume_rate_limit( - store: Storage, - *, - project_id: str, - environment_id: str, - bucket: str, - identity: str, -) -> RateLimitDecision: - limit, window_seconds = INGEST_RATE_LIMITS[bucket] - return store.consume_ingest_rate_limit( - project_id=project_id, - environment_id=environment_id, - bucket=bucket, - identity=identity, - limit=limit, - window_seconds=window_seconds, - ) - - -def _require_service_token( - handler: BaseHTTPRequestHandler, - store: Storage, - *, - scopes: set[str], -): - token = authenticate_service_token(store, _bearer_token(handler.headers)) - if token is None: - _json_response( - handler, - 401, - {"error": "unauthorized", "message": "Missing or invalid service token."}, - ) - return None - if scopes and not scopes.intersection(set(token.scopes)): - _json_response( - handler, - 403, - {"error": "forbidden", "message": "Service token lacks the required scope."}, - ) - return None - return token - - -def _row_dict(row: Any, *, include_payload: bool = False) -> dict[str, Any]: - out = {k: row[k] for k in row.keys()} - for key in ( - "metadata_json", - "preview_json", - "signal_summary_json", - "reproduction_steps_json", - ): - if key in out: - try: - out[key.removesuffix("_json")] = json.loads(out[key] or "{}") - except json.JSONDecodeError: - out[key.removesuffix("_json")] = {} if key != "reproduction_steps_json" else [] - del out[key] - if not include_payload and "payload_json" in out: - del out["payload_json"] - return out - - -def _dt_api(value: Any) -> str: - return value.isoformat() if hasattr(value, "isoformat") else "" - - -def _incident_api_dict( - *, - store: Storage, - incident: Any, - failures: list[Any] | None = None, -) -> dict[str, Any]: - linked_failures = failures - if linked_failures is None: - linked_failures = store.list_incident_failures(incident_id=incident.id) - representative = _latest_failure(linked_failures) - trace_ids: list[str] = [] - top_stack_frame = "" - transaction = "" - release = "" - provider = "" - alert_state = "active" - alert_rule_name = "" - if representative is not None: - metadata = dict(getattr(representative, "metadata", {}) or {}) - trace_value = metadata.get("trace_ids") - if isinstance(trace_value, list): - trace_ids = [str(item) for item in trace_value if str(item).strip()] - top_stack_frame = str(metadata.get("top_stack_frame") or "") - transaction = str(metadata.get("transaction") or metadata.get("route") or "") - release = str(metadata.get("release") or metadata.get("deploy_sha") or "") - provider = str(metadata.get("provider") or "") - alert_state = str(metadata.get("alert_state") or "active") - alert_rule_name = str(metadata.get("alert_rule_name") or "") - return { - "id": incident.id, - "public_id": incident.public_id, - "title": incident.title, - "summary": incident.summary, - "severity": incident.severity, - "status": incident.status, - "failure_count": incident.failure_count, - "evidence_count": incident.evidence_count, - "repair_task_id": incident.repair_task_id, - "group_key": incident.group_key, - "metadata": dict(incident.metadata or {}), - "first_seen_at": _dt_api(incident.created_at), - "last_seen_at": _dt_api(incident.updated_at), - "latest_failure": ( - _failure_api_dict(representative, include_metadata=False) - if representative is not None - else None - ), - "trace_ids": trace_ids, - "top_stack_frame": top_stack_frame, - "transaction": transaction, - "release": release, - "provider": provider, - "alert_state": alert_state, - "alert_rule_name": alert_rule_name, - } - - -def _latest_failure(failures: list[Any]) -> Any | None: - if not failures: - return None - return max( - failures, - key=lambda failure: ( - int(getattr(failure, "last_seen_ms", 0) or 0), - _dt_api(getattr(failure, "updated_at", "")), - str(getattr(failure, "id", "")), - ), - ) - - -def _failure_api_dict(failure: Any, *, include_metadata: bool = True) -> dict[str, Any]: - payload = { - "id": failure.id, - "public_id": failure.public_id, - "source_type": failure.source_type, - "source_external_id": failure.source_external_id, - "fingerprint": failure.fingerprint, - "title": failure.title, - "summary": failure.summary, - "severity": failure.severity, - "confidence": failure.confidence, - "status": failure.status, - "affected_users": failure.affected_users, - "affected_sessions": failure.affected_sessions, - "first_seen_ms": failure.first_seen_ms, - "last_seen_ms": failure.last_seen_ms, - "related_deploy_sha": failure.related_deploy_sha, - "linked_tests": list(failure.linked_tests or []), - "linked_repair_task_id": failure.linked_repair_task_id, - "created_at": _dt_api(failure.created_at), - "updated_at": _dt_api(failure.updated_at), - } - if include_metadata: - payload["metadata"] = dict(failure.metadata or {}) - return payload - - -def _evidence_api_dict(evidence: Any) -> dict[str, Any]: - return { - "id": evidence.id, - "failure_id": evidence.failure_id, - "evidence_type": evidence.evidence_type, - "occurred_at_ms": evidence.occurred_at_ms, - "source": evidence.source, - "redaction_state": evidence.redaction_state, - "payload": dict(evidence.payload or {}), - "artifact_path": evidence.artifact_path, - "created_at": _dt_api(evidence.created_at), - } - - -def _incident_lifecycle_event_api_dict(event: Any) -> dict[str, Any]: - return { - "id": event.id, - "incident_id": event.incident_id, - "from_status": event.from_status, - "to_status": event.to_status, - "actor_type": event.actor_type, - "actor_id": event.actor_id, - "reason": event.reason, - "metadata": dict(event.metadata or {}), - "created_at": _dt_api(event.created_at), - } - - -def _repair_task_api_dict(task: Any) -> dict[str, Any]: - return { - "id": task.id, - "public_id": task.public_id, - "failure_id": task.failure_id, - "source_type": task.source_type, - "source_external_id": task.source_external_id, - "title": task.title, - "status": task.status, - "likely_files": list(task.likely_files or []), - "validation_commands": list(task.validation_commands or []), - "branch": task.branch, - "pr_url": task.pr_url, - "risk_notes": task.risk_notes, - "metadata": dict(task.metadata or {}), - "evidence_ids": list(task.evidence_ids or []), - "created_at": _dt_api(task.created_at), - "updated_at": _dt_api(task.updated_at), - } - - -def _hosted_onboarding_manifest( - *, - base_url: str, - project_id: str, - environment_id: str, - sdk_key: str, - service_token: str, - service_token_scopes: Iterable[str], - release: str = "$GITHUB_SHA", - artifact_url: str = "https://cdn.example.com/assets/app.min.js", -) -> dict[str, Any]: - clean_base_url = base_url.rstrip("/") or "http://127.0.0.1:8788" - clean_release = release.strip() or "$GITHUB_SHA" - clean_artifact_url = artifact_url.strip() or "https://cdn.example.com/assets/app.min.js" - sentry_dsn = build_sentry_dsn( - public_key=sdk_key, - base_url=clean_base_url, - project_id=project_id, - ) - monitoring_webhook = ( - f"{clean_base_url}/api/monitoring/webhook/sentry?environment_id={environment_id}" - ) - source_map_endpoint = f"{clean_base_url}/api/source-maps?environment_id={environment_id}" - app_errors_endpoint = f"{clean_base_url}/api/app-errors?environment_id={environment_id}" - alert_rules_endpoint = ( - f"{clean_base_url}/api/app-error-alert-rules?environment_id={environment_id}" - ) - prune_endpoint = f"{clean_base_url}/api/app-errors/prune?environment_id={environment_id}" - health_endpoint = f"{clean_base_url}/healthz" - smoke_event_id = "retrace-onboarding-smoke-1" - source_map_upload = ( - "curl -X POST " - f"'{source_map_endpoint}' " - f"-H 'Authorization: Bearer {service_token}' " - "-H 'Content-Type: application/json' " - f"-d '{{\"release\":\"{clean_release}\",\"artifact_url\":\"{clean_artifact_url}\",\"source_map\":{{\"version\":3,\"sources\":[\"src/app.ts\"],\"names\":[],\"mappings\":\"AAAA\"}}}}'" - ) - monitoring_smoke = ( - "curl -X POST " - f"'{monitoring_webhook}' " - f"-H 'Authorization: Bearer {service_token}' " - "-H 'Content-Type: application/json' " - f"-d '{{\"event\":{{\"event_id\":\"{smoke_event_id}\",\"title\":\"Retrace onboarding smoke error\",\"level\":\"error\",\"release\":\"{clean_release}\"}}}}'" - ) - alert_rule_create = ( - "curl -X POST " - f"'{alert_rules_endpoint}' " - f"-H 'Authorization: Bearer {service_token}' " - "-H 'Content-Type: application/json' " - "-d '{\"name\":\"Critical production errors\",\"action\":\"alert\",\"min_severity\":\"high\"}'" - ) - retention_prune = ( - "curl -X POST " - f"'{prune_endpoint}' " - f"-H 'Authorization: Bearer {service_token}' " - "-H 'Content-Type: application/json' " - "-d '{\"failure_retention_days\":90,\"evidence_retention_days\":90,\"source_map_retention_days\":30,\"rate_limit_retention_hours\":48}'" - ) - return { - "workspace": { - "project_id": project_id, - "environment_id": environment_id, - "api_base_url": clean_base_url, - }, - "credentials": { - "browser_sdk_key": sdk_key, - "service_token": service_token, - "service_token_scopes": [str(scope) for scope in service_token_scopes], - "sentry_dsn": sentry_dsn, - }, - "endpoints": { - "replay_ingest": f"{clean_base_url}/api/sdk/replay", - "sentry_store": f"{clean_base_url}/api/sentry/{project_id}/store/", - "sentry_envelope": f"{clean_base_url}/api/sentry/{project_id}/envelope/", - "monitoring_webhook": monitoring_webhook, - "source_maps": source_map_endpoint, - "app_errors": app_errors_endpoint, - "app_error_alert_rules": alert_rules_endpoint, - "app_error_retention_prune": prune_endpoint, - }, - "snippets": { - "browser_sdk_install": "npm install @retrace/browser", - "browser_sdk_init": ( - "import { initRetrace } from '@retrace/browser';\n\n" - "initRetrace({\n" - f" apiBaseUrl: '{clean_base_url}',\n" - f" key: '{sdk_key}',\n" - " captureConsole: true,\n" - " captureNetwork: true,\n" - " captureClicks: true,\n" - "});" - ), - "sentry_js_init": ( - "import * as Sentry from '@sentry/browser';\n\n" - "Sentry.init({\n" - f" dsn: '{sentry_dsn}',\n" - f" release: '{clean_release}',\n" - "});" - ), - "monitoring_webhook_curl": ( - monitoring_smoke - ), - "source_map_upload_curl": ( - source_map_upload - ), - "alert_rule_curl": ( - alert_rule_create - ), - "resolve_incident_curl": ( - "curl -X POST " - f"'{clean_base_url}/api/app-errors//lifecycle?environment_id={environment_id}' " - f"-H 'Authorization: Bearer {service_token}' " - "-H 'Content-Type: application/json' " - "-d '{\"action\":\"resolve\",\"reason\":\"fixed and verified\"}'" - ), - "retention_cron": ( - retention_prune - ), - "github_actions_source_maps": ( - "name: Upload Retrace source maps\n" - "on: [push]\n" - "jobs:\n" - " upload-source-maps:\n" - " runs-on: ubuntu-latest\n" - " steps:\n" - " - uses: actions/checkout@v4\n" - " - run: npm ci && npm run build\n" - " - run: |\n" - " curl -X POST " - f"'{source_map_endpoint}' \\\n" - " -H 'Authorization: Bearer ${{ secrets.RETRACE_SERVICE_TOKEN }}' \\\n" - " -H 'Content-Type: application/json' \\\n" - " --data-binary @retrace-source-map-upload.json" - ), - }, - "verification": { - "ordered_steps": [ - { - "id": "health", - "label": "API health", - "command": f"curl -fsS '{health_endpoint}'", - "expect": {"status": 200, "json": {"ok": True}}, - }, - { - "id": "source_maps", - "label": "Upload source map for release", - "command": source_map_upload, - "expect": { - "status": 202, - "json_contains": { - "source_map": { - "release": clean_release, - "artifact_url": clean_artifact_url, - } - }, - }, - }, - { - "id": "alert_rule", - "label": "Create high-severity alert rule", - "command": alert_rule_create, - "expect": { - "status": 202, - "json_contains": {"rule": {"action": "alert"}}, - }, - }, - { - "id": "monitoring_smoke", - "label": "Send monitoring smoke error", - "command": monitoring_smoke, - "expect": { - "status": 202, - "json_contains": {"results": [{"external_id": smoke_event_id}]}, - }, - }, - { - "id": "app_errors", - "label": "Confirm smoke incident is visible", - "command": ( - f"curl -fsS '{app_errors_endpoint}' " - f"-H 'Authorization: Bearer {service_token}'" - ), - "expect": { - "status": 200, - "json_path_hint": "$.incidents[?(@.latest_failure.source_external_id contains retrace-onboarding-smoke-1)]", - }, - }, - { - "id": "retention", - "label": "Verify hosted retention prune endpoint", - "command": retention_prune, - "expect": { - "status": 202, - "json_contains": {"retention": {"environment_id": environment_id}}, - }, - }, - ], - "required_scopes": [str(scope) for scope in service_token_scopes], - "release": clean_release, - "artifact_url": clean_artifact_url, - }, - "checklist": [ - "Start the API with `retrace api serve --host 0.0.0.0 --port 8788` behind TLS.", - "Install the browser SDK or point Sentry-compatible clients at the DSN.", - "Upload source maps from CI for each release before or during deploy.", - "Create at least one alert rule for high-severity production errors.", - "Send the monitoring smoke webhook and confirm it appears in `GET /api/app-errors`.", - "Schedule the retention prune request daily for hosted cleanup.", - ], - } - - -def _alert_rule_api_dict(rule: Any) -> dict[str, Any]: - return { - "id": rule.id, - "public_id": rule.public_id, - "name": rule.name, - "enabled": rule.enabled, - "precedence": rule.precedence, - "action": rule.action, - "min_severity": rule.min_severity, - "provider": rule.provider, - "title_contains": rule.title_contains, - "fingerprint_contains": rule.fingerprint_contains, - "route_contains": rule.route_contains, - "metadata": dict(rule.metadata or {}), - "created_at": _dt_api(rule.created_at), - "updated_at": _dt_api(rule.updated_at), - } - - -def _retention_result_api_dict(result: Any) -> dict[str, Any]: - return { - "dry_run": bool(result.dry_run), - "failure_retention_days": int(result.failure_retention_days), - "evidence_retention_days": int(result.evidence_retention_days), - "source_map_retention_days": int(result.source_map_retention_days), - "rate_limit_retention_hours": int(result.rate_limit_retention_hours), - "deleted": { - "failures": int(result.failures), - "evidence": int(result.evidence), - "incident_links": int(result.incident_links), - "incidents": int(result.incidents), - "source_maps": int(result.source_maps), - "rate_limit_rows": int(result.rate_limit_rows), - }, - } - - -def _optional_int(payload: dict[str, Any], key: str, default: int) -> int: - value = payload.get(key, None) - if value is None: - return default - return int(value) - - -def _optional_bool(payload: dict[str, Any], key: str, default: bool = False) -> bool: - value = payload.get(key, None) - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - normalized = value.strip().lower() - if normalized == "true": - return True - if normalized == "false": - return False - raise ValueError(f"{key} must be a boolean") - - -def _app_error_notification_payload( - *, - store: Storage, - result: Any, -) -> NotificationPayload | None: - failure = store.get_failure_by_id(str(result.failure_id)) - if failure is None: - return None - incident_public_id = str(getattr(result, "incident_public_id", "") or "") - public_id = incident_public_id or str(getattr(result, "failure_public_id", "") or "") - provider = str(getattr(result, "provider", "") or "") - return NotificationPayload( - event=NotificationEvent.APP_ERROR_CREATED.value, - title=f"{provider.title() or 'App'} error: {failure.title}", - summary=failure.summary, - severity=failure.severity, - public_id=public_id, - extra={ - "provider": provider, - "incident_id": str(getattr(result, "incident_id", "") or ""), - "incident_public_id": incident_public_id, - "failure_id": failure.id, - "failure_public_id": failure.public_id, - "evidence_id": str(getattr(result, "evidence_id", "") or ""), - "source_external_id": failure.source_external_id, - "trace_ids": list((failure.metadata or {}).get("trace_ids") or []), - "top_stack_frame": str((failure.metadata or {}).get("top_stack_frame") or ""), - }, - ) - - -def _dispatch_app_error_notifications( - *, - sinks: Iterable[Any], - store: Storage, - results: Iterable[Any], - lock: Lock | None = None, -) -> None: - sink_list = list(sinks) - if not sink_list: - return - for result in results: - if not bool(getattr(result, "created", False)): - continue - payload = _app_error_notification_payload(store=store, result=result) - if payload is not None: - try: - if lock is None: - dispatch_notification(sink_list, payload) - else: - with lock: - dispatch_notification(sink_list, payload) - except Exception: - logger.warning( - "failed to dispatch app_error notification", - extra={ - "failure_id": str(getattr(result, "failure_id", "") or ""), - "incident_id": str(getattr(result, "incident_id", "") or ""), - }, - exc_info=True, - ) - - -def _build_enricher(cfg: Any, store: Storage) -> CorrelationEnricher | None: - """Construct a best-effort correlation enricher when PostHog is configured. - - Returns None if PostHog credentials are missing OR if construction fails - (e.g. malformed host URL). Replay processing must never block on an - optional enrichment feature, so any failure here downgrades to "no - enricher" rather than propagating. - """ - try: - if not getattr(cfg.posthog, "api_key", "").strip(): - return None - except AttributeError: - return None - try: - return CorrelationEnricher(cfg, store) - except Exception: - logger.warning( - "correlation enricher disabled due to invalid PostHog config", - exc_info=True, - ) - return None - - -def _issue_cards_for_items( - store: Storage, - items: list[dict[str, str]], -) -> list[dict[str, Any]]: - cards: list[dict[str, Any]] = [] - for item in items[:10]: - public_id = str(item.get("public_id") or "") - project_id = str(item.get("project_id") or "") - environment_id = str(item.get("environment_id") or "") - if not public_id or not project_id or not environment_id: - continue - issue = store.get_replay_issue( - project_id=project_id, - environment_id=environment_id, - issue_id=public_id, - ) - if issue is None: - continue - sessions = store.list_replay_issue_sessions(str(issue["id"])) - cards.append(build_issue_card(store=store, issue=issue, sessions=sessions)) - return cards - - -def _handler( - store: Storage, - *, - enricher: CorrelationEnricher | None = None, - github_webhook_secret: str = "", - notification_sinks: Iterable[Any] = (), -) -> type[BaseHTTPRequestHandler]: - notification_lock = Lock() - - class RetraceAPIHandler(BaseHTTPRequestHandler): - server_version = "retrace-api/0.1" - - def handle_one_request(self) -> None: - self._retrace_trace_id = uuid.uuid4().hex - self._retrace_response_status = 500 - started = time.perf_counter() - try: - super().handle_one_request() - finally: - latency_ms = (time.perf_counter() - started) * 1000 - method = str(getattr(self, "command", "") or "") - path = urlsplit(str(getattr(self, "path", "") or "")).path - status = int(getattr(self, "_retrace_response_status", 500)) - if method and path: - record_api_request( - method=method, - path=path, - status=status, - latency_ms=latency_ms, - trace_id=self._retrace_trace_id, - ) - logger.info( - json.dumps( - { - "event": "api_request", - "trace_id": self._retrace_trace_id, - "method": method, - "path": path, - "status": status, - "latency_ms": round(latency_ms, 3), - }, - separators=(",", ":"), - ) - ) - - def do_GET(self) -> None: # noqa: N802 - parsed = urlsplit(self.path) - if parsed.path == "/healthz": - _json_response(self, 200, {"ok": True}) - return - if parsed.path == "/api/metrics": - self._handle_metrics() - return - if parsed.path == "/api/replays": - self._handle_list_replays(parsed.query) - return - if parsed.path.startswith("/api/replays/"): - replay_id = parsed.path.removeprefix("/api/replays/").strip("/") - self._handle_get_replay(replay_id, parsed.query) - return - if parsed.path == "/api/app-errors": - self._handle_list_app_error_incidents(parsed.query) - return - if parsed.path == "/api/app-error-alert-rules": - self._handle_list_app_error_alert_rules(parsed.query) - return - if parsed.path.startswith("/api/app-errors/"): - incident_id = parsed.path.removeprefix("/api/app-errors/").strip("/") - self._handle_get_app_error_incident(incident_id, parsed.query) - return - if parsed.path == "/api/issues": - self._handle_list_issues(parsed.query) - return - _json_response(self, 404, {"error": "not_found"}) - - def do_OPTIONS(self) -> None: # noqa: N802 - parsed = urlsplit(self.path) - if ( - parsed.path != "/api/sdk/replay" - and parsed.path != "/api/deploys" - and parsed.path != "/api/source-maps" - and parsed.path != "/api/onboarding/hosted" - and parsed.path != "/api/app-error-alert-rules" - and parsed.path != "/api/app-errors/prune" - and not ( - parsed.path.startswith("/api/app-errors/") - and parsed.path.endswith("/lifecycle") - ) - and not parsed.path.startswith("/api/otel/") - and not parsed.path.startswith("/api/sentry/") - and _sentry_ingest_path_parts(parsed.path) is None - and not parsed.path.startswith("/api/monitoring/webhook") - and parsed.path != "/api/github/webhook" - ): - _json_response(self, 404, {"error": "not_found"}) - return - self._retrace_response_status = 204 - self.send_response(204) - trace_id = str(getattr(self, "_retrace_trace_id", "") or "") - if trace_id: - self.send_header("X-Retrace-Trace-Id", trace_id) - _cors_headers(self) - self.send_header("Content-Length", "0") - self.end_headers() - - def do_POST(self) -> None: # noqa: N802 - parsed = urlsplit(self.path) - if parsed.path == "/api/replays/process": - self._handle_process_replays() - return - if parsed.path == "/api/deploys": - self._handle_record_deploy(parsed.query) - return - if parsed.path == "/api/source-maps": - self._handle_upload_source_map(parsed.query) - return - if parsed.path == "/api/app-error-alert-rules": - self._handle_upsert_app_error_alert_rule(parsed.query) - return - if parsed.path == "/api/app-errors/prune": - self._handle_prune_app_errors(parsed.query) - return - if parsed.path == "/api/onboarding/hosted": - self._handle_hosted_onboarding(parsed.query) - return - if parsed.path.startswith("/api/app-errors/") and parsed.path.endswith( - "/lifecycle" - ): - incident_id = parsed.path.removeprefix("/api/app-errors/")[ - : -len("/lifecycle") - ].strip("/") - self._handle_app_error_incident_lifecycle(incident_id, parsed.query) - return - if parsed.path in {"/api/otel/v1/logs", "/api/otel/v1/traces"}: - self._handle_otel_ingest(parsed.path, parsed.query) - return - if parsed.path == "/api/monitoring/webhook" or parsed.path.startswith( - "/api/monitoring/webhook/" - ): - self._handle_monitoring_webhook(parsed.path, parsed.query) - return - if parsed.path.startswith("/api/sentry/"): - self._handle_sentry_compat_ingest(parsed.path, parsed.query) - return - if _sentry_ingest_path_parts(parsed.path) is not None: - self._handle_sentry_compat_ingest(parsed.path, parsed.query) - return - if parsed.path == "/api/github/webhook": - self._handle_github_webhook() - return - if parsed.path == "/api/sdk/server-replay": - self._handle_server_replay_ingest(parsed.query) - return - if parsed.path != "/api/sdk/replay": - _json_response(self, 404, {"error": "not_found"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length < 0: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response( - self, - 413, - { - "error": "body_too_large", - "message": "Replay batch is too large.", - }, - ) - return - replay_query = _query_dict(parsed.query) - sdk_key = authenticate_sdk_key( - store, _extract_replay_sdk_key(self.headers, replay_query) - ) - if sdk_key is None: - _json_response( - self, - 401, - { - "error": "unauthorized", - "message": "Missing or invalid SDK key.", - }, - ) - return - decision = _consume_rate_limit( - store, - project_id=sdk_key.project_id, - environment_id=sdk_key.environment_id, - bucket="replay", - identity=sdk_key.id, - ) - if not decision.allowed: - _rate_limited_response(self, bucket="replay", decision=decision) - return - try: - body = self.rfile.read(length) - result = ingest_replay_request( - store=store, - headers={k: v for k, v in self.headers.items()}, - body=body, - query=replay_query, - ) - _json_response(self, 202, result) - except ReplayIngestError as exc: - _json_response( - self, - exc.status, - {"error": exc.code, "message": exc.message}, - ) - except Exception: - logger.exception("Unhandled replay ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - - def _handle_sentry_compat_ingest(self, path: str, query: str) -> None: - parts = _sentry_ingest_path_parts(path) - if parts is None: - _json_response(self, 404, {"error": "not_found"}) - return - project_id, endpoint = parts - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length < 0: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length > MAX_SENTRY_BODY_BYTES: - _json_response( - self, - 413, - { - "error": "body_too_large", - "message": "Sentry payload is too large.", - }, - ) - return - body = self.rfile.read(length) - sentry_headers = {k: v for k, v in self.headers.items()} - sentry_query = _query_dict(query) - raw_sentry_key = extract_sentry_ingest_key( - headers=sentry_headers, - query=sentry_query, - body=body, - content_encoding=_header_value(self.headers, "content-encoding"), - ) - if raw_sentry_key: - sdk_key = authenticate_sdk_key(store, raw_sentry_key) - if sdk_key is None: - _json_response( - self, - 401, - { - "error": "unauthorized", - "message": "Missing or invalid Sentry SDK key.", - }, - ) - return - if project_id and sdk_key.project_id != project_id: - _json_response( - self, - 403, - { - "error": "forbidden", - "message": "SDK key does not belong to this project.", - }, - ) - return - decision = _consume_rate_limit( - store, - project_id=sdk_key.project_id, - environment_id=sdk_key.environment_id, - bucket="sentry", - identity=sdk_key.id, - ) - if not decision.allowed: - _rate_limited_response(self, bucket="sentry", decision=decision) - return - try: - result = ingest_sentry_compat_request( - store=store, - project_id=project_id, - endpoint=endpoint, - headers=sentry_headers, - body=body, - query=sentry_query, - ) - _dispatch_app_error_notifications( - sinks=notification_sinks, - store=store, - results=result.results, - lock=notification_lock, - ) - except SentryCompatIngestError as exc: - _json_response( - self, - exc.status, - {"error": exc.code, "message": exc.message}, - ) - return - except Exception: - logger.exception("Unhandled Sentry compatibility ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202, result.to_dict()) - - def _handle_monitoring_webhook(self, path: str, query: str) -> None: - token = _require_service_token( - self, store, scopes={"monitoring:write", "ingest", "admin"} - ) - if token is None: - return - params = _query_dict(query) - provider = str(params.get("provider") or "").strip().lower() - suffix = path.removeprefix("/api/monitoring/webhook").strip("/") - if suffix: - provider = suffix.split("/", 1)[0].strip().lower() - if not provider: - _json_response( - self, - 400, - { - "error": "missing_provider", - "message": "provider is required in the path or query string.", - }, - ) - return - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response( - self, - 400, - { - "error": "missing_environment_id", - "message": "environment_id is required.", - }, - ) - return - decision = _consume_rate_limit( - store, - project_id=token.project_id, - environment_id=environment_id, - bucket="monitoring", - identity=f"{token.id}:{provider}", - ) - if not decision.allowed: - _rate_limited_response(self, bucket="monitoring", decision=decision) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length < 0: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response( - self, - 413, - { - "error": "body_too_large", - "message": "Webhook payload is too large.", - }, - ) - return - if length == 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict) or not payload: - _json_response(self, 400, {"error": "invalid_payload"}) - return - try: - result = ingest_monitoring_webhook( - store=store, - project_id=token.project_id, - environment_id=environment_id, - provider=provider, - payload=payload, - ) - _dispatch_app_error_notifications( - sinks=notification_sinks, - store=store, - results=[result], - lock=notification_lock, - ) - except Exception: - logger.exception("Unhandled monitoring webhook ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202, result.to_dict()) - - def _handle_github_webhook(self) -> None: - if not github_webhook_secret.strip(): - _json_response( - self, - 503, - { - "error": "github_app_not_configured", - "message": "GitHub App webhook secret is not configured.", - }, - ) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - result = handle_github_webhook( - store=store, - body=body, - headers={k: v for k, v in self.headers.items()}, - webhook_secret=github_webhook_secret, - ) - except GitHubWebhookError as exc: - _json_response( - self, - exc.status, - {"error": exc.code, "message": exc.message}, - ) - return - except Exception: - logger.exception("Unhandled GitHub webhook error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202 if result.accepted else 200, result.to_dict()) - - def _handle_record_deploy(self, query: str) -> None: - token = _require_service_token( - self, store, scopes={"deploy:write", "ingest", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict) or not payload: - _json_response(self, 400, {"error": "invalid_payload"}) - return - sha = str(payload.get("sha") or payload.get("commit_sha") or "").strip() - if not sha: - _json_response(self, 400, {"error": "missing_sha"}) - return - changed_files = payload.get("changed_files", payload.get("changedFiles")) - clean_changed_files: list[str] | None = None - if changed_files is not None: - if not isinstance(changed_files, list): - _json_response(self, 400, {"error": "invalid_changed_files"}) - return - clean_changed_files = [str(item) for item in changed_files] - try: - deployed_at_ms = int(payload.get("deployed_at_ms") or 0) - except (TypeError, ValueError): - _json_response(self, 400, {"error": "invalid_deployed_at_ms"}) - return - try: - deploy = record_deploy( - store=store, - project_id=token.project_id, - environment_id=environment_id, - sha=sha, - branch=str(payload.get("branch") or ""), - author=str(payload.get("author") or ""), - deployed_at_ms=deployed_at_ms, - changed_files=clean_changed_files, - metadata=( - dict(payload.get("metadata")) - if isinstance(payload.get("metadata"), dict) - else None - ), - ) - correlations = correlate_recent_failures_to_deploys( - store=store, - project_id=token.project_id, - environment_id=environment_id, - ) - correlations = [ - item for item in correlations if item.deploy_sha == deploy.sha - ] - except Exception: - logger.exception("Unhandled deploy ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response( - self, - 202, - { - "deploy": { - "id": deploy.id, - "public_id": deploy.public_id, - "sha": deploy.sha, - "branch": deploy.branch, - "author": deploy.author, - "deployed_at_ms": deploy.deployed_at_ms, - "changed_files": deploy.changed_files, - }, - "correlated_failures": [ - {"failure_id": item.failure_id, "deploy_sha": item.deploy_sha} - for item in correlations - ], - }, - ) - - def _handle_upload_source_map(self, query: str) -> None: - token = _require_service_token( - self, store, scopes={"source_maps:write", "ingest", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - decision = _consume_rate_limit( - store, - project_id=token.project_id, - environment_id=environment_id, - bucket="source_maps", - identity=token.id, - ) - if not decision.allowed: - _rate_limited_response(self, bucket="source_maps", decision=decision) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict) or not payload: - _json_response(self, 400, {"error": "invalid_payload"}) - return - release = str(payload.get("release") or "").strip() - artifact_url = str( - payload.get("artifact_url") - or payload.get("artifactUrl") - or payload.get("url") - or "" - ).strip() - source_map = payload.get("source_map") or payload.get("sourceMap") - if not release: - _json_response(self, 400, {"error": "missing_release"}) - return - if not artifact_url: - _json_response(self, 400, {"error": "missing_artifact_url"}) - return - if not isinstance(source_map, dict) or not source_map: - _json_response(self, 400, {"error": "invalid_source_map"}) - return - try: - row = upload_source_map( - store=store, - project_id=token.project_id, - environment_id=environment_id, - release=release, - dist=str(payload.get("dist") or ""), - artifact_url=artifact_url, - source_map=source_map, - ) - except ValueError as exc: - _json_response(self, 400, {"error": "invalid_source_map", "message": str(exc)}) - return - except Exception: - logger.exception("Unhandled source map upload error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response( - self, - 202, - { - "source_map": { - "id": row.id, - "public_id": row.public_id, - "release": row.release, - "dist": row.dist, - "artifact_url": row.artifact_url, - } - }, - ) - - def _handle_otel_ingest(self, path: str, query: str) -> None: - token = _require_service_token( - self, store, scopes={"otel:write", "ingest", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - # Rate limit *before* reading the body — a flooded client - # shouldn't get to spend our bandwidth uploading a payload - # we're about to reject. Identity is the service-token id - # so different tokens for the same project don't share a - # bucket (operators sometimes split tokens per host). - decision = _consume_rate_limit( - store, - project_id=token.project_id, - environment_id=environment_id, - bucket="otel", - identity=token.id, - ) - if not decision.allowed: - _rate_limited_response(self, bucket="otel", decision=decision) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict) or not payload: - _json_response(self, 400, {"error": "invalid_payload"}) - return - try: - result = ( - ingest_otel_logs( - store=store, - project_id=token.project_id, - environment_id=environment_id, - payload=payload, - ) - if path.endswith("/logs") - else ingest_otel_traces( - store=store, - project_id=token.project_id, - environment_id=environment_id, - payload=payload, - ) - ) - except Exception: - logger.exception("Unhandled OpenTelemetry ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202, result.to_dict()) - - def _handle_server_replay_ingest(self, query: str) -> None: - """P3.6 (scaffold) — accept a single server-side replay - session record. - - Payload shape (JSON): - { - "session_id": "...", - "request": { - "method": "GET", - "path": "/api/checkout", - "headers": {...}, - "body": "..." - }, - "response": { - "status": 500, - "headers": {...} - }, - "rendered_html": "...", // optional snippet - "runtime": "node-20", // optional - "occurred_at_ms": 0, // optional; defaults to now - "error_summary": "...", // optional - "metadata": {...} // optional - } - - The capture middleware that produces this payload is - deferred — see `docs/roadmap.md` P3.6. This endpoint - exists today so that middleware has a defined seam to - ship against, and so the ingest path can be hardened / - rate-limited / tested before SDK work begins. - """ - replay_query = _query_dict(query) - sdk_key = authenticate_sdk_key( - store, _extract_replay_sdk_key(self.headers, replay_query) - ) - if sdk_key is None: - _json_response( - self, - 401, - { - "error": "unauthorized", - "message": "Missing or invalid SDK key.", - }, - ) - return - decision = _consume_rate_limit( - store, - project_id=sdk_key.project_id, - environment_id=sdk_key.environment_id, - bucket="server_replay", - identity=sdk_key.id, - ) - if not decision.allowed: - _rate_limited_response(self, bucket="server_replay", decision=decision) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except (json.JSONDecodeError, UnicodeDecodeError): - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict): - _json_response(self, 400, {"error": "invalid_payload"}) - return - request_block = payload.get("request") or {} - response_block = payload.get("response") or {} - if not isinstance(request_block, dict) or not isinstance( - response_block, dict - ): - _json_response( - self, - 400, - { - "error": "invalid_payload", - "message": "`request` and `response` must be objects.", - }, - ) - return - try: - row_id = store.insert_server_replay_session( - project_id=sdk_key.project_id, - environment_id=sdk_key.environment_id, - session_id=str(payload.get("session_id") or ""), - request_method=str(request_block.get("method") or ""), - request_path=str(request_block.get("path") or ""), - request_headers=request_block.get("headers") or {}, - request_body_text=str(request_block.get("body") or ""), - response_status=int(response_block.get("status") or 0), - response_headers=response_block.get("headers") or {}, - rendered_html_snippet=str(payload.get("rendered_html") or ""), - runtime=str(payload.get("runtime") or ""), - occurred_at_ms=int(payload.get("occurred_at_ms") or 0), - error_summary=str(payload.get("error_summary") or ""), - metadata=payload.get("metadata") or {}, - ) - except (TypeError, ValueError) as exc: - _json_response( - self, - 400, - {"error": "invalid_payload", "message": str(exc)}, - ) - return - except Exception: - logger.exception("Unhandled server-replay ingest error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202, {"id": row_id, "accepted": 1}) - - def _handle_list_replays(self, query: str) -> None: - token = _require_service_token( - self, store, scopes={"replay:read", "mcp:read", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response( - self, - 400, - { - "error": "missing_environment_id", - "message": "environment_id is required.", - }, - ) - return - status = str(params.get("status") or "").strip() or None - try: - limit = int(params.get("limit") or "100") - except ValueError: - _json_response(self, 400, {"error": "invalid_limit"}) - return - rows = store.list_replay_sessions( - project_id=token.project_id, - environment_id=environment_id, - status=status, - limit=limit, - ) - _json_response( - self, - 200, - { - "project_id": token.project_id, - "environment_id": environment_id, - "sessions": [_row_dict(r) for r in rows], - }, - ) - - def _handle_metrics(self) -> None: - token = _require_service_token( - self, store, scopes={"admin", "mcp:read"} - ) - if token is None: - return - _json_response(self, 200, collect_local_observability(store).to_dict()) - - def _handle_process_replays(self) -> None: - token = _require_service_token( - self, store, scopes={"replay:write", "admin"} - ) - if token is None: - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - body = self.rfile.read(max(0, length)) if length else b"{}" - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - try: - limit = int(payload.get("limit") or 25) - except (TypeError, ValueError): - _json_response(self, 400, {"error": "invalid_limit"}) - return - result = process_queued_replay_jobs( - store=store, - limit=limit, - project_id=token.project_id, - enricher=enricher, - ) - _json_response( - self, - 200, - { - "jobs_seen": result.jobs_seen, - "jobs_processed": result.jobs_processed, - "jobs_failed": result.jobs_failed, - "sessions_processed": result.sessions_processed, - "issues_created_or_updated": result.issues_created_or_updated, - "project_id": token.project_id, - }, - ) - - def _handle_get_replay(self, replay_id: str, query: str) -> None: - token = _require_service_token( - self, store, scopes={"replay:read", "mcp:read", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - replay_id = replay_id.strip() - if not replay_id: - _json_response(self, 404, {"error": "not_found"}) - return - playback = store.get_replay_playback( - project_id=token.project_id, - environment_id=environment_id, - replay_id=replay_id, - ) - if playback is None: - _json_response(self, 404, {"error": "not_found"}) - return - _json_response( - self, - 200, - { - "session": _row_dict(playback.session), - "batches": [_row_dict(b) for b in playback.batches], - "events": playback.events, - }, - ) - - def _handle_list_issues(self, query: str) -> None: - token = _require_service_token( - self, store, scopes={"issues:read", "mcp:read", "admin"} - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - status = str(params.get("status") or "").strip() or None - rows = store.list_replay_issues( - project_id=token.project_id, - environment_id=environment_id, - status=status, - ) - _json_response( - self, - 200, - { - "project_id": token.project_id, - "environment_id": environment_id, - "issues": [_row_dict(r) for r in rows], - }, - ) - - def _handle_list_app_error_incidents(self, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:read", "issues:read", "mcp:read", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - status = str(params.get("status") or "").strip() or None - try: - limit = int(params.get("limit") or "100") - except ValueError: - _json_response(self, 400, {"error": "invalid_limit"}) - return - incidents = store.list_incidents( - project_id=token.project_id, - environment_id=environment_id, - status=status, - limit=limit, - ) - failures_by_incident = store.list_incident_failures_for_incidents( - incident_ids=[incident.id for incident in incidents] - ) - _json_response( - self, - 200, - { - "project_id": token.project_id, - "environment_id": environment_id, - "incidents": [ - _incident_api_dict( - store=store, - incident=incident, - failures=failures_by_incident.get(incident.id, []), - ) - for incident in incidents - ], - }, - ) - - def _handle_list_app_error_alert_rules(self, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:read", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - try: - limit = int(params.get("limit") or "100") - offset = int(params.get("offset") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_pagination"}) - return - rules = store.list_app_error_alert_rules( - project_id=token.project_id, - environment_id=environment_id, - limit=limit, - offset=offset, - ) - _json_response( - self, - 200, - { - "project_id": token.project_id, - "environment_id": environment_id, - "limit": max(1, min(limit, 500)), - "offset": max(0, offset), - "rules": [_alert_rule_api_dict(rule) for rule in rules], - }, - ) - - def _handle_upsert_app_error_alert_rule(self, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:write", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > MAX_REPLAY_BODY_BYTES: - _json_response(self, 413, {"error": "payload_too_large"}) - return - body = self.rfile.read(length) - try: - payload = json.loads(body.decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict) or not payload: - _json_response(self, 400, {"error": "invalid_payload"}) - return - try: - rule_id = store.upsert_app_error_alert_rule( - project_id=token.project_id, - environment_id=environment_id, - name=str(payload.get("name") or ""), - enabled=bool(payload.get("enabled", True)), - precedence=int(payload.get("precedence") or 0), - action=str(payload.get("action") or "alert"), - min_severity=str(payload.get("min_severity") or ""), - provider=str(payload.get("provider") or ""), - title_contains=str(payload.get("title_contains") or ""), - fingerprint_contains=str(payload.get("fingerprint_contains") or ""), - route_contains=str(payload.get("route_contains") or ""), - metadata=( - dict(payload.get("metadata")) - if isinstance(payload.get("metadata"), dict) - else None - ), - ) - except ValueError as exc: - _json_response(self, 400, {"error": "invalid_alert_rule", "message": str(exc)}) - return - rule = next( - ( - item - for item in store.list_app_error_alert_rules( - project_id=token.project_id, - environment_id=environment_id, - enabled=None, - ) - if item.id == rule_id - ), - None, - ) - _json_response( - self, - 202, - {"rule": _alert_rule_api_dict(rule) if rule is not None else {"id": rule_id}}, - ) - - def _handle_hosted_onboarding(self, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length < 0: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length > 64 * 1024: - _json_response(self, 413, {"error": "payload_too_large"}) - return - payload: dict[str, Any] = {} - if length: - try: - decoded = json.loads(self.rfile.read(length).decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(decoded, dict): - _json_response(self, 400, {"error": "invalid_payload"}) - return - payload = decoded - raw_scopes = payload.get("service_token_scopes") - if raw_scopes is None: - service_token_scopes = list(HOSTED_ONBOARDING_SCOPES) - elif isinstance(raw_scopes, list) and all( - isinstance(item, str) and item.strip() for item in raw_scopes - ): - service_token_scopes = [str(item).strip() for item in raw_scopes] - else: - _json_response(self, 400, {"error": "invalid_service_token_scopes"}) - return - sdk = create_sdk_key( - store, - project_id=token.project_id, - environment_id=environment_id, - name=str(payload.get("sdk_key_name") or "Hosted browser SDK"), - ) - service = create_service_token( - store, - project_id=token.project_id, - name=str(payload.get("service_token_name") or "Hosted onboarding"), - scopes=service_token_scopes, - ) - manifest = _hosted_onboarding_manifest( - base_url=str(payload.get("api_base_url") or "http://127.0.0.1:8788"), - project_id=token.project_id, - environment_id=environment_id, - sdk_key=sdk.key, - service_token=service.token, - service_token_scopes=service.scopes, - release=str(payload.get("release") or "$GITHUB_SHA"), - artifact_url=str( - payload.get("artifact_url") - or "https://cdn.example.com/assets/app.min.js" - ), - ) - _json_response(self, 201, {"onboarding": manifest}) - - def _handle_prune_app_errors(self, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:write", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length < 0: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length > 64 * 1024: - _json_response(self, 413, {"error": "payload_too_large"}) - return - payload: dict[str, Any] = {} - if length: - try: - decoded = json.loads(self.rfile.read(length).decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(decoded, dict): - _json_response(self, 400, {"error": "invalid_payload"}) - return - payload = decoded - try: - result = store.prune_app_error_retention( - project_id=token.project_id, - environment_id=environment_id, - failure_retention_days=_optional_int( - payload, "failure_retention_days", 90 - ), - evidence_retention_days=_optional_int( - payload, "evidence_retention_days", 90 - ), - source_map_retention_days=_optional_int( - payload, "source_map_retention_days", 30 - ), - rate_limit_retention_hours=_optional_int( - payload, "rate_limit_retention_hours", 48 - ), - dry_run=_optional_bool(payload, "dry_run"), - ) - except (TypeError, ValueError) as exc: - _json_response(self, 400, {"error": "invalid_retention", "message": str(exc)}) - return - except Exception: - logger.exception("Unhandled app-error retention prune error") - _json_response( - self, - 500, - { - "error": "internal_error", - "message": "An internal server error occurred.", - }, - ) - return - _json_response(self, 202, {"retention": _retention_result_api_dict(result)}) - - def _handle_app_error_incident_lifecycle( - self, incident_id: str, query: str - ) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:write", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - incident_id = incident_id.strip() - if not incident_id: - _json_response(self, 404, {"error": "not_found"}) - return - try: - length = int(self.headers.get("Content-Length") or "0") - except ValueError: - _json_response(self, 400, {"error": "invalid_content_length"}) - return - if length <= 0: - _json_response(self, 400, {"error": "invalid_payload"}) - return - if length > 64 * 1024: - _json_response(self, 413, {"error": "payload_too_large"}) - return - try: - payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") - except json.JSONDecodeError: - _json_response(self, 400, {"error": "invalid_json"}) - return - if not isinstance(payload, dict): - _json_response(self, 400, {"error": "invalid_payload"}) - return - action = str(payload.get("action") or "").strip().lower() - status = str(payload.get("status") or "").strip().lower() - action_statuses = { - "resolve": "resolved", - "resolved": "resolved", - "ignore": "ignored", - "ignored": "ignored", - "reopen": "open", - "open": "open", - "triage": "triaged", - "triaged": "triaged", - "investigate": "investigating", - "investigating": "investigating", - } - if action and not status: - status = action_statuses.get(action, "") - if not status: - _json_response(self, 400, {"error": "missing_status"}) - return - metadata = payload.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - _json_response(self, 400, {"error": "invalid_metadata"}) - return - try: - incident = store.transition_app_error_incident( - project_id=token.project_id, - environment_id=environment_id, - incident_id=incident_id, - status=status, - actor_type=str(payload.get("actor_type") or "service_token"), - actor_id=str(payload.get("actor_id") or token.name or token.id), - reason=str(payload.get("reason") or ""), - metadata=dict(metadata or {}), - ) - except ValueError as exc: - message = str(exc) - if "unknown incident_id" in message: - _json_response(self, 404, {"error": "not_found"}) - return - _json_response( - self, - 400, - {"error": "invalid_lifecycle_transition", "message": message}, - ) - return - failures = store.list_incident_failures(incident_id=incident.id) - events = store.list_incident_lifecycle_events(incident_id=incident.id) - _json_response( - self, - 202, - { - "project_id": token.project_id, - "environment_id": environment_id, - "incident": _incident_api_dict( - store=store, - incident=incident, - failures=failures, - ), - "failures": [ - _failure_api_dict(failure, include_metadata=False) - for failure in failures - ], - "lifecycle_events": [ - _incident_lifecycle_event_api_dict(event) for event in events - ], - }, - ) - - def _handle_get_app_error_incident(self, incident_id: str, query: str) -> None: - token = _require_service_token( - self, - store, - scopes={"app_errors:read", "issues:read", "mcp:read", "admin"}, - ) - if token is None: - return - params = _query_dict(query) - environment_id = str(params.get("environment_id") or "").strip() - if not environment_id: - _json_response(self, 400, {"error": "missing_environment_id"}) - return - incident_id = incident_id.strip() - if not incident_id: - _json_response(self, 404, {"error": "not_found"}) - return - include_sensitive = str( - params.get("include_sensitive") or "false" - ).strip().lower() in {"1", "true", "yes"} - if include_sensitive and not {"app_errors:read", "admin"}.intersection( - set(token.scopes) - ): - _json_response( - self, - 403, - { - "error": "forbidden", - "message": "include_sensitive=true requires app_errors:read scope.", - }, - ) - return - try: - detail = get_incident_detail( - store=store, - incident_id=incident_id, - include_sensitive_evidence=include_sensitive, - ) - except ValueError: - _json_response(self, 404, {"error": "not_found"}) - return - if ( - detail.incident.project_id != token.project_id - or detail.incident.environment_id != environment_id - ): - _json_response(self, 404, {"error": "not_found"}) - return - _json_response( - self, - 200, - { - "project_id": token.project_id, - "environment_id": environment_id, - "incident": _incident_api_dict( - store=store, - incident=detail.incident, - failures=detail.failures, - ), - "failures": [ - _failure_api_dict(failure) for failure in detail.failures - ], - "evidence": [ - _evidence_api_dict(evidence) for evidence in detail.evidence - ], - "lifecycle_events": [ - _incident_lifecycle_event_api_dict(event) - for event in store.list_incident_lifecycle_events( - incident_id=detail.incident.id - ) - ], - "repair_task": ( - _repair_task_api_dict(detail.repair_task) - if detail.repair_task is not None - else None - ), - }, - ) - - def log_message(self, format: str, *args: object) -> None: - click.echo(f"{self.address_string()} - {format % args}", err=True) - - return RetraceAPIHandler - +logger = logging.getLogger(__name__) @click.group("api") def api_group() -> None: diff --git a/src/retrace/commands/api_handler.py b/src/retrace/commands/api_handler.py new file mode 100644 index 0000000..26109ce --- /dev/null +++ b/src/retrace/commands/api_handler.py @@ -0,0 +1,2303 @@ +from __future__ import annotations + +import json +import logging +from threading import Lock +import time +import uuid +from http.server import BaseHTTPRequestHandler +from typing import Any, Iterable +from urllib.parse import parse_qs, urlsplit + +import click + +from retrace.github_app import GitHubWebhookError, handle_github_webhook +from retrace.incidents import get_incident_detail +from retrace.issue_sinks import build_issue_card +from retrace.llm.client import LLMClient +from retrace.notification_sinks import ( + NotificationEvent, + NotificationPayload, + dispatch_notification, +) +from retrace.observability import collect_local_observability, record_api_request +from retrace.enrichment import CorrelationEnricher +from retrace.deploys import correlate_recent_failures_to_deploys, record_deploy +from retrace.monitoring_ingest import ingest_monitoring_webhook +from retrace.otel_ingest import ingest_otel_logs, ingest_otel_traces +from retrace.replay_api import ( + MAX_REPLAY_BODY_BYTES, + ReplayIngestError, + ingest_replay_request, +) +from retrace.replay_core import process_queued_replay_jobs +from retrace.sdk_keys import ( + authenticate_sdk_key, + authenticate_service_token, + create_sdk_key, + create_service_token, +) +from retrace.sentry_compat import ( + MAX_SENTRY_BODY_BYTES, + SentryCompatIngestError, + build_sentry_dsn, + extract_sentry_ingest_key, + ingest_sentry_compat_request, +) +from retrace.source_maps import upload_source_map +from retrace.storage import RateLimitDecision, Storage + + +logger = logging.getLogger(__name__) + +INGEST_RATE_LIMITS: dict[str, tuple[int, int]] = { + "replay": (600, 60), + "sentry": (600, 60), + "monitoring": (300, 60), + "source_maps": (30, 60), + # OTel logs/traces — high-volume by design (every span is a row), + # so we run the same ceiling as replay rather than the tighter + # monitoring webhook quota. Operators with bursty OTel traffic + # should tune via the existing `consume_ingest_rate_limit` knobs + # if they hit the limit on legitimate load. + "otel": (600, 60), + # P3.6 (scaffold) — server-side replay sessions are captured at + # the failure moment (one per SSR exception, not a stream), so + # expected volume is much lower than browser replay. Tight + # default; operators with high failure rates can tune. + "server_replay": (120, 60), +} +HOSTED_ONBOARDING_SCOPES = ( + "ingest", + "source_maps:write", + "app_errors:read", + "app_errors:write", + "issues:read", + "replay:read", +) + + +def _maybe_llm_client(cfg: Any, *, enabled: bool) -> LLMClient | None: + if not enabled: + return None + return LLMClient(cfg.llm) + + +def _json_response( + handler: BaseHTTPRequestHandler, + status: int, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> None: + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") + setattr(handler, "_retrace_response_status", status) + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + trace_id = str(getattr(handler, "_retrace_trace_id", "") or "") + if trace_id: + handler.send_header("X-Retrace-Trace-Id", trace_id) + for name, value in (headers or {}).items(): + handler.send_header(name, value) + _cors_headers(handler) + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + + +def _cors_headers(handler: BaseHTTPRequestHandler) -> None: + handler.send_header("Access-Control-Allow-Origin", "*") + handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + handler.send_header( + "Access-Control-Expose-Headers", + ( + "X-Retrace-Trace-Id, Retry-After, X-RateLimit-Limit, " + "X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Window" + ), + ) + handler.send_header( + "Access-Control-Allow-Headers", + ( + "authorization, content-encoding, content-type, x-github-delivery, " + "x-github-event, x-hub-signature-256, x-retrace-key, x-sentry-auth" + ), + ) + handler.send_header("Access-Control-Max-Age", "86400") + + +def _query_dict(query: str) -> dict[str, str]: + return {k: v[-1] for k, v in parse_qs(query, keep_blank_values=True).items()} + + +def _sentry_ingest_path_parts(path: str) -> tuple[str, str] | None: + if path.startswith("/api/sentry/"): + suffix = path.removeprefix("/api/sentry/").strip("/") + parts = [part for part in suffix.split("/") if part] + if len(parts) == 2: + return parts[0].strip(), parts[1].strip() + return None + if not path.startswith("/api/"): + return None + suffix = path.removeprefix("/api/").strip("/") + parts = [part for part in suffix.split("/") if part] + if len(parts) == 2 and parts[1].strip().lower() in {"store", "envelope"}: + return parts[0].strip(), parts[1].strip() + return None + + +def _bearer_token(headers: Any) -> str: + auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return "" + + +def _header_value(headers: Any, name: str) -> str: + lname = name.lower() + for key, value in headers.items(): + if str(key).lower() == lname: + return str(value) + return "" + + +def _extract_replay_sdk_key(headers: Any, query: dict[str, str]) -> str: + direct = _header_value(headers, "x-retrace-key").strip() + if direct: + return direct + auth = _header_value(headers, "authorization").strip() + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return str( + query.get("key") or query.get("api_key") or query.get("apiKey") or "" + ).strip() + + +def _rate_limit_headers(decision: RateLimitDecision) -> dict[str, str]: + return { + "Retry-After": str(decision.reset_after_seconds), + "X-RateLimit-Limit": str(decision.limit), + "X-RateLimit-Remaining": str(decision.remaining), + "X-RateLimit-Reset": str(decision.reset_after_seconds), + "X-RateLimit-Window": str(decision.window_seconds), + } + + +def _rate_limited_response( + handler: BaseHTTPRequestHandler, + *, + bucket: str, + decision: RateLimitDecision, +) -> None: + _json_response( + handler, + 429, + { + "error": "rate_limited", + "message": f"{bucket} ingest rate limit exceeded.", + "limit": decision.limit, + "remaining": decision.remaining, + "retry_after_seconds": decision.reset_after_seconds, + "window_seconds": decision.window_seconds, + }, + headers=_rate_limit_headers(decision), + ) + + +def _consume_rate_limit( + store: Storage, + *, + project_id: str, + environment_id: str, + bucket: str, + identity: str, +) -> RateLimitDecision: + limit, window_seconds = INGEST_RATE_LIMITS[bucket] + return store.consume_ingest_rate_limit( + project_id=project_id, + environment_id=environment_id, + bucket=bucket, + identity=identity, + limit=limit, + window_seconds=window_seconds, + ) + + +def _require_service_token( + handler: BaseHTTPRequestHandler, + store: Storage, + *, + scopes: set[str], +): + token = authenticate_service_token(store, _bearer_token(handler.headers)) + if token is None: + _json_response( + handler, + 401, + {"error": "unauthorized", "message": "Missing or invalid service token."}, + ) + return None + if scopes and not scopes.intersection(set(token.scopes)): + _json_response( + handler, + 403, + {"error": "forbidden", "message": "Service token lacks the required scope."}, + ) + return None + return token + + +def _row_dict(row: Any, *, include_payload: bool = False) -> dict[str, Any]: + out = {k: row[k] for k in row.keys()} + for key in ( + "metadata_json", + "preview_json", + "signal_summary_json", + "reproduction_steps_json", + ): + if key in out: + try: + out[key.removesuffix("_json")] = json.loads(out[key] or "{}") + except json.JSONDecodeError: + out[key.removesuffix("_json")] = {} if key != "reproduction_steps_json" else [] + del out[key] + if not include_payload and "payload_json" in out: + del out["payload_json"] + return out + + +def _dt_api(value: Any) -> str: + return value.isoformat() if hasattr(value, "isoformat") else "" + + +def _incident_api_dict( + *, + store: Storage, + incident: Any, + failures: list[Any] | None = None, +) -> dict[str, Any]: + linked_failures = failures + if linked_failures is None: + linked_failures = store.list_incident_failures(incident_id=incident.id) + representative = _latest_failure(linked_failures) + trace_ids: list[str] = [] + top_stack_frame = "" + transaction = "" + release = "" + provider = "" + alert_state = "active" + alert_rule_name = "" + if representative is not None: + metadata = dict(getattr(representative, "metadata", {}) or {}) + trace_value = metadata.get("trace_ids") + if isinstance(trace_value, list): + trace_ids = [str(item) for item in trace_value if str(item).strip()] + top_stack_frame = str(metadata.get("top_stack_frame") or "") + transaction = str(metadata.get("transaction") or metadata.get("route") or "") + release = str(metadata.get("release") or metadata.get("deploy_sha") or "") + provider = str(metadata.get("provider") or "") + alert_state = str(metadata.get("alert_state") or "active") + alert_rule_name = str(metadata.get("alert_rule_name") or "") + return { + "id": incident.id, + "public_id": incident.public_id, + "title": incident.title, + "summary": incident.summary, + "severity": incident.severity, + "status": incident.status, + "failure_count": incident.failure_count, + "evidence_count": incident.evidence_count, + "repair_task_id": incident.repair_task_id, + "group_key": incident.group_key, + "metadata": dict(incident.metadata or {}), + "first_seen_at": _dt_api(incident.created_at), + "last_seen_at": _dt_api(incident.updated_at), + "latest_failure": ( + _failure_api_dict(representative, include_metadata=False) + if representative is not None + else None + ), + "trace_ids": trace_ids, + "top_stack_frame": top_stack_frame, + "transaction": transaction, + "release": release, + "provider": provider, + "alert_state": alert_state, + "alert_rule_name": alert_rule_name, + } + + +def _latest_failure(failures: list[Any]) -> Any | None: + if not failures: + return None + return max( + failures, + key=lambda failure: ( + int(getattr(failure, "last_seen_ms", 0) or 0), + _dt_api(getattr(failure, "updated_at", "")), + str(getattr(failure, "id", "")), + ), + ) + + +def _failure_api_dict(failure: Any, *, include_metadata: bool = True) -> dict[str, Any]: + payload = { + "id": failure.id, + "public_id": failure.public_id, + "source_type": failure.source_type, + "source_external_id": failure.source_external_id, + "fingerprint": failure.fingerprint, + "title": failure.title, + "summary": failure.summary, + "severity": failure.severity, + "confidence": failure.confidence, + "status": failure.status, + "affected_users": failure.affected_users, + "affected_sessions": failure.affected_sessions, + "first_seen_ms": failure.first_seen_ms, + "last_seen_ms": failure.last_seen_ms, + "related_deploy_sha": failure.related_deploy_sha, + "linked_tests": list(failure.linked_tests or []), + "linked_repair_task_id": failure.linked_repair_task_id, + "created_at": _dt_api(failure.created_at), + "updated_at": _dt_api(failure.updated_at), + } + if include_metadata: + payload["metadata"] = dict(failure.metadata or {}) + return payload + + +def _evidence_api_dict(evidence: Any) -> dict[str, Any]: + return { + "id": evidence.id, + "failure_id": evidence.failure_id, + "evidence_type": evidence.evidence_type, + "occurred_at_ms": evidence.occurred_at_ms, + "source": evidence.source, + "redaction_state": evidence.redaction_state, + "payload": dict(evidence.payload or {}), + "artifact_path": evidence.artifact_path, + "created_at": _dt_api(evidence.created_at), + } + + +def _incident_lifecycle_event_api_dict(event: Any) -> dict[str, Any]: + return { + "id": event.id, + "incident_id": event.incident_id, + "from_status": event.from_status, + "to_status": event.to_status, + "actor_type": event.actor_type, + "actor_id": event.actor_id, + "reason": event.reason, + "metadata": dict(event.metadata or {}), + "created_at": _dt_api(event.created_at), + } + + +def _repair_task_api_dict(task: Any) -> dict[str, Any]: + return { + "id": task.id, + "public_id": task.public_id, + "failure_id": task.failure_id, + "source_type": task.source_type, + "source_external_id": task.source_external_id, + "title": task.title, + "status": task.status, + "likely_files": list(task.likely_files or []), + "validation_commands": list(task.validation_commands or []), + "branch": task.branch, + "pr_url": task.pr_url, + "risk_notes": task.risk_notes, + "metadata": dict(task.metadata or {}), + "evidence_ids": list(task.evidence_ids or []), + "created_at": _dt_api(task.created_at), + "updated_at": _dt_api(task.updated_at), + } + + +def _hosted_onboarding_manifest( + *, + base_url: str, + project_id: str, + environment_id: str, + sdk_key: str, + service_token: str, + service_token_scopes: Iterable[str], + release: str = "$GITHUB_SHA", + artifact_url: str = "https://cdn.example.com/assets/app.min.js", +) -> dict[str, Any]: + clean_base_url = base_url.rstrip("/") or "http://127.0.0.1:8788" + clean_release = release.strip() or "$GITHUB_SHA" + clean_artifact_url = artifact_url.strip() or "https://cdn.example.com/assets/app.min.js" + sentry_dsn = build_sentry_dsn( + public_key=sdk_key, + base_url=clean_base_url, + project_id=project_id, + ) + monitoring_webhook = ( + f"{clean_base_url}/api/monitoring/webhook/sentry?environment_id={environment_id}" + ) + source_map_endpoint = f"{clean_base_url}/api/source-maps?environment_id={environment_id}" + app_errors_endpoint = f"{clean_base_url}/api/app-errors?environment_id={environment_id}" + alert_rules_endpoint = ( + f"{clean_base_url}/api/app-error-alert-rules?environment_id={environment_id}" + ) + prune_endpoint = f"{clean_base_url}/api/app-errors/prune?environment_id={environment_id}" + health_endpoint = f"{clean_base_url}/healthz" + smoke_event_id = "retrace-onboarding-smoke-1" + source_map_upload = ( + "curl -X POST " + f"'{source_map_endpoint}' " + f"-H 'Authorization: Bearer {service_token}' " + "-H 'Content-Type: application/json' " + f"-d '{{\"release\":\"{clean_release}\",\"artifact_url\":\"{clean_artifact_url}\",\"source_map\":{{\"version\":3,\"sources\":[\"src/app.ts\"],\"names\":[],\"mappings\":\"AAAA\"}}}}'" + ) + monitoring_smoke = ( + "curl -X POST " + f"'{monitoring_webhook}' " + f"-H 'Authorization: Bearer {service_token}' " + "-H 'Content-Type: application/json' " + f"-d '{{\"event\":{{\"event_id\":\"{smoke_event_id}\",\"title\":\"Retrace onboarding smoke error\",\"level\":\"error\",\"release\":\"{clean_release}\"}}}}'" + ) + alert_rule_create = ( + "curl -X POST " + f"'{alert_rules_endpoint}' " + f"-H 'Authorization: Bearer {service_token}' " + "-H 'Content-Type: application/json' " + "-d '{\"name\":\"Critical production errors\",\"action\":\"alert\",\"min_severity\":\"high\"}'" + ) + retention_prune = ( + "curl -X POST " + f"'{prune_endpoint}' " + f"-H 'Authorization: Bearer {service_token}' " + "-H 'Content-Type: application/json' " + "-d '{\"failure_retention_days\":90,\"evidence_retention_days\":90,\"source_map_retention_days\":30,\"rate_limit_retention_hours\":48}'" + ) + return { + "workspace": { + "project_id": project_id, + "environment_id": environment_id, + "api_base_url": clean_base_url, + }, + "credentials": { + "browser_sdk_key": sdk_key, + "service_token": service_token, + "service_token_scopes": [str(scope) for scope in service_token_scopes], + "sentry_dsn": sentry_dsn, + }, + "endpoints": { + "replay_ingest": f"{clean_base_url}/api/sdk/replay", + "sentry_store": f"{clean_base_url}/api/sentry/{project_id}/store/", + "sentry_envelope": f"{clean_base_url}/api/sentry/{project_id}/envelope/", + "monitoring_webhook": monitoring_webhook, + "source_maps": source_map_endpoint, + "app_errors": app_errors_endpoint, + "app_error_alert_rules": alert_rules_endpoint, + "app_error_retention_prune": prune_endpoint, + }, + "snippets": { + "browser_sdk_install": "npm install @retrace/browser", + "browser_sdk_init": ( + "import { initRetrace } from '@retrace/browser';\n\n" + "initRetrace({\n" + f" apiBaseUrl: '{clean_base_url}',\n" + f" key: '{sdk_key}',\n" + " captureConsole: true,\n" + " captureNetwork: true,\n" + " captureClicks: true,\n" + "});" + ), + "sentry_js_init": ( + "import * as Sentry from '@sentry/browser';\n\n" + "Sentry.init({\n" + f" dsn: '{sentry_dsn}',\n" + f" release: '{clean_release}',\n" + "});" + ), + "monitoring_webhook_curl": ( + monitoring_smoke + ), + "source_map_upload_curl": ( + source_map_upload + ), + "alert_rule_curl": ( + alert_rule_create + ), + "resolve_incident_curl": ( + "curl -X POST " + f"'{clean_base_url}/api/app-errors//lifecycle?environment_id={environment_id}' " + f"-H 'Authorization: Bearer {service_token}' " + "-H 'Content-Type: application/json' " + "-d '{\"action\":\"resolve\",\"reason\":\"fixed and verified\"}'" + ), + "retention_cron": ( + retention_prune + ), + "github_actions_source_maps": ( + "name: Upload Retrace source maps\n" + "on: [push]\n" + "jobs:\n" + " upload-source-maps:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v4\n" + " - run: npm ci && npm run build\n" + " - run: |\n" + " curl -X POST " + f"'{source_map_endpoint}' \\\n" + " -H 'Authorization: Bearer ${{ secrets.RETRACE_SERVICE_TOKEN }}' \\\n" + " -H 'Content-Type: application/json' \\\n" + " --data-binary @retrace-source-map-upload.json" + ), + }, + "verification": { + "ordered_steps": [ + { + "id": "health", + "label": "API health", + "command": f"curl -fsS '{health_endpoint}'", + "expect": {"status": 200, "json": {"ok": True}}, + }, + { + "id": "source_maps", + "label": "Upload source map for release", + "command": source_map_upload, + "expect": { + "status": 202, + "json_contains": { + "source_map": { + "release": clean_release, + "artifact_url": clean_artifact_url, + } + }, + }, + }, + { + "id": "alert_rule", + "label": "Create high-severity alert rule", + "command": alert_rule_create, + "expect": { + "status": 202, + "json_contains": {"rule": {"action": "alert"}}, + }, + }, + { + "id": "monitoring_smoke", + "label": "Send monitoring smoke error", + "command": monitoring_smoke, + "expect": { + "status": 202, + "json_contains": {"results": [{"external_id": smoke_event_id}]}, + }, + }, + { + "id": "app_errors", + "label": "Confirm smoke incident is visible", + "command": ( + f"curl -fsS '{app_errors_endpoint}' " + f"-H 'Authorization: Bearer {service_token}'" + ), + "expect": { + "status": 200, + "json_path_hint": "$.incidents[?(@.latest_failure.source_external_id contains retrace-onboarding-smoke-1)]", + }, + }, + { + "id": "retention", + "label": "Verify hosted retention prune endpoint", + "command": retention_prune, + "expect": { + "status": 202, + "json_contains": {"retention": {"environment_id": environment_id}}, + }, + }, + ], + "required_scopes": [str(scope) for scope in service_token_scopes], + "release": clean_release, + "artifact_url": clean_artifact_url, + }, + "checklist": [ + "Start the API with `retrace api serve --host 0.0.0.0 --port 8788` behind TLS.", + "Install the browser SDK or point Sentry-compatible clients at the DSN.", + "Upload source maps from CI for each release before or during deploy.", + "Create at least one alert rule for high-severity production errors.", + "Send the monitoring smoke webhook and confirm it appears in `GET /api/app-errors`.", + "Schedule the retention prune request daily for hosted cleanup.", + ], + } + + +def _alert_rule_api_dict(rule: Any) -> dict[str, Any]: + return { + "id": rule.id, + "public_id": rule.public_id, + "name": rule.name, + "enabled": rule.enabled, + "precedence": rule.precedence, + "action": rule.action, + "min_severity": rule.min_severity, + "provider": rule.provider, + "title_contains": rule.title_contains, + "fingerprint_contains": rule.fingerprint_contains, + "route_contains": rule.route_contains, + "metadata": dict(rule.metadata or {}), + "created_at": _dt_api(rule.created_at), + "updated_at": _dt_api(rule.updated_at), + } + + +def _retention_result_api_dict(result: Any) -> dict[str, Any]: + return { + "dry_run": bool(result.dry_run), + "failure_retention_days": int(result.failure_retention_days), + "evidence_retention_days": int(result.evidence_retention_days), + "source_map_retention_days": int(result.source_map_retention_days), + "rate_limit_retention_hours": int(result.rate_limit_retention_hours), + "deleted": { + "failures": int(result.failures), + "evidence": int(result.evidence), + "incident_links": int(result.incident_links), + "incidents": int(result.incidents), + "source_maps": int(result.source_maps), + "rate_limit_rows": int(result.rate_limit_rows), + }, + } + + +def _optional_int(payload: dict[str, Any], key: str, default: int) -> int: + value = payload.get(key, None) + if value is None: + return default + return int(value) + + +def _optional_bool(payload: dict[str, Any], key: str, default: bool = False) -> bool: + value = payload.get(key, None) + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + raise ValueError(f"{key} must be a boolean") + + +def _app_error_notification_payload( + *, + store: Storage, + result: Any, +) -> NotificationPayload | None: + failure = store.get_failure_by_id(str(result.failure_id)) + if failure is None: + return None + incident_public_id = str(getattr(result, "incident_public_id", "") or "") + public_id = incident_public_id or str(getattr(result, "failure_public_id", "") or "") + provider = str(getattr(result, "provider", "") or "") + return NotificationPayload( + event=NotificationEvent.APP_ERROR_CREATED.value, + title=f"{provider.title() or 'App'} error: {failure.title}", + summary=failure.summary, + severity=failure.severity, + public_id=public_id, + extra={ + "provider": provider, + "incident_id": str(getattr(result, "incident_id", "") or ""), + "incident_public_id": incident_public_id, + "failure_id": failure.id, + "failure_public_id": failure.public_id, + "evidence_id": str(getattr(result, "evidence_id", "") or ""), + "source_external_id": failure.source_external_id, + "trace_ids": list((failure.metadata or {}).get("trace_ids") or []), + "top_stack_frame": str((failure.metadata or {}).get("top_stack_frame") or ""), + }, + ) + + +def _dispatch_app_error_notifications( + *, + sinks: Iterable[Any], + store: Storage, + results: Iterable[Any], + lock: Lock | None = None, +) -> None: + sink_list = list(sinks) + if not sink_list: + return + for result in results: + if not bool(getattr(result, "created", False)): + continue + payload = _app_error_notification_payload(store=store, result=result) + if payload is not None: + try: + if lock is None: + dispatch_notification(sink_list, payload) + else: + with lock: + dispatch_notification(sink_list, payload) + except Exception: + logger.warning( + "failed to dispatch app_error notification", + extra={ + "failure_id": str(getattr(result, "failure_id", "") or ""), + "incident_id": str(getattr(result, "incident_id", "") or ""), + }, + exc_info=True, + ) + + +def _build_enricher(cfg: Any, store: Storage) -> CorrelationEnricher | None: + """Construct a best-effort correlation enricher when PostHog is configured. + + Returns None if PostHog credentials are missing OR if construction fails + (e.g. malformed host URL). Replay processing must never block on an + optional enrichment feature, so any failure here downgrades to "no + enricher" rather than propagating. + """ + try: + if not getattr(cfg.posthog, "api_key", "").strip(): + return None + except AttributeError: + return None + try: + return CorrelationEnricher(cfg, store) + except Exception: + logger.warning( + "correlation enricher disabled due to invalid PostHog config", + exc_info=True, + ) + return None + + +def _issue_cards_for_items( + store: Storage, + items: list[dict[str, str]], +) -> list[dict[str, Any]]: + cards: list[dict[str, Any]] = [] + for item in items[:10]: + public_id = str(item.get("public_id") or "") + project_id = str(item.get("project_id") or "") + environment_id = str(item.get("environment_id") or "") + if not public_id or not project_id or not environment_id: + continue + issue = store.get_replay_issue( + project_id=project_id, + environment_id=environment_id, + issue_id=public_id, + ) + if issue is None: + continue + sessions = store.list_replay_issue_sessions(str(issue["id"])) + cards.append(build_issue_card(store=store, issue=issue, sessions=sessions)) + return cards + + +def _handler( + store: Storage, + *, + enricher: CorrelationEnricher | None = None, + github_webhook_secret: str = "", + notification_sinks: Iterable[Any] = (), +) -> type[BaseHTTPRequestHandler]: + notification_lock = Lock() + + class RetraceAPIHandler(BaseHTTPRequestHandler): + server_version = "retrace-api/0.1" + + def handle_one_request(self) -> None: + self._retrace_trace_id = uuid.uuid4().hex + self._retrace_response_status = 500 + started = time.perf_counter() + try: + super().handle_one_request() + finally: + latency_ms = (time.perf_counter() - started) * 1000 + method = str(getattr(self, "command", "") or "") + path = urlsplit(str(getattr(self, "path", "") or "")).path + status = int(getattr(self, "_retrace_response_status", 500)) + if method and path: + record_api_request( + method=method, + path=path, + status=status, + latency_ms=latency_ms, + trace_id=self._retrace_trace_id, + ) + logger.info( + json.dumps( + { + "event": "api_request", + "trace_id": self._retrace_trace_id, + "method": method, + "path": path, + "status": status, + "latency_ms": round(latency_ms, 3), + }, + separators=(",", ":"), + ) + ) + + def do_GET(self) -> None: # noqa: N802 + parsed = urlsplit(self.path) + if parsed.path == "/healthz": + _json_response(self, 200, {"ok": True}) + return + if parsed.path == "/api/metrics": + self._handle_metrics() + return + if parsed.path == "/api/replays": + self._handle_list_replays(parsed.query) + return + if parsed.path.startswith("/api/replays/"): + replay_id = parsed.path.removeprefix("/api/replays/").strip("/") + self._handle_get_replay(replay_id, parsed.query) + return + if parsed.path == "/api/app-errors": + self._handle_list_app_error_incidents(parsed.query) + return + if parsed.path == "/api/app-error-alert-rules": + self._handle_list_app_error_alert_rules(parsed.query) + return + if parsed.path.startswith("/api/app-errors/"): + incident_id = parsed.path.removeprefix("/api/app-errors/").strip("/") + self._handle_get_app_error_incident(incident_id, parsed.query) + return + if parsed.path == "/api/issues": + self._handle_list_issues(parsed.query) + return + _json_response(self, 404, {"error": "not_found"}) + + def do_OPTIONS(self) -> None: # noqa: N802 + parsed = urlsplit(self.path) + if ( + parsed.path != "/api/sdk/replay" + and parsed.path != "/api/deploys" + and parsed.path != "/api/source-maps" + and parsed.path != "/api/onboarding/hosted" + and parsed.path != "/api/app-error-alert-rules" + and parsed.path != "/api/app-errors/prune" + and not ( + parsed.path.startswith("/api/app-errors/") + and parsed.path.endswith("/lifecycle") + ) + and not parsed.path.startswith("/api/otel/") + and not parsed.path.startswith("/api/sentry/") + and _sentry_ingest_path_parts(parsed.path) is None + and not parsed.path.startswith("/api/monitoring/webhook") + and parsed.path != "/api/github/webhook" + ): + _json_response(self, 404, {"error": "not_found"}) + return + self._retrace_response_status = 204 + self.send_response(204) + trace_id = str(getattr(self, "_retrace_trace_id", "") or "") + if trace_id: + self.send_header("X-Retrace-Trace-Id", trace_id) + _cors_headers(self) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_POST(self) -> None: # noqa: N802 + parsed = urlsplit(self.path) + if parsed.path == "/api/replays/process": + self._handle_process_replays() + return + if parsed.path == "/api/deploys": + self._handle_record_deploy(parsed.query) + return + if parsed.path == "/api/source-maps": + self._handle_upload_source_map(parsed.query) + return + if parsed.path == "/api/app-error-alert-rules": + self._handle_upsert_app_error_alert_rule(parsed.query) + return + if parsed.path == "/api/app-errors/prune": + self._handle_prune_app_errors(parsed.query) + return + if parsed.path == "/api/onboarding/hosted": + self._handle_hosted_onboarding(parsed.query) + return + if parsed.path.startswith("/api/app-errors/") and parsed.path.endswith( + "/lifecycle" + ): + incident_id = parsed.path.removeprefix("/api/app-errors/")[ + : -len("/lifecycle") + ].strip("/") + self._handle_app_error_incident_lifecycle(incident_id, parsed.query) + return + if parsed.path in {"/api/otel/v1/logs", "/api/otel/v1/traces"}: + self._handle_otel_ingest(parsed.path, parsed.query) + return + if parsed.path == "/api/monitoring/webhook" or parsed.path.startswith( + "/api/monitoring/webhook/" + ): + self._handle_monitoring_webhook(parsed.path, parsed.query) + return + if parsed.path.startswith("/api/sentry/"): + self._handle_sentry_compat_ingest(parsed.path, parsed.query) + return + if _sentry_ingest_path_parts(parsed.path) is not None: + self._handle_sentry_compat_ingest(parsed.path, parsed.query) + return + if parsed.path == "/api/github/webhook": + self._handle_github_webhook() + return + if parsed.path == "/api/sdk/server-replay": + self._handle_server_replay_ingest(parsed.query) + return + if parsed.path != "/api/sdk/replay": + _json_response(self, 404, {"error": "not_found"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length < 0: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response( + self, + 413, + { + "error": "body_too_large", + "message": "Replay batch is too large.", + }, + ) + return + replay_query = _query_dict(parsed.query) + sdk_key = authenticate_sdk_key( + store, _extract_replay_sdk_key(self.headers, replay_query) + ) + if sdk_key is None: + _json_response( + self, + 401, + { + "error": "unauthorized", + "message": "Missing or invalid SDK key.", + }, + ) + return + decision = _consume_rate_limit( + store, + project_id=sdk_key.project_id, + environment_id=sdk_key.environment_id, + bucket="replay", + identity=sdk_key.id, + ) + if not decision.allowed: + _rate_limited_response(self, bucket="replay", decision=decision) + return + try: + body = self.rfile.read(length) + result = ingest_replay_request( + store=store, + headers={k: v for k, v in self.headers.items()}, + body=body, + query=replay_query, + ) + _json_response(self, 202, result) + except ReplayIngestError as exc: + _json_response( + self, + exc.status, + {"error": exc.code, "message": exc.message}, + ) + except Exception: + logger.exception("Unhandled replay ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + + def _handle_sentry_compat_ingest(self, path: str, query: str) -> None: + parts = _sentry_ingest_path_parts(path) + if parts is None: + _json_response(self, 404, {"error": "not_found"}) + return + project_id, endpoint = parts + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length < 0: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length > MAX_SENTRY_BODY_BYTES: + _json_response( + self, + 413, + { + "error": "body_too_large", + "message": "Sentry payload is too large.", + }, + ) + return + body = self.rfile.read(length) + sentry_headers = {k: v for k, v in self.headers.items()} + sentry_query = _query_dict(query) + raw_sentry_key = extract_sentry_ingest_key( + headers=sentry_headers, + query=sentry_query, + body=body, + content_encoding=_header_value(self.headers, "content-encoding"), + ) + if raw_sentry_key: + sdk_key = authenticate_sdk_key(store, raw_sentry_key) + if sdk_key is None: + _json_response( + self, + 401, + { + "error": "unauthorized", + "message": "Missing or invalid Sentry SDK key.", + }, + ) + return + if project_id and sdk_key.project_id != project_id: + _json_response( + self, + 403, + { + "error": "forbidden", + "message": "SDK key does not belong to this project.", + }, + ) + return + decision = _consume_rate_limit( + store, + project_id=sdk_key.project_id, + environment_id=sdk_key.environment_id, + bucket="sentry", + identity=sdk_key.id, + ) + if not decision.allowed: + _rate_limited_response(self, bucket="sentry", decision=decision) + return + try: + result = ingest_sentry_compat_request( + store=store, + project_id=project_id, + endpoint=endpoint, + headers=sentry_headers, + body=body, + query=sentry_query, + ) + _dispatch_app_error_notifications( + sinks=notification_sinks, + store=store, + results=result.results, + lock=notification_lock, + ) + except SentryCompatIngestError as exc: + _json_response( + self, + exc.status, + {"error": exc.code, "message": exc.message}, + ) + return + except Exception: + logger.exception("Unhandled Sentry compatibility ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202, result.to_dict()) + + def _handle_monitoring_webhook(self, path: str, query: str) -> None: + token = _require_service_token( + self, store, scopes={"monitoring:write", "ingest", "admin"} + ) + if token is None: + return + params = _query_dict(query) + provider = str(params.get("provider") or "").strip().lower() + suffix = path.removeprefix("/api/monitoring/webhook").strip("/") + if suffix: + provider = suffix.split("/", 1)[0].strip().lower() + if not provider: + _json_response( + self, + 400, + { + "error": "missing_provider", + "message": "provider is required in the path or query string.", + }, + ) + return + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response( + self, + 400, + { + "error": "missing_environment_id", + "message": "environment_id is required.", + }, + ) + return + decision = _consume_rate_limit( + store, + project_id=token.project_id, + environment_id=environment_id, + bucket="monitoring", + identity=f"{token.id}:{provider}", + ) + if not decision.allowed: + _rate_limited_response(self, bucket="monitoring", decision=decision) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length < 0: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response( + self, + 413, + { + "error": "body_too_large", + "message": "Webhook payload is too large.", + }, + ) + return + if length == 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict) or not payload: + _json_response(self, 400, {"error": "invalid_payload"}) + return + try: + result = ingest_monitoring_webhook( + store=store, + project_id=token.project_id, + environment_id=environment_id, + provider=provider, + payload=payload, + ) + _dispatch_app_error_notifications( + sinks=notification_sinks, + store=store, + results=[result], + lock=notification_lock, + ) + except Exception: + logger.exception("Unhandled monitoring webhook ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202, result.to_dict()) + + def _handle_github_webhook(self) -> None: + if not github_webhook_secret.strip(): + _json_response( + self, + 503, + { + "error": "github_app_not_configured", + "message": "GitHub App webhook secret is not configured.", + }, + ) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + result = handle_github_webhook( + store=store, + body=body, + headers={k: v for k, v in self.headers.items()}, + webhook_secret=github_webhook_secret, + ) + except GitHubWebhookError as exc: + _json_response( + self, + exc.status, + {"error": exc.code, "message": exc.message}, + ) + return + except Exception: + logger.exception("Unhandled GitHub webhook error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202 if result.accepted else 200, result.to_dict()) + + def _handle_record_deploy(self, query: str) -> None: + token = _require_service_token( + self, store, scopes={"deploy:write", "ingest", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict) or not payload: + _json_response(self, 400, {"error": "invalid_payload"}) + return + sha = str(payload.get("sha") or payload.get("commit_sha") or "").strip() + if not sha: + _json_response(self, 400, {"error": "missing_sha"}) + return + changed_files = payload.get("changed_files", payload.get("changedFiles")) + clean_changed_files: list[str] | None = None + if changed_files is not None: + if not isinstance(changed_files, list): + _json_response(self, 400, {"error": "invalid_changed_files"}) + return + clean_changed_files = [str(item) for item in changed_files] + try: + deployed_at_ms = int(payload.get("deployed_at_ms") or 0) + except (TypeError, ValueError): + _json_response(self, 400, {"error": "invalid_deployed_at_ms"}) + return + try: + deploy = record_deploy( + store=store, + project_id=token.project_id, + environment_id=environment_id, + sha=sha, + branch=str(payload.get("branch") or ""), + author=str(payload.get("author") or ""), + deployed_at_ms=deployed_at_ms, + changed_files=clean_changed_files, + metadata=( + dict(payload.get("metadata")) + if isinstance(payload.get("metadata"), dict) + else None + ), + ) + correlations = correlate_recent_failures_to_deploys( + store=store, + project_id=token.project_id, + environment_id=environment_id, + ) + correlations = [ + item for item in correlations if item.deploy_sha == deploy.sha + ] + except Exception: + logger.exception("Unhandled deploy ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response( + self, + 202, + { + "deploy": { + "id": deploy.id, + "public_id": deploy.public_id, + "sha": deploy.sha, + "branch": deploy.branch, + "author": deploy.author, + "deployed_at_ms": deploy.deployed_at_ms, + "changed_files": deploy.changed_files, + }, + "correlated_failures": [ + {"failure_id": item.failure_id, "deploy_sha": item.deploy_sha} + for item in correlations + ], + }, + ) + + def _handle_upload_source_map(self, query: str) -> None: + token = _require_service_token( + self, store, scopes={"source_maps:write", "ingest", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + decision = _consume_rate_limit( + store, + project_id=token.project_id, + environment_id=environment_id, + bucket="source_maps", + identity=token.id, + ) + if not decision.allowed: + _rate_limited_response(self, bucket="source_maps", decision=decision) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict) or not payload: + _json_response(self, 400, {"error": "invalid_payload"}) + return + release = str(payload.get("release") or "").strip() + artifact_url = str( + payload.get("artifact_url") + or payload.get("artifactUrl") + or payload.get("url") + or "" + ).strip() + source_map = payload.get("source_map") or payload.get("sourceMap") + if not release: + _json_response(self, 400, {"error": "missing_release"}) + return + if not artifact_url: + _json_response(self, 400, {"error": "missing_artifact_url"}) + return + if not isinstance(source_map, dict) or not source_map: + _json_response(self, 400, {"error": "invalid_source_map"}) + return + try: + row = upload_source_map( + store=store, + project_id=token.project_id, + environment_id=environment_id, + release=release, + dist=str(payload.get("dist") or ""), + artifact_url=artifact_url, + source_map=source_map, + ) + except ValueError as exc: + _json_response(self, 400, {"error": "invalid_source_map", "message": str(exc)}) + return + except Exception: + logger.exception("Unhandled source map upload error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response( + self, + 202, + { + "source_map": { + "id": row.id, + "public_id": row.public_id, + "release": row.release, + "dist": row.dist, + "artifact_url": row.artifact_url, + } + }, + ) + + def _handle_otel_ingest(self, path: str, query: str) -> None: + token = _require_service_token( + self, store, scopes={"otel:write", "ingest", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + # Rate limit *before* reading the body — a flooded client + # shouldn't get to spend our bandwidth uploading a payload + # we're about to reject. Identity is the service-token id + # so different tokens for the same project don't share a + # bucket (operators sometimes split tokens per host). + decision = _consume_rate_limit( + store, + project_id=token.project_id, + environment_id=environment_id, + bucket="otel", + identity=token.id, + ) + if not decision.allowed: + _rate_limited_response(self, bucket="otel", decision=decision) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict) or not payload: + _json_response(self, 400, {"error": "invalid_payload"}) + return + try: + result = ( + ingest_otel_logs( + store=store, + project_id=token.project_id, + environment_id=environment_id, + payload=payload, + ) + if path.endswith("/logs") + else ingest_otel_traces( + store=store, + project_id=token.project_id, + environment_id=environment_id, + payload=payload, + ) + ) + except Exception: + logger.exception("Unhandled OpenTelemetry ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202, result.to_dict()) + + def _handle_server_replay_ingest(self, query: str) -> None: + """P3.6 (scaffold) — accept a single server-side replay + session record. + + Payload shape (JSON): + { + "session_id": "...", + "request": { + "method": "GET", + "path": "/api/checkout", + "headers": {...}, + "body": "..." + }, + "response": { + "status": 500, + "headers": {...} + }, + "rendered_html": "...", // optional snippet + "runtime": "node-20", // optional + "occurred_at_ms": 0, // optional; defaults to now + "error_summary": "...", // optional + "metadata": {...} // optional + } + + The capture middleware that produces this payload is + deferred — see `docs/roadmap.md` P3.6. This endpoint + exists today so that middleware has a defined seam to + ship against, and so the ingest path can be hardened / + rate-limited / tested before SDK work begins. + """ + replay_query = _query_dict(query) + sdk_key = authenticate_sdk_key( + store, _extract_replay_sdk_key(self.headers, replay_query) + ) + if sdk_key is None: + _json_response( + self, + 401, + { + "error": "unauthorized", + "message": "Missing or invalid SDK key.", + }, + ) + return + decision = _consume_rate_limit( + store, + project_id=sdk_key.project_id, + environment_id=sdk_key.environment_id, + bucket="server_replay", + identity=sdk_key.id, + ) + if not decision.allowed: + _rate_limited_response(self, bucket="server_replay", decision=decision) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict): + _json_response(self, 400, {"error": "invalid_payload"}) + return + request_block = payload.get("request") or {} + response_block = payload.get("response") or {} + if not isinstance(request_block, dict) or not isinstance( + response_block, dict + ): + _json_response( + self, + 400, + { + "error": "invalid_payload", + "message": "`request` and `response` must be objects.", + }, + ) + return + try: + row_id = store.insert_server_replay_session( + project_id=sdk_key.project_id, + environment_id=sdk_key.environment_id, + session_id=str(payload.get("session_id") or ""), + request_method=str(request_block.get("method") or ""), + request_path=str(request_block.get("path") or ""), + request_headers=request_block.get("headers") or {}, + request_body_text=str(request_block.get("body") or ""), + response_status=int(response_block.get("status") or 0), + response_headers=response_block.get("headers") or {}, + rendered_html_snippet=str(payload.get("rendered_html") or ""), + runtime=str(payload.get("runtime") or ""), + occurred_at_ms=int(payload.get("occurred_at_ms") or 0), + error_summary=str(payload.get("error_summary") or ""), + metadata=payload.get("metadata") or {}, + ) + except (TypeError, ValueError) as exc: + _json_response( + self, + 400, + {"error": "invalid_payload", "message": str(exc)}, + ) + return + except Exception: + logger.exception("Unhandled server-replay ingest error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202, {"id": row_id, "accepted": 1}) + + def _handle_list_replays(self, query: str) -> None: + token = _require_service_token( + self, store, scopes={"replay:read", "mcp:read", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response( + self, + 400, + { + "error": "missing_environment_id", + "message": "environment_id is required.", + }, + ) + return + status = str(params.get("status") or "").strip() or None + try: + limit = int(params.get("limit") or "100") + except ValueError: + _json_response(self, 400, {"error": "invalid_limit"}) + return + rows = store.list_replay_sessions( + project_id=token.project_id, + environment_id=environment_id, + status=status, + limit=limit, + ) + _json_response( + self, + 200, + { + "project_id": token.project_id, + "environment_id": environment_id, + "sessions": [_row_dict(r) for r in rows], + }, + ) + + def _handle_metrics(self) -> None: + token = _require_service_token( + self, store, scopes={"admin", "mcp:read"} + ) + if token is None: + return + _json_response(self, 200, collect_local_observability(store).to_dict()) + + def _handle_process_replays(self) -> None: + token = _require_service_token( + self, store, scopes={"replay:write", "admin"} + ) + if token is None: + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + body = self.rfile.read(max(0, length)) if length else b"{}" + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + try: + limit = int(payload.get("limit") or 25) + except (TypeError, ValueError): + _json_response(self, 400, {"error": "invalid_limit"}) + return + result = process_queued_replay_jobs( + store=store, + limit=limit, + project_id=token.project_id, + enricher=enricher, + ) + _json_response( + self, + 200, + { + "jobs_seen": result.jobs_seen, + "jobs_processed": result.jobs_processed, + "jobs_failed": result.jobs_failed, + "sessions_processed": result.sessions_processed, + "issues_created_or_updated": result.issues_created_or_updated, + "project_id": token.project_id, + }, + ) + + def _handle_get_replay(self, replay_id: str, query: str) -> None: + token = _require_service_token( + self, store, scopes={"replay:read", "mcp:read", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + replay_id = replay_id.strip() + if not replay_id: + _json_response(self, 404, {"error": "not_found"}) + return + playback = store.get_replay_playback( + project_id=token.project_id, + environment_id=environment_id, + replay_id=replay_id, + ) + if playback is None: + _json_response(self, 404, {"error": "not_found"}) + return + _json_response( + self, + 200, + { + "session": _row_dict(playback.session), + "batches": [_row_dict(b) for b in playback.batches], + "events": playback.events, + }, + ) + + def _handle_list_issues(self, query: str) -> None: + token = _require_service_token( + self, store, scopes={"issues:read", "mcp:read", "admin"} + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + status = str(params.get("status") or "").strip() or None + rows = store.list_replay_issues( + project_id=token.project_id, + environment_id=environment_id, + status=status, + ) + _json_response( + self, + 200, + { + "project_id": token.project_id, + "environment_id": environment_id, + "issues": [_row_dict(r) for r in rows], + }, + ) + + def _handle_list_app_error_incidents(self, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:read", "issues:read", "mcp:read", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + status = str(params.get("status") or "").strip() or None + try: + limit = int(params.get("limit") or "100") + except ValueError: + _json_response(self, 400, {"error": "invalid_limit"}) + return + incidents = store.list_incidents( + project_id=token.project_id, + environment_id=environment_id, + status=status, + limit=limit, + ) + failures_by_incident = store.list_incident_failures_for_incidents( + incident_ids=[incident.id for incident in incidents] + ) + _json_response( + self, + 200, + { + "project_id": token.project_id, + "environment_id": environment_id, + "incidents": [ + _incident_api_dict( + store=store, + incident=incident, + failures=failures_by_incident.get(incident.id, []), + ) + for incident in incidents + ], + }, + ) + + def _handle_list_app_error_alert_rules(self, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:read", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + try: + limit = int(params.get("limit") or "100") + offset = int(params.get("offset") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_pagination"}) + return + rules = store.list_app_error_alert_rules( + project_id=token.project_id, + environment_id=environment_id, + limit=limit, + offset=offset, + ) + _json_response( + self, + 200, + { + "project_id": token.project_id, + "environment_id": environment_id, + "limit": max(1, min(limit, 500)), + "offset": max(0, offset), + "rules": [_alert_rule_api_dict(rule) for rule in rules], + }, + ) + + def _handle_upsert_app_error_alert_rule(self, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:write", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > MAX_REPLAY_BODY_BYTES: + _json_response(self, 413, {"error": "payload_too_large"}) + return + body = self.rfile.read(length) + try: + payload = json.loads(body.decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict) or not payload: + _json_response(self, 400, {"error": "invalid_payload"}) + return + try: + rule_id = store.upsert_app_error_alert_rule( + project_id=token.project_id, + environment_id=environment_id, + name=str(payload.get("name") or ""), + enabled=bool(payload.get("enabled", True)), + precedence=int(payload.get("precedence") or 0), + action=str(payload.get("action") or "alert"), + min_severity=str(payload.get("min_severity") or ""), + provider=str(payload.get("provider") or ""), + title_contains=str(payload.get("title_contains") or ""), + fingerprint_contains=str(payload.get("fingerprint_contains") or ""), + route_contains=str(payload.get("route_contains") or ""), + metadata=( + dict(payload.get("metadata")) + if isinstance(payload.get("metadata"), dict) + else None + ), + ) + except ValueError as exc: + _json_response(self, 400, {"error": "invalid_alert_rule", "message": str(exc)}) + return + rule = next( + ( + item + for item in store.list_app_error_alert_rules( + project_id=token.project_id, + environment_id=environment_id, + enabled=None, + ) + if item.id == rule_id + ), + None, + ) + _json_response( + self, + 202, + {"rule": _alert_rule_api_dict(rule) if rule is not None else {"id": rule_id}}, + ) + + def _handle_hosted_onboarding(self, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length < 0: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length > 64 * 1024: + _json_response(self, 413, {"error": "payload_too_large"}) + return + payload: dict[str, Any] = {} + if length: + try: + decoded = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(decoded, dict): + _json_response(self, 400, {"error": "invalid_payload"}) + return + payload = decoded + raw_scopes = payload.get("service_token_scopes") + if raw_scopes is None: + service_token_scopes = list(HOSTED_ONBOARDING_SCOPES) + elif isinstance(raw_scopes, list) and all( + isinstance(item, str) and item.strip() for item in raw_scopes + ): + service_token_scopes = [str(item).strip() for item in raw_scopes] + else: + _json_response(self, 400, {"error": "invalid_service_token_scopes"}) + return + sdk = create_sdk_key( + store, + project_id=token.project_id, + environment_id=environment_id, + name=str(payload.get("sdk_key_name") or "Hosted browser SDK"), + ) + service = create_service_token( + store, + project_id=token.project_id, + name=str(payload.get("service_token_name") or "Hosted onboarding"), + scopes=service_token_scopes, + ) + manifest = _hosted_onboarding_manifest( + base_url=str(payload.get("api_base_url") or "http://127.0.0.1:8788"), + project_id=token.project_id, + environment_id=environment_id, + sdk_key=sdk.key, + service_token=service.token, + service_token_scopes=service.scopes, + release=str(payload.get("release") or "$GITHUB_SHA"), + artifact_url=str( + payload.get("artifact_url") + or "https://cdn.example.com/assets/app.min.js" + ), + ) + _json_response(self, 201, {"onboarding": manifest}) + + def _handle_prune_app_errors(self, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:write", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length < 0: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length > 64 * 1024: + _json_response(self, 413, {"error": "payload_too_large"}) + return + payload: dict[str, Any] = {} + if length: + try: + decoded = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(decoded, dict): + _json_response(self, 400, {"error": "invalid_payload"}) + return + payload = decoded + try: + result = store.prune_app_error_retention( + project_id=token.project_id, + environment_id=environment_id, + failure_retention_days=_optional_int( + payload, "failure_retention_days", 90 + ), + evidence_retention_days=_optional_int( + payload, "evidence_retention_days", 90 + ), + source_map_retention_days=_optional_int( + payload, "source_map_retention_days", 30 + ), + rate_limit_retention_hours=_optional_int( + payload, "rate_limit_retention_hours", 48 + ), + dry_run=_optional_bool(payload, "dry_run"), + ) + except (TypeError, ValueError) as exc: + _json_response(self, 400, {"error": "invalid_retention", "message": str(exc)}) + return + except Exception: + logger.exception("Unhandled app-error retention prune error") + _json_response( + self, + 500, + { + "error": "internal_error", + "message": "An internal server error occurred.", + }, + ) + return + _json_response(self, 202, {"retention": _retention_result_api_dict(result)}) + + def _handle_app_error_incident_lifecycle( + self, incident_id: str, query: str + ) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:write", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + incident_id = incident_id.strip() + if not incident_id: + _json_response(self, 404, {"error": "not_found"}) + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + _json_response(self, 400, {"error": "invalid_content_length"}) + return + if length <= 0: + _json_response(self, 400, {"error": "invalid_payload"}) + return + if length > 64 * 1024: + _json_response(self, 413, {"error": "payload_too_large"}) + return + try: + payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + except json.JSONDecodeError: + _json_response(self, 400, {"error": "invalid_json"}) + return + if not isinstance(payload, dict): + _json_response(self, 400, {"error": "invalid_payload"}) + return + action = str(payload.get("action") or "").strip().lower() + status = str(payload.get("status") or "").strip().lower() + action_statuses = { + "resolve": "resolved", + "resolved": "resolved", + "ignore": "ignored", + "ignored": "ignored", + "reopen": "open", + "open": "open", + "triage": "triaged", + "triaged": "triaged", + "investigate": "investigating", + "investigating": "investigating", + } + if action and not status: + status = action_statuses.get(action, "") + if not status: + _json_response(self, 400, {"error": "missing_status"}) + return + metadata = payload.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + _json_response(self, 400, {"error": "invalid_metadata"}) + return + try: + incident = store.transition_app_error_incident( + project_id=token.project_id, + environment_id=environment_id, + incident_id=incident_id, + status=status, + actor_type=str(payload.get("actor_type") or "service_token"), + actor_id=str(payload.get("actor_id") or token.name or token.id), + reason=str(payload.get("reason") or ""), + metadata=dict(metadata or {}), + ) + except ValueError as exc: + message = str(exc) + if "unknown incident_id" in message: + _json_response(self, 404, {"error": "not_found"}) + return + _json_response( + self, + 400, + {"error": "invalid_lifecycle_transition", "message": message}, + ) + return + failures = store.list_incident_failures(incident_id=incident.id) + events = store.list_incident_lifecycle_events(incident_id=incident.id) + _json_response( + self, + 202, + { + "project_id": token.project_id, + "environment_id": environment_id, + "incident": _incident_api_dict( + store=store, + incident=incident, + failures=failures, + ), + "failures": [ + _failure_api_dict(failure, include_metadata=False) + for failure in failures + ], + "lifecycle_events": [ + _incident_lifecycle_event_api_dict(event) for event in events + ], + }, + ) + + def _handle_get_app_error_incident(self, incident_id: str, query: str) -> None: + token = _require_service_token( + self, + store, + scopes={"app_errors:read", "issues:read", "mcp:read", "admin"}, + ) + if token is None: + return + params = _query_dict(query) + environment_id = str(params.get("environment_id") or "").strip() + if not environment_id: + _json_response(self, 400, {"error": "missing_environment_id"}) + return + incident_id = incident_id.strip() + if not incident_id: + _json_response(self, 404, {"error": "not_found"}) + return + include_sensitive = str( + params.get("include_sensitive") or "false" + ).strip().lower() in {"1", "true", "yes"} + if include_sensitive and not {"app_errors:read", "admin"}.intersection( + set(token.scopes) + ): + _json_response( + self, + 403, + { + "error": "forbidden", + "message": "include_sensitive=true requires app_errors:read scope.", + }, + ) + return + try: + detail = get_incident_detail( + store=store, + incident_id=incident_id, + include_sensitive_evidence=include_sensitive, + ) + except ValueError: + _json_response(self, 404, {"error": "not_found"}) + return + if ( + detail.incident.project_id != token.project_id + or detail.incident.environment_id != environment_id + ): + _json_response(self, 404, {"error": "not_found"}) + return + _json_response( + self, + 200, + { + "project_id": token.project_id, + "environment_id": environment_id, + "incident": _incident_api_dict( + store=store, + incident=detail.incident, + failures=detail.failures, + ), + "failures": [ + _failure_api_dict(failure) for failure in detail.failures + ], + "evidence": [ + _evidence_api_dict(evidence) for evidence in detail.evidence + ], + "lifecycle_events": [ + _incident_lifecycle_event_api_dict(event) + for event in store.list_incident_lifecycle_events( + incident_id=detail.incident.id + ) + ], + "repair_task": ( + _repair_task_api_dict(detail.repair_task) + if detail.repair_task is not None + else None + ), + }, + ) + + def log_message(self, format: str, *args: object) -> None: + click.echo(f"{self.address_string()} - {format % args}", err=True) + + return RetraceAPIHandler + + diff --git a/src/retrace/commands/ui.py b/src/retrace/commands/ui.py index 78c135a..1de4ec3 100644 --- a/src/retrace/commands/ui.py +++ b/src/retrace/commands/ui.py @@ -1,14 +1,15 @@ +"""Thin CLI wrapper for the Retrace local UI server. + +The payload-building helpers live in ui_payloads.py and the HTML template +in ui_templates.py. This module re-exports everything for backward +compatibility and defines the ``ui`` click command. +""" + from __future__ import annotations -import ipaddress import json import logging -import os -import platform import re -import socket -import shutil -import subprocess from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -16,36 +17,12 @@ from urllib.parse import parse_qs, urlparse import click -import httpx -import yaml -from retrace.api_suites import api_suites_dir_for_data_dir, list_api_suites, load_api_suite -from retrace.api_testing import ( - api_runs_dir_for_data_dir, - api_specs_dir_for_data_dir, - list_api_specs, - load_api_spec, - run_api_spec, -) -from retrace.fix_suggestions import ( - generate_fix_suggestions, - parsed_finding_from_replay_issue, - replay_issue_report_key, - slugify, -) -from retrace.evidence import build_evidence_timeline +from retrace.api_testing import run_api_spec # noqa: F401 from retrace.ingester import PostHogIngester from retrace.llm.client import LLMClient -from retrace.llm.client import build_llm_http_request -from retrace.reports.parser import parse_report_findings -from retrace.replay_specs import ( - _redacted_url, - generate_api_spec_from_replay_issue, - generate_spec_from_replay_issue, -) -from retrace.sdk_keys import create_sdk_key -from retrace.sentry_compat import build_sentry_dsn -from retrace.storage import GitHubRepoRow, Storage +from retrace.sdk_keys import create_sdk_key # noqa: F401 +from retrace.storage import Storage from retrace.tester import ( DEFAULT_APP_URL, DEFAULT_HARNESS_COMMAND, @@ -53,4025 +30,85 @@ list_specs, load_run_summaries, load_spec, - now_iso, run_spec, runs_dir_for_data_dir, - save_spec, specs_dir_for_data_dir, - validate_spec, ) -logger = logging.getLogger(__name__) - -_CLOUD_LLM_PROVIDERS = {"openai", "anthropic", "openrouter"} - - -def _create_pinned_transport( - pinned_ip: str, hostname: str, scheme: str -) -> httpx.HTTPTransport: - """Create an httpx transport that connects to a pinned IP address. - - This prevents DNS rebinding/TOCTOU attacks by ensuring the HTTP connection uses the - IP address that was validated during URL checking, rather than performing a fresh - DNS resolution that could return a different (malicious) IP. - - Args: - pinned_ip: The IP address to connect to (already validated) - hostname: The original hostname (used for Host header and, for HTTPS, SNI) - scheme: The URL scheme ('http' or 'https') - - Returns: - An HTTPTransport configured to connect to the pinned IP with proper SNI for HTTPS - - Implementation: - For HTTPS, this creates a custom NetworkBackend that wraps SSL sockets with the - correct server_hostname for SNI, ensuring TLS certificate validation works properly - even though we're connecting to an IP address. - """ - import ssl - - class PinnedHTTPTransport(httpx.HTTPTransport): - """Transport that rewrites requests to use a pinned IP address.""" - - def __init__( - self, pinned_ip: str, original_hostname: str, *args: Any, **kwargs: Any - ): - self._pinned_ip = pinned_ip - self._original_hostname = original_hostname - super().__init__(*args, **kwargs) - - def handle_request(self, request: httpx.Request) -> httpx.Response: - # Rewrite the request URL to use the pinned IP instead of hostname - original_url = str(request.url) - parsed = urlparse(original_url) - - # Set Host header to the original hostname (required for virtual hosting) - request.headers["Host"] = self._original_hostname - - # Prepare the pinned IP for use in URL netloc - # IPv6 addresses must be bracketed; also percent-encode zone IDs - pinned_ip_for_url = self._pinned_ip - if ":" in self._pinned_ip: - # This looks like an IPv6 address - # Percent-encode any zone identifier (% becomes %25) - pinned_ip_for_url = self._pinned_ip.replace("%", "%25") - # Wrap in brackets for URL - pinned_ip_for_url = f"[{pinned_ip_for_url}]" - - # Replace hostname with pinned IP in the netloc - if ":" in parsed.netloc and not parsed.netloc.startswith("["): - # Explicit port present: hostname:port -> ip:port - _, port = parsed.netloc.rsplit(":", 1) - new_netloc = f"{pinned_ip_for_url}:{port}" - else: - # No explicit port: hostname -> ip - new_netloc = pinned_ip_for_url - - # Reconstruct the URL with pinned IP - new_path = parsed.path or "/" - new_url = f"{parsed.scheme}://{new_netloc}{new_path}" - if parsed.query: - new_url += f"?{parsed.query}" - if parsed.fragment: - new_url += f"#{parsed.fragment}" - - request.url = httpx.URL(new_url) - - return super().handle_request(request) - - if scheme == "https": - # For HTTPS, create a custom network backend that sets correct SNI - ssl_context = ssl.create_default_context() - - # Create a custom NetworkBackend that wraps sockets with correct server_hostname - from httpcore._backends.sync import SyncBackend - - class SNINetworkBackend(SyncBackend): - """NetworkBackend that overrides SNI hostname for SSL connections.""" - - def __init__(self, pinned_ip: str, sni_hostname: str): - super().__init__() - self._pinned_ip = pinned_ip - self._sni_hostname = sni_hostname - - def start_tls( - self, - sock: socket.socket, - ssl_context: ssl.SSLContext, - server_hostname: Optional[str] = None, - timeout: Optional[float] = None, - ) -> ssl.SSLSocket: - # Override server_hostname to use the original hostname for SNI - # when we're connecting to our pinned IP - try: - peername = sock.getpeername() - if peername and peername[0] == self._pinned_ip: - server_hostname = self._sni_hostname - except Exception: - pass - - # Wrap the socket with SSL using the correct server_hostname - return super().start_tls(sock, ssl_context, server_hostname, timeout) - - network_backend = SNINetworkBackend(pinned_ip=pinned_ip, sni_hostname=hostname) - - transport = PinnedHTTPTransport( - pinned_ip=pinned_ip, - original_hostname=hostname, - verify=ssl_context, - network_backend=network_backend, - ) - - return transport - else: - # For HTTP, no SSL/SNI concerns - transport = PinnedHTTPTransport( - pinned_ip=pinned_ip, - original_hostname=hostname, - ) - - return transport - - -_LLM_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { - "openai_compatible": { - "base_url": "http://localhost:8080/v1", - "model": "llama-3.1-8b-instruct", - }, - "openai": { - "base_url": "https://api.openai.com/v1", - "model": "gpt-4o-mini", - }, - "anthropic": { - "base_url": "https://api.anthropic.com/v1", - "model": "claude-3-5-sonnet-latest", - }, - "openrouter": { - "base_url": "https://openrouter.ai/api/v1", - "model": "openai/gpt-4o-mini", - }, -} - - -def _default_config() -> dict[str, Any]: - llm_default = _LLM_PROVIDER_DEFAULTS["openai_compatible"] - return { - "posthog": { - "host": "https://us.i.posthog.com", - "project_id": "", - }, - "llm": { - "provider": "openai_compatible", - "base_url": llm_default["base_url"], - "model": llm_default["model"], - }, - "run": { - "lookback_hours": 168, - "max_sessions_per_run": 50, - "output_dir": "./reports", - "data_dir": "./data", - }, - "detectors": { - "console_error": True, - "network_5xx": True, - "network_4xx": True, - "rage_click": True, - "dead_click": True, - "error_toast": True, - "blank_render": True, - "session_abandon_on_error": True, - }, - "cluster": { - "min_size": 1, - }, - "tester": { - "app_url": DEFAULT_APP_URL, - "start_command": "", - "harness_command": DEFAULT_HARNESS_COMMAND, - "max_retries": 1, - "auth_required": False, - "auth_mode": "none", - "auth_login_url": "", - "auth_username": "", - "auth_password_env": "RETRACE_TESTER_AUTH_PASSWORD", - "auth_jwt_env": "RETRACE_TESTER_AUTH_JWT", - "auth_headers_env": "RETRACE_TESTER_AUTH_HEADERS", - }, - } - - -def _read_config(config_path: Path) -> dict[str, Any]: - cfg = _default_config() - if not config_path.exists(): - return cfg - raw = yaml.safe_load(config_path.read_text()) or {} - for k, v in raw.items(): - if isinstance(v, dict) and isinstance(cfg.get(k), dict): - cfg[k].update(v) - else: - cfg[k] = v - return cfg - - -def _write_config(config_path: Path, cfg: dict[str, Any]) -> None: - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) - - -def _read_env(env_path: Path) -> dict[str, str]: - out: dict[str, str] = {} - if not env_path.exists(): - return out - for line in env_path.read_text().splitlines(): - s = line.strip() - if not s or s.startswith("#") or "=" not in s: - continue - k, v = s.split("=", 1) - out[k.strip()] = v.strip() - return out - - -def _write_env(env_path: Path, vals: dict[str, str]) -> None: - env_path.parent.mkdir(parents=True, exist_ok=True) - lines = [f"{k}={v}" for k, v in vals.items()] - env_path.write_text("\n".join(lines) + "\n") - - -def _latest_report(report_dir: Path) -> Optional[Path]: - files = sorted( - report_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True - ) - return files[0] if files else None - - -def _session_id_from_url(url: str) -> str: - return url.rstrip("/").split("/")[-1] - - -def _gh_checks() -> dict[str, Any]: - sys_name = platform.system().lower() - if "darwin" in sys_name: - install_cmd = "brew install gh" - elif "linux" in sys_name: - install_cmd = "sudo apt install gh" - elif "windows" in sys_name: - install_cmd = "winget install --id GitHub.cli" - else: - install_cmd = "See https://cli.github.com/ for install instructions" - - gh_path = shutil.which("gh") - installed = gh_path is not None - authed = False - auth_detail = "" - if installed: - try: - proc = subprocess.run( - ["gh", "auth", "status"], - capture_output=True, - text=True, - timeout=6, - check=False, - ) - authed = proc.returncode == 0 - auth_detail = (proc.stdout or proc.stderr or "").strip()[:500] - except Exception as exc: - auth_detail = str(exc) - - return { - "installed": installed, - "authed": authed, - "gh_path": gh_path or "", - "auth_detail": auth_detail, - "commands": { - "install": install_cmd, - "login": "gh auth login", - "status": "gh auth status", - }, - } - - -def _posthog_check(host: str, project_id: str, api_key: str) -> dict[str, Any]: - configured = bool(host.strip() and project_id.strip() and api_key.strip()) - if not configured: - return { - "configured": False, - "reachable": None, - "detail": "Missing host/project/API key.", - } - - # Validate and pin the IP to prevent DNS rebinding - ok_url, safe_host, err, pinned_ips = _validate_base_url(host) - if not ok_url: - return {"configured": True, "reachable": False, "detail": err} - - url = f"{safe_host.rstrip('/')}/api/projects/{project_id}/" - parsed = urlparse(safe_host) - last_exc: Optional[Exception] = None - last_status: Optional[int] = None - try: - for pinned_ip in pinned_ips: - try: - # Use validated/pinned IP to prevent TOCTOU/DNS rebinding. - transport = _create_pinned_transport( - pinned_ip, parsed.hostname or "", parsed.scheme or "" - ) - with httpx.Client(timeout=8, transport=transport) as c: - r = c.get(url, headers={"Authorization": f"Bearer {api_key}"}) - last_status = r.status_code - if r.status_code // 100 == 2: - return { - "configured": True, - "reachable": True, - "detail": f"OK ({r.status_code})", - } - except Exception as exc: - last_exc = exc - continue - if last_status is not None: - return { - "configured": True, - "reachable": False, - "detail": f"HTTP {last_status}", - } - if last_exc: - raise last_exc - return {"configured": True, "reachable": False, "detail": "No pinned IPs available."} - except Exception as exc: - return {"configured": True, "reachable": False, "detail": str(exc)} - - -def _replay_api_check(base_url: str = "http://127.0.0.1:8788") -> dict[str, Any]: - url = base_url.rstrip("/") + "/healthz" - try: - with httpx.Client(timeout=2) as c: - response = c.get(url) - if response.status_code // 100 == 2: - return { - "configured": True, - "reachable": True, - "detail": f"OK ({response.status_code})", - "url": base_url.rstrip("/"), - "commands": {"serve": "retrace api serve"}, - } - return { - "configured": True, - "reachable": False, - "detail": f"HTTP {response.status_code}", - "url": base_url.rstrip("/"), - "commands": {"serve": "retrace api serve"}, - } - except Exception as exc: - return { - "configured": True, - "reachable": False, - "detail": str(exc), - "url": base_url.rstrip("/"), - "commands": {"serve": "retrace api serve"}, - } - - -def _truthy_env(name: str) -> bool: - return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes", "on"} - - -def _validate_base_url(base_url: str) -> tuple[bool, str, str, list[str]]: - """Validate outbound model-provider URLs to reduce SSRF risk. - - Returns: (ok, normalized_url, error_message, pinned_ips) - pinned_ips are acceptable IPs resolved during validation and must be used - for actual HTTP requests to prevent DNS rebinding attacks. - """ - raw = base_url.strip() - if not raw: - return False, "", "Base URL is required.", [] - - parsed = urlparse(raw) - scheme = (parsed.scheme or "").lower() - if scheme not in {"http", "https"}: - return False, "", "Base URL must use http or https.", [] - if not parsed.hostname: - return False, "", "Base URL must include a hostname.", [] - - if parsed.query or parsed.fragment: - return False, "", "Base URL must not include query parameters or fragments.", [] - - default_port = 443 if scheme == "https" else 80 - port = parsed.port or default_port - allow_internal = _truthy_env("RETRACE_ALLOW_INTERNAL_URLS") - try: - infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) - except socket.gaierror as exc: - return False, "", f"Base URL hostname resolution failed: {exc}", [] - - pinned_ips: list[str] = [] - if not allow_internal: - for _, _, _, _, sockaddr in infos: - if not sockaddr: - continue - try: - ip = ipaddress.ip_address(sockaddr[0]) - except ValueError: - continue - if ( - ip.is_private - or ip.is_loopback - or ip.is_link_local - or ip.is_multicast - or ip.is_reserved - or ip.is_unspecified - ): - continue - ip_s = sockaddr[0] - if ip_s not in pinned_ips: - pinned_ips.append(ip_s) - else: - # If internal URLs are allowed, keep all resolved IPs (deduped). - for _, _, _, _, sockaddr in infos: - if sockaddr: - ip_s = sockaddr[0] - if ip_s not in pinned_ips: - pinned_ips.append(ip_s) - - if not pinned_ips: - return ( - False, - "", - "No acceptable IP addresses found for base URL. " - "Set RETRACE_ALLOW_INTERNAL_URLS=true to allow internal hosts.", - [], - ) - - normalized = f"{scheme}://{parsed.netloc}{parsed.path or ''}".rstrip("/") - return True, normalized, "", pinned_ips - - -def _llm_check( - provider: str, base_url: str, model: str, api_key: str -) -> dict[str, Any]: - p = provider.strip().lower() - configured = bool(p and base_url.strip() and model.strip()) - if not configured: - return { - "configured": False, - "reachable": None, - "detail": "Missing provider/base URL/model.", - } - ok_url, safe_base_url, err, pinned_ips = _validate_base_url(base_url) - if not ok_url: - return {"configured": True, "reachable": False, "detail": err} - parsed = urlparse(safe_base_url) - last_exc: Optional[Exception] = None - last_status: Optional[int] = None - try: - url, headers, body = build_llm_http_request( - provider=p, - base_url=safe_base_url, - model=model, - api_key=api_key, - system="You are a test assistant.", - user="reply with ping", - temperature=0.0, - response_json=False, - max_tokens=8, - ) - for pinned_ip in pinned_ips: - try: - transport = _create_pinned_transport( - pinned_ip, parsed.hostname or "", parsed.scheme or "" - ) - with httpx.Client(timeout=12, transport=transport) as c: - r = c.post(url, headers=headers, json=body) - last_status = r.status_code - if r.status_code // 100 == 2: - return { - "configured": True, - "reachable": True, - "detail": f"OK ({r.status_code})", - } - except Exception as exc: - last_exc = exc - continue - if last_status is not None: - return { - "configured": True, - "reachable": False, - "detail": f"HTTP {last_status}", - } - if last_exc: - raise last_exc - return {"configured": True, "reachable": False, "detail": "No pinned IPs available."} - except Exception as exc: - return {"configured": True, "reachable": False, "detail": str(exc)} - - -def _llm_defaults(provider: str) -> dict[str, str]: - return _LLM_PROVIDER_DEFAULTS.get( - provider.strip().lower(), _LLM_PROVIDER_DEFAULTS["openai_compatible"] - ) - - -def _resolve_llm_api_key(provider: str, env_vars: dict[str, str]) -> str: - """Resolve the effective LLM API key using the same logic as load_config(). - - Args: - provider: The LLM provider name - env_vars: Dictionary of environment variables (from .env file or os.environ) - - Returns: - The effective API key (may be empty string) - """ - llm_key_env = env_vars.get("RETRACE_LLM_API_KEY", "").strip() - if not llm_key_env: - provider_env_map = { - "openai": "RETRACE_OPENAI_API_KEY", - "anthropic": "RETRACE_ANTHROPIC_API_KEY", - "openrouter": "RETRACE_OPENROUTER_API_KEY", - } - provider_env = provider_env_map.get(provider.strip().lower()) - if provider_env: - llm_key_env = env_vars.get(provider_env, "").strip() - return llm_key_env - - -def _llm_models(provider: str, base_url: str, api_key: str) -> dict[str, Any]: - p = provider.strip().lower() or "openai_compatible" - if p in _CLOUD_LLM_PROVIDERS and not api_key.strip(): - return {"ok": False, "error": "API key required for selected provider."} - ok_url, safe_base_url, err, pinned_ips = _validate_base_url(base_url) - if not ok_url: - return {"ok": False, "error": err} - parsed = urlparse(safe_base_url) - last_exc: Optional[Exception] = None - last_status: Optional[int] = None - try: - # We can't pass transport to fetch_llm_models without modifying it, - # so we inline the model fetching logic here with our secure transport - from retrace.llm.client import _build_headers, _extract_model_ids - - headers = _build_headers(provider=p, api_key=api_key.strip() or None) - url = f"{safe_base_url.rstrip('/')}/models" - for pinned_ip in pinned_ips: - try: - transport = _create_pinned_transport( - pinned_ip, parsed.hostname or "", parsed.scheme or "" - ) - with httpx.Client(timeout=10, transport=transport) as c: - resp = c.get(url, headers=headers) - last_status = resp.status_code - resp.raise_for_status() - payload = resp.json() - models = _extract_model_ids(payload) - return {"ok": True, "models": models} - except Exception as exc: - last_exc = exc - continue - if last_status is not None: - return {"ok": False, "error": f"HTTP {last_status}"} - if last_exc: - raise last_exc - return {"ok": False, "error": "No pinned IPs available."} - except Exception as exc: - return {"ok": False, "error": str(exc)} - - -def _to_findings_payload( - *, - store: Storage, - report_path: Optional[Path], - repo_full_name: Optional[str], -) -> list[dict[str, Any]]: - if report_path is None or not report_path.exists(): - return [] - - parsed = parse_report_findings(report_path) - rows = store.list_report_findings(str(report_path)) - by_hash = {r.finding_hash: r for r in rows} - repos = store.list_github_repos() - chosen_repo = None - if repo_full_name: - for r in repos: - if r.repo_full_name == repo_full_name: - chosen_repo = r - break - if chosen_repo is None and repos: - chosen_repo = repos[0] - - out: list[dict[str, Any]] = [] - for f in parsed: - h = f.finding_hash() - row = by_hash.get(h) - candidates: list[dict[str, Any]] = [] - prompts: dict[str, str] = {} - if row and chosen_repo: - for c in store.list_code_candidates( - finding_id=row.id, repo_id=chosen_repo.id - ): - rationale = "" - try: - rationale = (json.loads(str(c["rationale_json"])) or {}).get( - "rationale", "" - ) - except Exception: - rationale = str(c["rationale_json"]) - candidates.append( - { - "file_path": str(c["file_path"]), - "symbol": c["symbol"], - "score": float(c["score"]), - "rationale": rationale, - } - ) - for p in store.list_fix_prompts(finding_id=row.id, repo_id=chosen_repo.id): - prompts[p.agent_target] = p.prompt_markdown - - out.append( - { - "id": h, - "title": f.title, - "severity": f.severity, - "category": f.category, - "session_id": _session_id_from_url(f.session_url), - "session_url": f.session_url, - "evidence_text": f.evidence_text, - "distinct_id": row.distinct_id if row else "", - "error_issue_ids": row.error_issue_ids if row else [], - "trace_ids": row.trace_ids if row else [], - "top_stack_frame": row.top_stack_frame if row else "", - "error_tracking_url": row.error_tracking_url if row else "", - "logs_url": row.logs_url if row else "", - "first_error_ts_ms": row.first_error_ts_ms if row else 0, - "last_error_ts_ms": row.last_error_ts_ms if row else 0, - "regression_state": row.regression_state if row else "new", - "regression_occurrence_count": row.regression_occurrence_count - if row - else 1, - "candidates": candidates, - "prompts": prompts, - } - ) - return out - - -def _json_field(row: Any, key: str, fallback: Any) -> Any: - try: - return json.loads(str(row[key] or "")) - except Exception: - return fallback - - -def _failure_test_link_payload(link: Any) -> dict[str, Any]: - return { - "id": link.id, - "failure_id": link.failure_id, - "issue_id": link.issue_id, - "issue_public_id": link.issue_public_id, - "spec_id": link.spec_id, - "spec_name": link.spec_name, - "spec_path": link.spec_path, - "source": link.source, - "coverage_state": link.coverage_state, - "latest_run_id": link.latest_run_id, - "latest_run_status": link.latest_run_status, - "latest_run_classification": link.latest_run_classification, - "latest_run_ok": link.latest_run_ok, - "latest_run_at": ( - link.latest_run_at.isoformat() if link.latest_run_at is not None else "" - ), - "created_at": link.created_at.isoformat(), - "updated_at": link.updated_at.isoformat(), - } - - -def _repair_task_payload(task: Any | None) -> dict[str, Any] | None: - if task is None: - return None - return { - "id": task.id, - "public_id": task.public_id, - "failure_id": task.failure_id, - "source_type": task.source_type, - "source_external_id": task.source_external_id, - "title": task.title, - "status": task.status, - "likely_files": task.likely_files, - "prompt_artifacts": task.prompt_artifacts, - "validation_commands": task.validation_commands, - "branch": task.branch, - "pr_url": task.pr_url, - "risk_notes": task.risk_notes, - "metadata": task.metadata, - "evidence_ids": task.evidence_ids, - "created_at": task.created_at.isoformat(), - "updated_at": task.updated_at.isoformat(), - } - - -def _issue_workflow_payload(issue: dict[str, Any]) -> dict[str, Any]: - timeline_count = len(issue.get("timeline") or []) - reproduction_count = len(issue.get("reproduction_steps") or []) - test_links = issue.get("test_links") if isinstance(issue.get("test_links"), list) else [] - repair_task = issue.get("repair_task") if isinstance(issue.get("repair_task"), dict) else None - api_call_count = len(issue.get("api_calls") or []) - replay_count = len(issue.get("sessions") or []) - status = str(issue.get("status") or "") - coverage_states = [str(link.get("coverage_state") or "") for link in test_links] - latest_statuses = [str(link.get("latest_run_status") or "") for link in test_links] - api_links = [ - link - for link in test_links - if "api" in str(link.get("source") or "").lower() - or "/api-tests/" in str(link.get("spec_path") or "") - ] - if not test_links: - coverage_state = "not_covered" - elif "covered_failing" in coverage_states: - coverage_state = "covered_failing" - elif "covered_passing" in coverage_states: - coverage_state = "covered_passing" - elif "covered_flaky" in coverage_states: - coverage_state = "covered_flaky" - else: - coverage_state = coverage_states[0] or "covered_unverified" - - stages = { - "evidence": "complete" if timeline_count else "blocked", - "reproduction": "complete" if reproduction_count or replay_count else "blocked", - "test": "complete" if test_links else "current", - "repair": "complete" if repair_task else "current", - "verification": "complete" if coverage_state == "covered_passing" else "current", - } - if not test_links: - stages["repair"] = "blocked" - stages["verification"] = "blocked" - elif coverage_state == "covered_failing": - stages["verification"] = "blocked" - if status == "ignored": - primary_label = "Ignored fingerprint" - primary_action = "none" - elif not timeline_count: - primary_label = "Review raw evidence" - primary_action = "review_timeline" - elif not test_links: - primary_label = "Generate regression test" - primary_action = "generate_replay_spec" - elif coverage_state == "covered_failing" and not repair_task: - primary_label = "Generate repair task" - primary_action = "generate_repair" - elif coverage_state == "covered_failing": - primary_label = "Fix and rerun linked tests" - primary_action = "run_tests" - elif status == "resolved" and coverage_state != "covered_passing": - primary_label = "Verify resolved issue" - primary_action = "verify_resolved" - elif coverage_state == "covered_passing": - primary_label = "Covered by passing test" - primary_action = "none" - elif not repair_task and status not in {"resolved", "ignored"}: - primary_label = "Generate repair task" - primary_action = "generate_repair" - else: - primary_label = "Run linked tests" - primary_action = "run_tests" - - blockers: list[str] = [] - recommended_actions: list[dict[str, str]] = [] - capture_blocked = False - if not timeline_count: - capture_blocked = True - blockers.append("No normalized evidence timeline is available.") - if not reproduction_count and not replay_count: - capture_blocked = True - blockers.append("No replay or reproduction steps are linked.") - if not test_links: - blockers.append("No regression test covers this issue yet.") - recommended_actions.append( - { - "action": "generate_replay_spec", - "label": "Generate UI regression", - "reason": "Create an editable UI test from replay evidence.", - } - ) - if api_call_count and not api_links: - recommended_actions.append( - { - "action": "generate_api_regression", - "label": "Generate API regression", - "reason": "A failed network call is present without API coverage.", - } - ) - if test_links and coverage_state == "covered_failing" and not repair_task: - recommended_actions.append( - { - "action": "generate_repair", - "label": "Generate repair task", - "reason": "A linked regression still fails and needs repair context.", - } - ) - if status == "resolved" and coverage_state != "covered_passing": - recommended_actions.append( - { - "action": "verify_resolved", - "label": "Verify resolved issue", - "reason": "Resolved issues should pass linked UI/API regressions.", - } - ) - if test_links and not latest_statuses: - recommended_actions.append( - { - "action": "run_tests", - "label": "Run linked tests", - "reason": "Coverage exists but has no recorded run result.", - } - ) - readiness = "ready_for_repair" - if capture_blocked: - readiness = "needs_capture" - elif not test_links: - readiness = "needs_test" - elif coverage_state == "covered_passing": - readiness = "verified" - elif coverage_state == "covered_failing" and repair_task: - readiness = "repair_ready" - elif coverage_state == "covered_failing": - readiness = "needs_repair_task" - - return { - "coverage_state": coverage_state, - "latest_run_statuses": latest_statuses, - "primary_action": primary_action, - "primary_label": primary_label, - "readiness": readiness, - "blockers": blockers, - "recommended_actions": recommended_actions, - "stage_states": stages, - "counts": { - "timeline": timeline_count, - "reproduction_steps": reproduction_count, - "replays": replay_count, - "api_calls": api_call_count, - "tests": len(test_links), - "api_tests": len(api_links), - "repair_tasks": 1 if repair_task else 0, - }, - } - - -def _issue_evidence_stitching_payload(issue: dict[str, Any]) -> dict[str, Any]: - timeline = issue.get("timeline") if isinstance(issue.get("timeline"), list) else [] - api_calls = issue.get("api_calls") if isinstance(issue.get("api_calls"), list) else [] - test_links = issue.get("test_links") if isinstance(issue.get("test_links"), list) else [] - repair_task = issue.get("repair_task") if isinstance(issue.get("repair_task"), dict) else None - api_links = [ - link - for link in test_links - if "api" in str(link.get("source") or "").lower() - or "/api-tests/" in str(link.get("spec_path") or "") - ] - trace_ids: set[str] = set() - source_map_states: list[dict[str, Any]] = [] - for call in api_calls: - trace = call.get("trace") if isinstance(call, dict) else {} - if isinstance(trace, dict): - for value in trace.values(): - if isinstance(value, str) and value.strip(): - trace_ids.add(value.strip()) - elif isinstance(value, list): - trace_ids.update(str(item).strip() for item in value if str(item).strip()) - for event in timeline: - payload = event.get("payload") if isinstance(event, dict) else {} - if not isinstance(payload, dict): - continue - metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} - for key in ("trace_id", "trace_ids"): - value = payload.get(key, metadata.get(key)) - if isinstance(value, str) and value.strip(): - trace_ids.add(value.strip()) - elif isinstance(value, list): - trace_ids.update(str(item).strip() for item in value if str(item).strip()) - evidence_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {} - frames = evidence_payload.get("stack_frames") if isinstance(evidence_payload, dict) else [] - if not isinstance(frames, list): - frames = [] - for frame in frames: - if not isinstance(frame, dict): - continue - source_map_states.append( - { - "filename": str(frame.get("filename") or frame.get("source") or ""), - "source_mapped": bool(frame.get("source_mapped")), - "reason": str(frame.get("source_map_reason") or ""), - "status": str(frame.get("source_map_status") or ""), - } - ) - stages = [ - { - "id": "frontend_replay", - "label": "Frontend replay", - "status": "complete" if timeline else "missing", - "detail": f"{len(timeline)} timeline event(s), {len(issue.get('sessions') or [])} replay(s)", - }, - { - "id": "network_api", - "label": "Network/API evidence", - "status": "complete" if api_calls else "missing", - "detail": f"{len(api_calls)} failed API call(s), {len(api_links)} linked API regression(s)", - }, - { - "id": "backend_trace", - "label": "Backend trace/log bridge", - "status": "complete" if trace_ids else "missing", - "detail": f"{len(trace_ids)} trace id(s)", - }, - { - "id": "source_maps", - "label": "Source map context", - "status": "complete" - if any(item.get("source_mapped") for item in source_map_states) - else ("partial" if source_map_states else "missing"), - "detail": f"{len(source_map_states)} stack frame mapping result(s)", - }, - { - "id": "repair_context", - "label": "Repair context", - "status": "complete" if repair_task else "missing", - "detail": str((repair_task or {}).get("public_id") or (repair_task or {}).get("id") or ""), - }, - ] - return { - "status": "complete" - if all(stage["status"] == "complete" for stage in stages) - else "partial", - "stages": stages, - "trace_ids": sorted(trace_ids), - "api_regression_spec_ids": [str(link.get("spec_id") or "") for link in api_links], - "source_map_frames": source_map_states[:10], - } - - -def _replay_issue_payload( - row: Any, *, sessions: list[dict[str, Any]] -) -> dict[str, Any]: - canonical_failure_id = row["canonical_failure_id"] - evidence = _json_field(row, "evidence_json", {}) - return { - "id": str(row["id"]), - "public_id": str(row["public_id"]), - "project_id": str(row["project_id"]), - "environment_id": str(row["environment_id"]), - "status": str(row["status"]), - "priority": str(row["priority"]), - "severity": str(row["severity"]), - "confidence": str(row["confidence"]), - "title": str(row["title"]), - "summary": str(row["summary"]), - "likely_cause": str(row["likely_cause"]), - "reproduction_steps": _json_field(row, "reproduction_steps_json", []), - "signal_summary": _json_field(row, "signal_summary_json", {}), - "evidence": evidence, - "api_calls": _replay_api_calls(evidence), - "fingerprint": str(row["fingerprint"]), - "analysis_status": str(row["analysis_status"]), - "analysis_model": str(row["analysis_model"]), - "analysis_prompt_version": str(row["analysis_prompt_version"]), - "analysis_error": str(row["analysis_error"]), - "affected_count": int(row["affected_count"]), - "affected_users": int(row["affected_users"]), - "representative_session_id": str(row["representative_session_id"]), - "external_ticket_state": str(row["external_ticket_state"]), - "external_ticket_url": str(row["external_ticket_url"]), - "external_ticket_id": str(row["external_ticket_id"]), - "canonical_failure_id": ( - None if canonical_failure_id is None else str(canonical_failure_id) - ), - "first_seen_ms": int(row["first_seen_ms"]), - "last_seen_ms": int(row["last_seen_ms"]), - "updated_at": str(row["updated_at"]), - "sessions": sessions, - "timeline": _replay_evidence_timeline(evidence), - "test_links": [], - "repair_task": None, - "workflow": {}, - "share_url": f"#issue={str(row['public_id'])}", - } - - -def _replay_evidence_timeline(evidence: dict[str, Any]) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - for event in evidence.get("events") if isinstance(evidence.get("events"), list) else []: - if not isinstance(event, dict): - continue - data_type = event.get("data_type") - if event.get("type") == 4: - title = "Navigation" - summary = str(event.get("href") or "") - kind = "navigation" - elif event.get("type") == 3 and event.get("source") == 2 and data_type == 2: - title = "Click" - summary = f"Clicked element id {event.get('id', 'unknown')}" - kind = "interaction" - elif event.get("type") == 3 and event.get("source") == 5: - title = "Input" - summary = f"Entered text in element id {event.get('id', 'unknown')}" - kind = "interaction" - else: - continue - items.append( - { - "id": "", - "type": "replay_event", - "kind": kind, - "occurred_at_ms": int(event.get("timestamp_ms") or 0), - "source": "replay", - "title": title, - "summary": summary, - "detector": "", - "detector_hit": False, - "confidence": "", - "reason_codes": [], - "payload": event, - } - ) - for signal in evidence.get("signals") if isinstance(evidence.get("signals"), list) else []: - if not isinstance(signal, dict): - continue - details = signal.get("details") if isinstance(signal.get("details"), dict) else {} - detector = str(signal.get("detector") or "") - status = details.get("status") - request_url = details.get("request_url") or details.get("url") - if detector.startswith("network") and request_url: - title = f"Network {status or ''}".strip() - summary = f"{details.get('method') or 'GET'} {request_url} returned {status or 'unknown'}" - kind = "network" - elif detector == "console_error": - title = "Console error" - summary = str(details.get("message") or details.get("payload") or detector) - kind = "error" - else: - title = detector.replace("_", " ").title() or "Detector signal" - summary = json.dumps(details, sort_keys=True) if details else title - kind = "detector" - items.append( - { - "id": "", - "type": "replay_signal", - "kind": kind, - "occurred_at_ms": int(signal.get("timestamp_ms") or 0), - "source": "replay", - "title": title, - "summary": summary, - "detector": detector, - "detector_hit": True, - "confidence": str(signal.get("confidence") or ""), - "reason_codes": signal.get("reason_codes") if isinstance(signal.get("reason_codes"), list) else [], - "payload": signal, - } - ) - return sorted(items, key=lambda item: (int(item.get("occurred_at_ms") or 0), str(item.get("title") or ""))) - - -def _replay_api_calls(evidence: dict[str, Any]) -> list[dict[str, Any]]: - calls: list[dict[str, Any]] = [] - for signal in evidence.get("signals") if isinstance(evidence.get("signals"), list) else []: - if not isinstance(signal, dict): - continue - detector = str(signal.get("detector") or "") - if detector not in {"network_4xx", "network_5xx"}: - continue - details = signal.get("details") if isinstance(signal.get("details"), dict) else {} - raw_url = str(details.get("request_url") or details.get("url") or "") - calls.append( - { - "detector": detector, - "timestamp_ms": int(signal.get("timestamp_ms") or 0), - "method": str(details.get("method") or details.get("request_method") or "GET").upper(), - "url": _redacted_url(raw_url), - "status": details.get("status") or details.get("status_code") or "", - "confidence": str(signal.get("confidence") or ""), - "reason_codes": signal.get("reason_codes") if isinstance(signal.get("reason_codes"), list) else [], - "trace": details.get("trace") if isinstance(details.get("trace"), dict) else {}, - } - ) - return calls - - -def _to_replay_dashboard_payload(store: Storage) -> dict[str, Any]: - issues = [] - issue_rows = store.list_recent_replay_issues(limit=50) - issue_sessions_by_id: dict[str, list[dict[str, Any]]] = {} - for session in store.list_replay_issue_sessions_for_issues( - [str(row["id"]) for row in issue_rows] - ): - issue_sessions_by_id.setdefault(str(session["issue_id"]), []).append( - { - "session_id": str(session["session_id"]), - "stable_id": str(session["replay_stable_id"] or session["session_id"]), - "public_id": str(session["replay_public_id"] or ""), - "role": str(session["role"]), - "first_seen_ms": int(session["first_seen_ms"]), - "last_seen_ms": int(session["last_seen_ms"]), - } - ) - for row in issue_rows: - payload = _replay_issue_payload( - row, - sessions=issue_sessions_by_id.get(str(row["id"]), []), - ) - failure_id = str(row["canonical_failure_id"] or "") - if failure_id: - payload["timeline"] = build_evidence_timeline( - store.list_failure_evidence(failure_id=failure_id) - ) - payload["test_links"] = [ - _failure_test_link_payload(link) - for link in store.list_failure_test_links(failure_id=failure_id) - ] - repair_tasks = store.list_repair_tasks(failure_id=failure_id, limit=1) - payload["repair_task"] = _repair_task_payload( - repair_tasks[0] if repair_tasks else None - ) - payload["workflow"] = _issue_workflow_payload(payload) - payload["evidence_stitching"] = _issue_evidence_stitching_payload(payload) - issues.append(payload) - sessions = [] - for row in store.list_recent_replay_sessions(limit=50): - sessions.append( - { - "id": str(row["id"]), - "project_id": str(row["project_id"]), - "environment_id": str(row["environment_id"]), - "stable_id": str(row["stable_id"]), - "public_id": str(row["public_id"]), - "distinct_id": str(row["distinct_id"]), - "status": str(row["status"]), - "event_count": int(row["event_count"]), - "metadata": _json_field(row, "metadata_json", {}), - "preview": _json_field(row, "preview_json", {}), - "last_seen_at": str(row["last_seen_at"]), - "share_url": f"#replay={str(row['public_id'])}", - } - ) - return {"issues": issues, "sessions": sessions} - - -def _generate_replay_issue_spec_payload( - *, - store: Storage, - data_dir: Path, - issue_id: str, - project_id: str, - environment_id: str, - app_url: str = "", -) -> tuple[dict[str, Any], int]: - try: - generated = generate_spec_from_replay_issue( - store=store, - specs_dir=specs_dir_for_data_dir(data_dir), - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - app_url=app_url, - ) - except ValueError as exc: - message = str(exc) - status = 404 if "not found" in message.lower() else 400 - return {"ok": False, "error": message}, status - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - return ( - { - "ok": True, - "spec": generated.spec.__dict__, - "issue_public_id": generated.issue_public_id, - "replay_public_id": generated.replay_public_id, - "confidence": generated.confidence, - "known_gaps": generated.known_gaps, - }, - 200, - ) - - -def _issue_has_replay_regression_link(store: Storage, issue: Any) -> bool: - failure_id = str(issue["canonical_failure_id"] or "") - if not failure_id: - return False - return any( - link.source == "replay_issue" - for link in store.list_failure_test_links(failure_id=failure_id) - ) - - -def _generate_replay_issue_specs_payload( - *, - store: Storage, - data_dir: Path, - project_id: str, - environment_id: str, - issue_ids: list[str] | None = None, - status: str = "", - app_url: str = "", - limit: int = 25, - missing_only: bool = True, -) -> tuple[dict[str, Any], int]: - try: - limit_v = max(1, min(int(limit), 100)) - except (TypeError, ValueError): - limit_v = 25 - clean_issue_ids = [ - str(item).strip() - for item in (issue_ids or []) - if str(item or "").strip() - ][:limit_v] - selected: list[Any] = [] - seen: set[str] = set() - if clean_issue_ids: - for issue_id in clean_issue_ids: - row = store.get_replay_issue( - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - ) - if row is None: - continue - public_id = str(row["public_id"]) - if public_id in seen: - continue - seen.add(public_id) - selected.append(row) - else: - selected = store.list_replay_issues( - project_id=project_id, - environment_id=environment_id, - status=status or None, - )[:limit_v] - - results: list[dict[str, Any]] = [] - skipped: list[dict[str, str]] = [] - failed: list[dict[str, str]] = [] - for issue in selected: - public_id = str(issue["public_id"]) - issue_status = str(issue["status"] or "") - if issue_status == "ignored": - skipped.append({"issue_public_id": public_id, "reason": "ignored"}) - continue - if missing_only and _issue_has_replay_regression_link(store, issue): - skipped.append( - {"issue_public_id": public_id, "reason": "already_covered"} - ) - continue - try: - generated = generate_spec_from_replay_issue( - store=store, - specs_dir=specs_dir_for_data_dir(data_dir), - project_id=project_id, - environment_id=environment_id, - issue_id=public_id, - app_url=app_url, - ) - except Exception as exc: - failed.append({"issue_public_id": public_id, "error": str(exc)}) - continue - results.append( - { - "issue_public_id": generated.issue_public_id, - "replay_public_id": generated.replay_public_id, - "spec_id": generated.spec.spec_id, - "spec_name": generated.spec.name, - "confidence": generated.confidence, - "known_gaps": generated.known_gaps, - } - ) - ok = not failed - return ( - { - "ok": ok, - "requested": len(clean_issue_ids) if clean_issue_ids else len(selected), - "considered": len(selected), - "generated": len(results), - "skipped": skipped, - "failed": failed, - "results": results, - }, - 200 if ok else 207, - ) - - -def _generate_replay_issue_api_spec_payload( - *, - store: Storage, - data_dir: Path, - issue_id: str, - project_id: str, - environment_id: str, - app_url: str = "", -) -> tuple[dict[str, Any], int]: - try: - generated = generate_api_spec_from_replay_issue( - store=store, - specs_dir=api_specs_dir_for_data_dir(data_dir), - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - app_url=app_url, - ) - except ValueError as exc: - message = str(exc) - status = 404 if "not found" in message.lower() else 400 - return {"ok": False, "error": message}, status - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - return ( - { - "ok": True, - "spec": generated.spec.__dict__, - "issue_public_id": generated.issue_public_id, - "replay_public_id": generated.replay_public_id, - "source_signal": generated.source_signal, - }, - 200, - ) - - -def _run_replay_issue_api_spec_payload( - *, - store: Storage, - data_dir: Path, - spec_id: str, -) -> tuple[dict[str, Any], int]: - try: - spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_id) - result = run_api_spec( - spec=spec, - runs_dir=api_runs_dir_for_data_dir(data_dir), - ) - links = store.list_failure_test_links(spec_id=result.spec_id, limit=10) - if not links: - failure_id = str(spec.fixtures.get("canonical_failure_id") or "") - issue_id = str(spec.fixtures.get("issue_id") or "") - issue_public_id = str(spec.fixtures.get("issue_public_id") or "") - if failure_id: - link_id = store.upsert_failure_test_link( - failure_id=failure_id, - issue_id=issue_id, - issue_public_id=issue_public_id, - spec_id=spec.spec_id, - spec_name=spec.name, - spec_path=str(api_specs_dir_for_data_dir(data_dir) / f"{spec.spec_id}.json"), - source=str(spec.fixtures.get("source") or "replay_issue_api"), - ) - links = store.list_failure_test_links( - spec_id=result.spec_id, - limit=10, - ) - links = [link for link in links if link.id == link_id] or links - updated = [] - for link in links: - updated.extend( - store.update_failure_test_link_run( - spec_id=result.spec_id, - run_result=result, - link_id=link.id, - ) - ) - except FileNotFoundError: - return {"ok": False, "error": f"API spec not found: {spec_id}"}, 404 - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - return ( - { - "ok": result.ok, - "result": result.__dict__, - "updated_links": [_failure_test_link_payload(link) for link in updated], - }, - 200 if result.ok else 400, - ) - - -def _select_repo( - *, store: Storage, repo_full_name: str = "" -) -> tuple[GitHubRepoRow | None, str]: - repos = store.list_github_repos() - requested = repo_full_name.strip() - if requested: - repo = store.get_github_repo(requested) - if repo is None: - return None, ( - f"Repo not connected: {requested}. " - "Run `retrace github connect --repo org/name` first." - ) - return repo, "" - if not repos: - return None, "No connected repo. Run `retrace github connect --repo org/name` first." - return repos[0], "" - - -def _generate_replay_issue_fix_prompts_payload( - *, - store: Storage, - output_dir: Path, - issue_id: str, - project_id: str, - environment_id: str, - repo_full_name: str = "", -) -> tuple[dict[str, Any], int]: - repo, error = _select_repo(store=store, repo_full_name=repo_full_name) - if repo is None: - return {"ok": False, "error": error}, 400 - - try: - issue = store.get_replay_issue( - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - ) - if issue is None: - return {"ok": False, "error": f"Replay issue not found: {issue_id}"}, 404 - if str(issue["status"] or "") == "ignored": - return {"ok": False, "error": f"Replay issue is ignored: {issue_id}"}, 409 - finding = parsed_finding_from_replay_issue(issue) - repo_path = Path(repo.local_path) if repo.local_path else None - result = generate_fix_suggestions( - store=store, - repo=repo, - repo_path=repo_path, - out_dir=output_dir / "fix-prompts", - report_key=replay_issue_report_key(str(issue["public_id"])), - source_label=f"replay issue {issue['public_id']}", - artifact_stem=f"replay-{slugify(str(issue['public_id']))}", - findings=[finding], - project_id=project_id, - environment_id=environment_id, - ) - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - - artifact = result.artifacts[0] if result.artifacts else None - return ( - { - "ok": True, - "issue_public_id": str(issue["public_id"]), - "repo": result.repo_full_name, - "repo_path": result.repo_path, - "out_dir": str(result.out_dir), - "stored": result.stored, - "generated": result.generated, - "regression_counts": result.regression_counts, - "finding_hash": artifact.finding_hash if artifact else "", - "candidates": [ - { - "file_path": c.file_path, - "symbol": c.symbol, - "score": c.score, - "rationale": c.rationale, - } - for c in (artifact.candidates if artifact else []) - ], - "prompts": artifact.prompts if artifact else {}, - "prompt_files": artifact.prompt_files if artifact else {}, - "artifact_json": artifact.artifact_json if artifact else "", - "artifact_manifest_json": artifact.artifact_manifest_json if artifact else "", - "repair_task_id": artifact.repair_task_id if artifact else "", - }, - 200, - ) - - -def _transition_replay_issue_payload( - *, - store: Storage, - issue_id: str, - project_id: str, - environment_id: str, - status: str, -) -> tuple[dict[str, Any], int]: - if status not in {"resolved", "unresolved", "ignored"}: - return {"ok": False, "error": "status must be resolved, unresolved, or ignored"}, 400 - issue = store.get_replay_issue( - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - ) - if issue is None: - return {"ok": False, "error": f"Replay issue not found: {issue_id}"}, 404 - try: - updated = store.transition_replay_issue(str(issue["id"]), status=status) - except ValueError as exc: - return {"ok": False, "error": str(exc)}, 400 - refreshed = store.get_replay_issue( - project_id=project_id, - environment_id=environment_id, - issue_id=issue_id, - ) - return ( - { - "ok": True, - "updated": updated, - "issue": _replay_issue_payload( - refreshed if refreshed is not None else issue, - sessions=[], - ), - }, - 200, - ) - - -def _verify_resolved_issues_payload( - *, - store: Storage, - data_dir: Path, - cwd: Path, - project_id: str, - environment_id: str, - limit: int = 10, - dry_run: bool = False, -) -> tuple[dict[str, Any], int]: - try: - limit_v = max(1, min(int(limit), 100)) - except (TypeError, ValueError): - limit_v = 10 - specs_by_id: dict[str, Any] = {} - specs_by_issue: dict[str, Any] = {} - for spec in list_specs(specs_dir_for_data_dir(data_dir)): - specs_by_id[spec.spec_id] = spec - public_id = str(spec.fixtures.get("issue_public_id") or "").strip() - if not public_id: - continue - existing = specs_by_issue.get(public_id) - if existing is None or spec.updated_at > existing.updated_at: - specs_by_issue[public_id] = spec - api_specs_by_id: dict[str, Any] = {} - api_specs_by_issue: dict[str, Any] = {} - for spec_path in api_specs_dir_for_data_dir(data_dir).glob("*.json"): - try: - spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_path.stem) - except Exception: - continue - api_specs_by_id[spec.spec_id] = spec - public_id = str(spec.fixtures.get("issue_public_id") or "").strip() - if not public_id: - continue - existing = api_specs_by_issue.get(public_id) - if existing is None or spec.updated_at > existing.updated_at: - api_specs_by_issue[public_id] = spec - - resolved = store.list_replay_issues( - project_id=project_id, - environment_id=environment_id, - status="resolved", - ) - plan: list[dict[str, Any]] = [] - for row in resolved[:limit_v]: - public_id = str(row["public_id"]) - failure_id = str(row["canonical_failure_id"] or "") - planned_tests: list[dict[str, str]] = [] - if failure_id: - for link in store.list_failure_test_links(failure_id=failure_id): - linked_spec = specs_by_id.get(link.spec_id) - if linked_spec is not None: - planned_tests.append( - { - "kind": "ui", - "spec_id": linked_spec.spec_id, - "coverage_link_id": link.id, - } - ) - continue - linked_api_spec = api_specs_by_id.get(link.spec_id) - if linked_api_spec is not None: - planned_tests.append( - { - "kind": "api", - "spec_id": linked_api_spec.spec_id, - "coverage_link_id": link.id, - } - ) - if not planned_tests: - spec = specs_by_issue.get(public_id) - if spec is not None: - planned_tests.append( - {"kind": "ui", "spec_id": spec.spec_id, "coverage_link_id": ""} - ) - if not planned_tests: - api_spec = api_specs_by_issue.get(public_id) - if api_spec is not None: - planned_tests.append( - {"kind": "api", "spec_id": api_spec.spec_id, "coverage_link_id": ""} - ) - plan.append( - { - "public_id": public_id, - "issue_id": str(row["id"]), - "failure_id": failure_id, - "title": str(row["title"] or "Replay issue"), - "spec_id": planned_tests[0]["spec_id"] if planned_tests else "", - "spec_kind": planned_tests[0]["kind"] if planned_tests else "", - "coverage_link_id": ( - planned_tests[0]["coverage_link_id"] if planned_tests else "" - ), - "tests": planned_tests, - "has_spec": bool(planned_tests), - } - ) - - if dry_run: - return {"ok": True, "plan": plan, "verified": [], "regressed": []}, 200 - - verified: list[str] = [] - regressed: list[dict[str, Any]] = [] - for entry in plan: - tests = entry.get("tests") if isinstance(entry.get("tests"), list) else [] - if not tests: - continue - failures: list[dict[str, str]] = [] - for test in tests: - spec_id = str(test.get("spec_id") or "") - kind = str(test.get("kind") or "ui") - coverage_link_id = str(test.get("coverage_link_id") or "") - try: - if kind == "api": - api_spec = api_specs_by_id.get(spec_id) - if api_spec is None: - raise FileNotFoundError(f"API spec not found: {spec_id}") - result = run_api_spec( - spec=api_spec, - runs_dir=api_runs_dir_for_data_dir(data_dir), - ) - else: - spec = specs_by_id.get(spec_id) or specs_by_issue[entry["public_id"]] - result = run_spec( - spec=spec, - runs_dir=runs_dir_for_data_dir(data_dir), - cwd=cwd, - ) - except Exception as exc: - failures.append( - {"spec_id": spec_id, "kind": kind, "error": f"run raised: {exc}"} - ) - continue - if coverage_link_id: - try: - store.update_failure_test_link_run( - spec_id=result.spec_id, - run_result=result, - link_id=coverage_link_id, - ) - except Exception: - logger.warning( - "failed to persist failure_test_link run metadata", - extra={"spec_id": result.spec_id, "run_id": result.run_id}, - exc_info=True, - ) - if not result.ok: - failures.append( - { - "spec_id": result.spec_id, - "kind": kind, - "run_id": result.run_id, - "exit_code": str(getattr(result, "exit_code", "")), - "error": result.error, - } - ) - if not failures: - store.transition_replay_issue(entry["issue_id"], status="verified") - verified.append(entry["public_id"]) - continue - store.transition_replay_issue(entry["issue_id"], status="regressed") - regressed.append( - { - "public_id": entry["public_id"], - "issue_id": entry["issue_id"], - "spec_id": failures[0].get("spec_id", ""), - "run_id": failures[0].get("run_id", ""), - "error": failures[0].get("error", ""), - "failures": failures, - } - ) - return {"ok": True, "plan": plan, "verified": verified, "regressed": regressed}, 200 - - -def _github_repos_payload(store: Storage) -> dict[str, Any]: - return { - "repos": [ - { - "repo_full_name": repo.repo_full_name, - "default_branch": repo.default_branch, - "remote_url": repo.remote_url, - "local_path": repo.local_path, - "provider": repo.provider, - "connected_at": repo.connected_at.isoformat(), - } - for repo in store.list_github_repos() - ] - } - - -def _api_suites_payload(data_dir: Path) -> dict[str, Any]: - suites = [] - for suite in list_api_suites(api_suites_dir_for_data_dir(data_dir)): - warnings = suite.import_summary.get("quality_warnings") - if not isinstance(warnings, dict): - warnings = {} - warning_count = sum( - len(value) for value in warnings.values() if isinstance(value, list) - ) - suites.append( - { - "suite_id": suite.suite_id, - "name": suite.name, - "source": suite.source, - "spec_count": len(suite.spec_ids), - "spec_ids": suite.spec_ids, - "auth_profile": suite.auth_profile, - "env_profile": suite.env_profile, - "filters": suite.filters, - "import_summary": suite.import_summary, - "operation_count": len(suite.operations), - "operations": suite.operations[:25], - "skipped_count": len(suite.skipped), - "skipped": suite.skipped[:25], - "quality_warning_count": warning_count, - "metadata": suite.metadata, - "created_at": suite.created_at, - "updated_at": suite.updated_at, - } - ) - return {"suites": suites} - - -def _api_specs_payload(data_dir: Path) -> dict[str, Any]: - specs = [] - for spec in list_api_specs(api_specs_dir_for_data_dir(data_dir)): - fixtures = dict(spec.fixtures or {}) - specs.append( - { - "spec_id": spec.spec_id, - "name": spec.name, - "method": spec.method, - "url": spec.url, - "auth_profile": spec.auth_profile, - "env_profile": spec.env_profile, - "expected_status": spec.expected_status, - "request_count": len(spec.steps) if spec.steps else 1, - "json_assertion_count": len(spec.json_assertions), - "schema_assertion_count": len(spec.schema_assertions), - "source": str(fixtures.get("source") or ""), - "issue_public_id": str(fixtures.get("issue_public_id") or ""), - "operation_id": str(fixtures.get("operation_id") or ""), - "openapi_path": str(fixtures.get("openapi_path") or ""), - "created_at": spec.created_at, - "updated_at": spec.updated_at, - } - ) - return {"specs": specs} - - -def _hosted_onboarding_readiness_payload( - *, - store: Storage, - data_dir: Path, - settings: dict[str, Any], - checks: dict[str, Any], -) -> dict[str, Any]: - sdk_keys = store.list_sdk_keys(include_revoked=False, limit=10) - replay_sessions = store.list_recent_replay_sessions(limit=10) - replay_issues = store.list_recent_replay_issues(limit=10) - fallback_workspace = store.ensure_workspace(project_name="Default") - if replay_issues: - project_id = str(replay_issues[0]["project_id"]) - environment_id = str(replay_issues[0]["environment_id"]) - elif sdk_keys: - project_id = sdk_keys[0].project_id - environment_id = sdk_keys[0].environment_id - else: - project_id = fallback_workspace.project_id - environment_id = fallback_workspace.environment_id - ui_specs = list_specs(specs_dir_for_data_dir(data_dir)) - api_specs = list_api_specs(api_specs_dir_for_data_dir(data_dir)) - api_suites = list_api_suites(api_suites_dir_for_data_dir(data_dir)) - source_maps = store.list_recent_source_maps( - project_id=project_id, - environment_id=environment_id, - limit=10, - ) - alert_rules = store.list_app_error_alert_rules( - project_id=project_id, - environment_id=environment_id, - limit=10, - ) - test_links = store.list_all_failure_test_links() - repair_tasks = store.list_repair_tasks(limit=10) - steps = [ - { - "id": "settings", - "label": "Configure hosted settings", - "status": "complete" - if settings.get("tester_app_url") and checks.get("replay_api", {}).get("reachable") is True - else "current", - "detail": f"Replay API: {checks.get('replay_api', {}).get('detail') or 'not checked'}", - "action": "Save settings and run retrace api serve", - }, - { - "id": "capture_key", - "label": "Create browser capture key", - "status": "complete" if sdk_keys else "current", - "detail": f"{len(sdk_keys)} active browser SDK key(s)", - "action": "Create SDK Key", - }, - { - "id": "capture_smoke", - "label": "Verify replay capture", - "status": "complete" if replay_sessions or replay_issues else "blocked", - "detail": f"{len(replay_sessions)} recent first-party replay session(s)", - "action": "Send a smoke replay from the instrumented app", - }, - { - "id": "issue_grouping", - "label": "Process captured errors into issues", - "status": "complete" if replay_issues else "blocked", - "detail": f"{len(replay_issues)} recent replay issue(s)", - "action": "Process Queued Replays", - }, - { - "id": "ui_tests", - "label": "Generate and review UI regressions", - "status": "complete" if ui_specs and test_links else "current", - "detail": f"{len(ui_specs)} UI spec(s), {sum(1 for spec in ui_specs if dict(spec.fixtures or {}).get('draft_status') == 'draft')} draft(s)", - "action": "Generate regression tests from issues", - }, - { - "id": "api_tests", - "label": "Import or generate API coverage", - "status": "complete" if api_suites or api_specs else "current", - "detail": f"{len(api_suites)} API suite(s), {len(api_specs)} API spec(s)", - "action": "Import OpenAPI or generate API regression", - }, - { - "id": "monitoring", - "label": "Harden monitoring", - "status": "complete" if source_maps and alert_rules else "current", - "detail": f"{len(source_maps)} source map upload(s), {len(alert_rules)} alert rule(s)", - "action": "Upload source maps and create alert rules", - }, - { - "id": "repair_loop", - "label": "Create repair-ready context", - "status": "complete" if repair_tasks else "blocked", - "detail": f"{len(repair_tasks)} repair task(s)", - "action": "Generate fix prompts from a failing issue", - }, - ] - complete = sum(1 for step in steps if step["status"] == "complete") - return { - "workspace": { - "project_id": project_id, - "environment_id": environment_id, - }, - "ready": complete == len(steps), - "complete": complete, - "total": len(steps), - "steps": steps, - "counts": { - "sdk_keys": len(sdk_keys), - "replay_sessions": len(replay_sessions), - "replay_issues": len(replay_issues), - "ui_specs": len(ui_specs), - "api_specs": len(api_specs), - "api_suites": len(api_suites), - "source_maps": len(source_maps), - "alert_rules": len(alert_rules), - "test_links": len(test_links), - "repair_tasks": len(repair_tasks), - }, - } - - -def _run_api_spec_payload(*, data_dir: Path, spec_id: str) -> tuple[dict[str, Any], int]: - clean_spec_id = spec_id.strip() - if not clean_spec_id: - return {"ok": False, "error": "spec_id is required"}, 400 - try: - spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), clean_spec_id) - except Exception: - return {"ok": False, "error": f"API spec not found: {clean_spec_id}"}, 404 - result = run_api_spec( - spec=spec, - runs_dir=api_runs_dir_for_data_dir(data_dir), - ) - return {"ok": result.ok, "result": result.__dict__}, 200 if result.ok else 400 - - -def _run_api_suite_payload(*, data_dir: Path, suite_id: str) -> tuple[dict[str, Any], int]: - clean_suite_id = suite_id.strip() - if not clean_suite_id: - return {"ok": False, "error": "suite_id is required"}, 400 - try: - suite = load_api_suite(api_suites_dir_for_data_dir(data_dir), clean_suite_id) - except Exception: - return {"ok": False, "error": f"API suite not found: {clean_suite_id}"}, 404 - results: list[dict[str, Any]] = [] - for spec_id in suite.spec_ids: - try: - spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_id) - result = run_api_spec( - spec=spec, - runs_dir=api_runs_dir_for_data_dir(data_dir), - ) - results.append( - { - "spec_id": spec.spec_id, - "name": spec.name, - "method": spec.method, - "url": spec.url, - "ok": result.ok, - "status": result.status, - "status_code": result.status_code, - "elapsed_ms": result.elapsed_ms, - "run_id": result.run_id, - "failure_classification": result.failure_classification, - "error": result.error, - } - ) - except Exception as exc: - results.append( - { - "spec_id": str(spec_id), - "name": "", - "method": "", - "url": "", - "ok": False, - "status": "failed", - "status_code": 0, - "elapsed_ms": 0, - "run_id": "", - "failure_classification": "suite_error", - "error": str(exc), - } - ) - passed = sum(1 for item in results if bool(item.get("ok"))) - failed = len(results) - passed - return { - "ok": failed == 0, - "suite_id": suite.suite_id, - "name": suite.name, - "total": len(results), - "passed": passed, - "failed": failed, - "results": results, - }, 200 if failed == 0 else 400 - - -def _json_object_list_payload(value: Any, *, label: str) -> list[dict[str, Any]]: - if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): - raise ValueError(f"{label} must be a JSON list of objects") - return [dict(item) for item in value] - - -def _edit_ui_draft_payload( - *, - data_dir: Path, - spec_id: str, - name: str = "", - prompt: str = "", - app_url: str = "", - steps: Any = None, - assertions: Any = None, - review_note: str = "", - accept: bool = False, -) -> tuple[dict[str, Any], int]: - clean_spec_id = spec_id.strip() - if not clean_spec_id: - return {"ok": False, "error": "spec_id is required"}, 400 - specs_dir = specs_dir_for_data_dir(data_dir) - try: - spec = load_spec(specs_dir, clean_spec_id) - except Exception: - return {"ok": False, "error": f"spec not found: {clean_spec_id}"}, 404 - if dict(spec.fixtures or {}).get("draft_status") != "draft": - return {"ok": False, "error": "Spec is not an unaccepted draft."}, 409 - - changed_fields: list[str] = [] - edited_name = name.strip() - if edited_name and edited_name != spec.name: - spec.name = edited_name - changed_fields.append("name") - edited_prompt = prompt.strip() - if edited_prompt and edited_prompt != spec.prompt: - spec.prompt = edited_prompt - changed_fields.append("prompt") - edited_app_url = app_url.strip() - if edited_app_url and edited_app_url != spec.app_url: - spec.app_url = edited_app_url - changed_fields.append("app_url") - try: - if steps is not None: - spec.exact_steps = _json_object_list_payload(steps, label="steps") - changed_fields.append("exact_steps") - if assertions is not None: - spec.assertions = _json_object_list_payload(assertions, label="assertions") - changed_fields.append("assertions") - except ValueError as exc: - return {"ok": False, "error": str(exc)}, 400 - - spec.fixtures = dict(spec.fixtures or {}) - notes = [ - str(item).strip() - for item in list(spec.fixtures.get("review_notes", []) or []) - if str(item).strip() - ] - clean_note = review_note.strip() - if clean_note: - notes.append(clean_note) - spec.fixtures["review_notes"] = notes - changed_fields.append("review_notes") - spec.fixtures["reviewed_at"] = now_iso() - if accept: - spec.fixtures["draft_status"] = "accepted" - spec.fixtures.setdefault("accepted_at", now_iso()) - changed_fields.append("draft_status") - if changed_fields: - spec.fixtures["last_review_edit"] = { - "edited_at": now_iso(), - "fields": sorted(set(changed_fields)), - } - spec.updated_at = now_iso() - try: - validate_spec(spec) - save_spec(specs_dir, spec) - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - return { - "ok": True, - "spec": spec.__dict__, - "draft_status": spec.fixtures.get("draft_status", ""), - "accepted": bool(accept), - "changed_fields": sorted(set(changed_fields)), - "step_count": len(spec.exact_steps or []), - "assertion_count": len(spec.assertions or []), - "review_notes": spec.fixtures.get("review_notes", []), - }, 200 - - -def _connect_github_repo_payload( - *, - store: Storage, - repo_full_name: str, - default_branch: str = "main", - local_path: str = "", -) -> tuple[dict[str, Any], int]: - repo = repo_full_name.strip() - branch = default_branch.strip() or "main" - path_value = local_path.strip() - provider = "github" - remote_url = "" - if path_value: - repo_path = Path(path_value).expanduser() - if not repo_path.exists() or not repo_path.is_dir(): - return { - "ok": False, - "error": f"Local path is not a directory: {path_value}", - }, 400 - path_value = str(repo_path) - if not repo: - repo = f"local/{slugify(repo_path.name or 'codebase')}" - provider = "local" - if not repo: - return { - "ok": False, - "error": "Enter an owner/name repo or a local checkout path.", - }, 400 - parts = repo.split("/") - if len(parts) != 2 or not parts[0] or not parts[1]: - return {"ok": False, "error": "Repo must use owner/name format."}, 400 - if provider == "github": - remote_url = f"https://github.com/{repo}.git" - store.upsert_github_repo( - repo_full_name=repo, - default_branch=branch, - remote_url=remote_url, - local_path=path_value, - provider=provider, - ) - return {"ok": True, **_github_repos_payload(store)}, 200 - - -def _create_sdk_key_payload( - *, - store: Storage, - project_name: str = "Default", - environment_name: str = "production", - name: str = "Browser SDK", -) -> tuple[dict[str, Any], int]: - project = project_name.strip() or "Default" - environment = environment_name.strip() or "production" - key_name = name.strip() or "Browser SDK" - try: - workspace = store.ensure_workspace( - project_name=project, - environment_name=environment, - ) - created = create_sdk_key( - store, - project_id=workspace.project_id, - environment_id=workspace.environment_id, - name=key_name, - ) - except Exception as exc: - return {"ok": False, "error": str(exc)}, 400 - return ( - { - "ok": True, - "id": created.id, - "project_id": workspace.project_id, - "environment_id": workspace.environment_id, - "project": project, - "environment": environment, - "name": key_name, - "key": created.key, - "prefix": created.prefix, - "last4": created.last4, - "ingest_path": "/api/sdk/replay", - "ingest_url": "http://127.0.0.1:8788/api/sdk/replay", - "sentry_dsn": build_sentry_dsn( - public_key=created.key, - base_url="http://127.0.0.1:8788", - project_id=workspace.project_id, - ), - }, - 200, - ) - - -_INDEX_HTML = """ - - - - - Retrace UI - - - - -
- - -
-
-
-
-

Issue Detail

Replay-backed failures are the primary workflow surface.
-
- - - -
-
- -
-
-
Select a replay-backed issue.
-
-
-

Replays

Recent captured sessions and playback.
-
-
-
Select a first-party replay session.
-
-
-
-

QA Incidents

Unified queue across replay, UI test, API test, error monitor, and PR review.
-
- - -
-
-
Loading…
-
-
-
-
-
-
Select a finding.
-
-
-
- - - - -""" +logger = logging.getLogger(__name__) @click.command("ui") @click.option( - "--config", - "config_path", - type=click.Path(dir_okay=False, path_type=Path), + "--config-path", default=Path("config.yaml"), - show_default=True, + type=click.Path(path_type=Path), + help="Path to config.yaml", ) -@click.option("--host", default="127.0.0.1", show_default=True) -@click.option("--port", default=8787, show_default=True, type=int) +@click.option("--host", default="127.0.0.1", help="Bind address") +@click.option("--port", default=8787, type=int, help="Port to listen on") @click.option( - "--repo", - "repo_full_name", + "--repo-full-name", default=None, - help="Optional connected repo full name filter.", + help="GitHub repo (owner/name) for code matching and fix prompts", ) def ui_command( config_path: Path, host: str, port: int, repo_full_name: Optional[str] ) -> None: """Run local browser UI for onboarding + findings + rrweb replay.""" - env_path = config_path.parent / ".env" cfg_dict = _read_config(config_path) diff --git a/src/retrace/commands/ui_payloads.py b/src/retrace/commands/ui_payloads.py new file mode 100644 index 0000000..d2944d3 --- /dev/null +++ b/src/retrace/commands/ui_payloads.py @@ -0,0 +1,2193 @@ +"""Payload-building helper functions for the Retrace UI server.""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import platform +import socket +import shutil +import subprocess +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urlparse + +import httpx +import yaml + +from retrace.api_suites import api_suites_dir_for_data_dir, list_api_suites, load_api_suite +from retrace.api_testing import ( + api_runs_dir_for_data_dir, + api_specs_dir_for_data_dir, + list_api_specs, + load_api_spec, + run_api_spec, +) +from retrace.fix_suggestions import ( + generate_fix_suggestions, + parsed_finding_from_replay_issue, + replay_issue_report_key, + slugify, +) +from retrace.evidence import build_evidence_timeline +from retrace.llm.client import build_llm_http_request +from retrace.reports.parser import parse_report_findings +from retrace.replay_specs import ( + _redacted_url, + generate_api_spec_from_replay_issue, + generate_spec_from_replay_issue, +) +from retrace.sdk_keys import create_sdk_key +from retrace.sentry_compat import build_sentry_dsn +from retrace.storage import GitHubRepoRow, Storage +from retrace.tester import ( + DEFAULT_APP_URL, + DEFAULT_HARNESS_COMMAND, + list_specs, + load_spec, + now_iso, + run_spec, + runs_dir_for_data_dir, + save_spec, + specs_dir_for_data_dir, + validate_spec, +) + +logger = logging.getLogger(__name__) + +_CLOUD_LLM_PROVIDERS = {"openai", "anthropic", "openrouter"} + +def _create_pinned_transport( + pinned_ip: str, hostname: str, scheme: str +) -> httpx.HTTPTransport: + """Create an httpx transport that connects to a pinned IP address. + + This prevents DNS rebinding/TOCTOU attacks by ensuring the HTTP connection uses the + IP address that was validated during URL checking, rather than performing a fresh + DNS resolution that could return a different (malicious) IP. + + Args: + pinned_ip: The IP address to connect to (already validated) + hostname: The original hostname (used for Host header and, for HTTPS, SNI) + scheme: The URL scheme ('http' or 'https') + + Returns: + An HTTPTransport configured to connect to the pinned IP with proper SNI for HTTPS + + Implementation: + For HTTPS, this creates a custom NetworkBackend that wraps SSL sockets with the + correct server_hostname for SNI, ensuring TLS certificate validation works properly + even though we're connecting to an IP address. + """ + import ssl + + class PinnedHTTPTransport(httpx.HTTPTransport): + """Transport that rewrites requests to use a pinned IP address.""" + + def __init__( + self, pinned_ip: str, original_hostname: str, *args: Any, **kwargs: Any + ): + self._pinned_ip = pinned_ip + self._original_hostname = original_hostname + super().__init__(*args, **kwargs) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + # Rewrite the request URL to use the pinned IP instead of hostname + original_url = str(request.url) + parsed = urlparse(original_url) + + # Set Host header to the original hostname (required for virtual hosting) + request.headers["Host"] = self._original_hostname + + # Prepare the pinned IP for use in URL netloc + # IPv6 addresses must be bracketed; also percent-encode zone IDs + pinned_ip_for_url = self._pinned_ip + if ":" in self._pinned_ip: + # This looks like an IPv6 address + # Percent-encode any zone identifier (% becomes %25) + pinned_ip_for_url = self._pinned_ip.replace("%", "%25") + # Wrap in brackets for URL + pinned_ip_for_url = f"[{pinned_ip_for_url}]" + + # Replace hostname with pinned IP in the netloc + if ":" in parsed.netloc and not parsed.netloc.startswith("["): + # Explicit port present: hostname:port -> ip:port + _, port = parsed.netloc.rsplit(":", 1) + new_netloc = f"{pinned_ip_for_url}:{port}" + else: + # No explicit port: hostname -> ip + new_netloc = pinned_ip_for_url + + # Reconstruct the URL with pinned IP + new_path = parsed.path or "/" + new_url = f"{parsed.scheme}://{new_netloc}{new_path}" + if parsed.query: + new_url += f"?{parsed.query}" + if parsed.fragment: + new_url += f"#{parsed.fragment}" + + request.url = httpx.URL(new_url) + + return super().handle_request(request) + + if scheme == "https": + # For HTTPS, create a custom network backend that sets correct SNI + ssl_context = ssl.create_default_context() + + # Create a custom NetworkBackend that wraps sockets with correct server_hostname + from httpcore._backends.sync import SyncBackend + + class SNINetworkBackend(SyncBackend): + """NetworkBackend that overrides SNI hostname for SSL connections.""" + + def __init__(self, pinned_ip: str, sni_hostname: str): + super().__init__() + self._pinned_ip = pinned_ip + self._sni_hostname = sni_hostname + + def start_tls( + self, + sock: socket.socket, + ssl_context: ssl.SSLContext, + server_hostname: Optional[str] = None, + timeout: Optional[float] = None, + ) -> ssl.SSLSocket: + # Override server_hostname to use the original hostname for SNI + # when we're connecting to our pinned IP + try: + peername = sock.getpeername() + if peername and peername[0] == self._pinned_ip: + server_hostname = self._sni_hostname + except Exception: + pass + + # Wrap the socket with SSL using the correct server_hostname + return super().start_tls(sock, ssl_context, server_hostname, timeout) + + network_backend = SNINetworkBackend(pinned_ip=pinned_ip, sni_hostname=hostname) + + transport = PinnedHTTPTransport( + pinned_ip=pinned_ip, + original_hostname=hostname, + verify=ssl_context, + network_backend=network_backend, + ) + + return transport + else: + # For HTTP, no SSL/SNI concerns + transport = PinnedHTTPTransport( + pinned_ip=pinned_ip, + original_hostname=hostname, + ) + + return transport + + +_LLM_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { + "openai_compatible": { + "base_url": "http://localhost:8080/v1", + "model": "llama-3.1-8b-instruct", + }, + "openai": { + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini", + }, + "anthropic": { + "base_url": "https://api.anthropic.com/v1", + "model": "claude-3-5-sonnet-latest", + }, + "openrouter": { + "base_url": "https://openrouter.ai/api/v1", + "model": "openai/gpt-4o-mini", + }, +} + + +def _default_config() -> dict[str, Any]: + llm_default = _LLM_PROVIDER_DEFAULTS["openai_compatible"] + return { + "posthog": { + "host": "https://us.i.posthog.com", + "project_id": "", + }, + "llm": { + "provider": "openai_compatible", + "base_url": llm_default["base_url"], + "model": llm_default["model"], + }, + "run": { + "lookback_hours": 168, + "max_sessions_per_run": 50, + "output_dir": "./reports", + "data_dir": "./data", + }, + "detectors": { + "console_error": True, + "network_5xx": True, + "network_4xx": True, + "rage_click": True, + "dead_click": True, + "error_toast": True, + "blank_render": True, + "session_abandon_on_error": True, + }, + "cluster": { + "min_size": 1, + }, + "tester": { + "app_url": DEFAULT_APP_URL, + "start_command": "", + "harness_command": DEFAULT_HARNESS_COMMAND, + "max_retries": 1, + "auth_required": False, + "auth_mode": "none", + "auth_login_url": "", + "auth_username": "", + "auth_password_env": "RETRACE_TESTER_AUTH_PASSWORD", + "auth_jwt_env": "RETRACE_TESTER_AUTH_JWT", + "auth_headers_env": "RETRACE_TESTER_AUTH_HEADERS", + }, + } + + +def _read_config(config_path: Path) -> dict[str, Any]: + cfg = _default_config() + if not config_path.exists(): + return cfg + raw = yaml.safe_load(config_path.read_text()) or {} + for k, v in raw.items(): + if isinstance(v, dict) and isinstance(cfg.get(k), dict): + cfg[k].update(v) + else: + cfg[k] = v + return cfg + + +def _write_config(config_path: Path, cfg: dict[str, Any]) -> None: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) + + +def _read_env(env_path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not env_path.exists(): + return out + for line in env_path.read_text().splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k, v = s.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def _write_env(env_path: Path, vals: dict[str, str]) -> None: + env_path.parent.mkdir(parents=True, exist_ok=True) + lines = [f"{k}={v}" for k, v in vals.items()] + env_path.write_text("\n".join(lines) + "\n") + + +def _latest_report(report_dir: Path) -> Optional[Path]: + files = sorted( + report_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True + ) + return files[0] if files else None + + +def _session_id_from_url(url: str) -> str: + return url.rstrip("/").split("/")[-1] + + +def _gh_checks() -> dict[str, Any]: + sys_name = platform.system().lower() + if "darwin" in sys_name: + install_cmd = "brew install gh" + elif "linux" in sys_name: + install_cmd = "sudo apt install gh" + elif "windows" in sys_name: + install_cmd = "winget install --id GitHub.cli" + else: + install_cmd = "See https://cli.github.com/ for install instructions" + + gh_path = shutil.which("gh") + installed = gh_path is not None + authed = False + auth_detail = "" + if installed: + try: + proc = subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + text=True, + timeout=6, + check=False, + ) + authed = proc.returncode == 0 + auth_detail = (proc.stdout or proc.stderr or "").strip()[:500] + except Exception as exc: + auth_detail = str(exc) + + return { + "installed": installed, + "authed": authed, + "gh_path": gh_path or "", + "auth_detail": auth_detail, + "commands": { + "install": install_cmd, + "login": "gh auth login", + "status": "gh auth status", + }, + } + + +def _posthog_check(host: str, project_id: str, api_key: str) -> dict[str, Any]: + configured = bool(host.strip() and project_id.strip() and api_key.strip()) + if not configured: + return { + "configured": False, + "reachable": None, + "detail": "Missing host/project/API key.", + } + + # Validate and pin the IP to prevent DNS rebinding + ok_url, safe_host, err, pinned_ips = _validate_base_url(host) + if not ok_url: + return {"configured": True, "reachable": False, "detail": err} + + url = f"{safe_host.rstrip('/')}/api/projects/{project_id}/" + parsed = urlparse(safe_host) + last_exc: Optional[Exception] = None + last_status: Optional[int] = None + try: + for pinned_ip in pinned_ips: + try: + # Use validated/pinned IP to prevent TOCTOU/DNS rebinding. + transport = _create_pinned_transport( + pinned_ip, parsed.hostname or "", parsed.scheme or "" + ) + with httpx.Client(timeout=8, transport=transport) as c: + r = c.get(url, headers={"Authorization": f"Bearer {api_key}"}) + last_status = r.status_code + if r.status_code // 100 == 2: + return { + "configured": True, + "reachable": True, + "detail": f"OK ({r.status_code})", + } + except Exception as exc: + last_exc = exc + continue + if last_status is not None: + return { + "configured": True, + "reachable": False, + "detail": f"HTTP {last_status}", + } + if last_exc: + raise last_exc + return {"configured": True, "reachable": False, "detail": "No pinned IPs available."} + except Exception as exc: + return {"configured": True, "reachable": False, "detail": str(exc)} + + +def _replay_api_check(base_url: str = "http://127.0.0.1:8788") -> dict[str, Any]: + url = base_url.rstrip("/") + "/healthz" + try: + with httpx.Client(timeout=2) as c: + response = c.get(url) + if response.status_code // 100 == 2: + return { + "configured": True, + "reachable": True, + "detail": f"OK ({response.status_code})", + "url": base_url.rstrip("/"), + "commands": {"serve": "retrace api serve"}, + } + return { + "configured": True, + "reachable": False, + "detail": f"HTTP {response.status_code}", + "url": base_url.rstrip("/"), + "commands": {"serve": "retrace api serve"}, + } + except Exception as exc: + return { + "configured": True, + "reachable": False, + "detail": str(exc), + "url": base_url.rstrip("/"), + "commands": {"serve": "retrace api serve"}, + } + + +def _truthy_env(name: str) -> bool: + return str(os.environ.get(name, "")).strip().lower() in {"1", "true", "yes", "on"} + + +def _validate_base_url(base_url: str) -> tuple[bool, str, str, list[str]]: + """Validate outbound model-provider URLs to reduce SSRF risk. + + Returns: (ok, normalized_url, error_message, pinned_ips) + pinned_ips are acceptable IPs resolved during validation and must be used + for actual HTTP requests to prevent DNS rebinding attacks. + """ + raw = base_url.strip() + if not raw: + return False, "", "Base URL is required.", [] + + parsed = urlparse(raw) + scheme = (parsed.scheme or "").lower() + if scheme not in {"http", "https"}: + return False, "", "Base URL must use http or https.", [] + if not parsed.hostname: + return False, "", "Base URL must include a hostname.", [] + + if parsed.query or parsed.fragment: + return False, "", "Base URL must not include query parameters or fragments.", [] + + default_port = 443 if scheme == "https" else 80 + port = parsed.port or default_port + allow_internal = _truthy_env("RETRACE_ALLOW_INTERNAL_URLS") + try: + infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + return False, "", f"Base URL hostname resolution failed: {exc}", [] + + pinned_ips: list[str] = [] + if not allow_internal: + for _, _, _, _, sockaddr in infos: + if not sockaddr: + continue + try: + ip = ipaddress.ip_address(sockaddr[0]) + except ValueError: + continue + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + continue + ip_s = sockaddr[0] + if ip_s not in pinned_ips: + pinned_ips.append(ip_s) + else: + # If internal URLs are allowed, keep all resolved IPs (deduped). + for _, _, _, _, sockaddr in infos: + if sockaddr: + ip_s = sockaddr[0] + if ip_s not in pinned_ips: + pinned_ips.append(ip_s) + + if not pinned_ips: + return ( + False, + "", + "No acceptable IP addresses found for base URL. " + "Set RETRACE_ALLOW_INTERNAL_URLS=true to allow internal hosts.", + [], + ) + + normalized = f"{scheme}://{parsed.netloc}{parsed.path or ''}".rstrip("/") + return True, normalized, "", pinned_ips + + +def _llm_check( + provider: str, base_url: str, model: str, api_key: str +) -> dict[str, Any]: + p = provider.strip().lower() + configured = bool(p and base_url.strip() and model.strip()) + if not configured: + return { + "configured": False, + "reachable": None, + "detail": "Missing provider/base URL/model.", + } + ok_url, safe_base_url, err, pinned_ips = _validate_base_url(base_url) + if not ok_url: + return {"configured": True, "reachable": False, "detail": err} + parsed = urlparse(safe_base_url) + last_exc: Optional[Exception] = None + last_status: Optional[int] = None + try: + url, headers, body = build_llm_http_request( + provider=p, + base_url=safe_base_url, + model=model, + api_key=api_key, + system="You are a test assistant.", + user="reply with ping", + temperature=0.0, + response_json=False, + max_tokens=8, + ) + for pinned_ip in pinned_ips: + try: + transport = _create_pinned_transport( + pinned_ip, parsed.hostname or "", parsed.scheme or "" + ) + with httpx.Client(timeout=12, transport=transport) as c: + r = c.post(url, headers=headers, json=body) + last_status = r.status_code + if r.status_code // 100 == 2: + return { + "configured": True, + "reachable": True, + "detail": f"OK ({r.status_code})", + } + except Exception as exc: + last_exc = exc + continue + if last_status is not None: + return { + "configured": True, + "reachable": False, + "detail": f"HTTP {last_status}", + } + if last_exc: + raise last_exc + return {"configured": True, "reachable": False, "detail": "No pinned IPs available."} + except Exception as exc: + return {"configured": True, "reachable": False, "detail": str(exc)} + + +def _llm_defaults(provider: str) -> dict[str, str]: + return _LLM_PROVIDER_DEFAULTS.get( + provider.strip().lower(), _LLM_PROVIDER_DEFAULTS["openai_compatible"] + ) + + +def _resolve_llm_api_key(provider: str, env_vars: dict[str, str]) -> str: + """Resolve the effective LLM API key using the same logic as load_config(). + + Args: + provider: The LLM provider name + env_vars: Dictionary of environment variables (from .env file or os.environ) + + Returns: + The effective API key (may be empty string) + """ + llm_key_env = env_vars.get("RETRACE_LLM_API_KEY", "").strip() + if not llm_key_env: + provider_env_map = { + "openai": "RETRACE_OPENAI_API_KEY", + "anthropic": "RETRACE_ANTHROPIC_API_KEY", + "openrouter": "RETRACE_OPENROUTER_API_KEY", + } + provider_env = provider_env_map.get(provider.strip().lower()) + if provider_env: + llm_key_env = env_vars.get(provider_env, "").strip() + return llm_key_env + + +def _llm_models(provider: str, base_url: str, api_key: str) -> dict[str, Any]: + p = provider.strip().lower() or "openai_compatible" + if p in _CLOUD_LLM_PROVIDERS and not api_key.strip(): + return {"ok": False, "error": "API key required for selected provider."} + ok_url, safe_base_url, err, pinned_ips = _validate_base_url(base_url) + if not ok_url: + return {"ok": False, "error": err} + parsed = urlparse(safe_base_url) + last_exc: Optional[Exception] = None + last_status: Optional[int] = None + try: + # We can't pass transport to fetch_llm_models without modifying it, + # so we inline the model fetching logic here with our secure transport + from retrace.llm.client import _build_headers, _extract_model_ids + + headers = _build_headers(provider=p, api_key=api_key.strip() or None) + url = f"{safe_base_url.rstrip('/')}/models" + for pinned_ip in pinned_ips: + try: + transport = _create_pinned_transport( + pinned_ip, parsed.hostname or "", parsed.scheme or "" + ) + with httpx.Client(timeout=10, transport=transport) as c: + resp = c.get(url, headers=headers) + last_status = resp.status_code + resp.raise_for_status() + payload = resp.json() + models = _extract_model_ids(payload) + return {"ok": True, "models": models} + except Exception as exc: + last_exc = exc + continue + if last_status is not None: + return {"ok": False, "error": f"HTTP {last_status}"} + if last_exc: + raise last_exc + return {"ok": False, "error": "No pinned IPs available."} + except Exception as exc: + return {"ok": False, "error": str(exc)} + + +def _to_findings_payload( + *, + store: Storage, + report_path: Optional[Path], + repo_full_name: Optional[str], +) -> list[dict[str, Any]]: + if report_path is None or not report_path.exists(): + return [] + + parsed = parse_report_findings(report_path) + rows = store.list_report_findings(str(report_path)) + by_hash = {r.finding_hash: r for r in rows} + repos = store.list_github_repos() + chosen_repo = None + if repo_full_name: + for r in repos: + if r.repo_full_name == repo_full_name: + chosen_repo = r + break + if chosen_repo is None and repos: + chosen_repo = repos[0] + + out: list[dict[str, Any]] = [] + for f in parsed: + h = f.finding_hash() + row = by_hash.get(h) + candidates: list[dict[str, Any]] = [] + prompts: dict[str, str] = {} + if row and chosen_repo: + for c in store.list_code_candidates( + finding_id=row.id, repo_id=chosen_repo.id + ): + rationale = "" + try: + rationale = (json.loads(str(c["rationale_json"])) or {}).get( + "rationale", "" + ) + except Exception: + rationale = str(c["rationale_json"]) + candidates.append( + { + "file_path": str(c["file_path"]), + "symbol": c["symbol"], + "score": float(c["score"]), + "rationale": rationale, + } + ) + for p in store.list_fix_prompts(finding_id=row.id, repo_id=chosen_repo.id): + prompts[p.agent_target] = p.prompt_markdown + + out.append( + { + "id": h, + "title": f.title, + "severity": f.severity, + "category": f.category, + "session_id": _session_id_from_url(f.session_url), + "session_url": f.session_url, + "evidence_text": f.evidence_text, + "distinct_id": row.distinct_id if row else "", + "error_issue_ids": row.error_issue_ids if row else [], + "trace_ids": row.trace_ids if row else [], + "top_stack_frame": row.top_stack_frame if row else "", + "error_tracking_url": row.error_tracking_url if row else "", + "logs_url": row.logs_url if row else "", + "first_error_ts_ms": row.first_error_ts_ms if row else 0, + "last_error_ts_ms": row.last_error_ts_ms if row else 0, + "regression_state": row.regression_state if row else "new", + "regression_occurrence_count": row.regression_occurrence_count + if row + else 1, + "candidates": candidates, + "prompts": prompts, + } + ) + return out + + +def _json_field(row: Any, key: str, fallback: Any) -> Any: + try: + return json.loads(str(row[key] or "")) + except Exception: + return fallback + + +def _failure_test_link_payload(link: Any) -> dict[str, Any]: + return { + "id": link.id, + "failure_id": link.failure_id, + "issue_id": link.issue_id, + "issue_public_id": link.issue_public_id, + "spec_id": link.spec_id, + "spec_name": link.spec_name, + "spec_path": link.spec_path, + "source": link.source, + "coverage_state": link.coverage_state, + "latest_run_id": link.latest_run_id, + "latest_run_status": link.latest_run_status, + "latest_run_classification": link.latest_run_classification, + "latest_run_ok": link.latest_run_ok, + "latest_run_at": ( + link.latest_run_at.isoformat() if link.latest_run_at is not None else "" + ), + "created_at": link.created_at.isoformat(), + "updated_at": link.updated_at.isoformat(), + } + + +def _repair_task_payload(task: Any | None) -> dict[str, Any] | None: + if task is None: + return None + return { + "id": task.id, + "public_id": task.public_id, + "failure_id": task.failure_id, + "source_type": task.source_type, + "source_external_id": task.source_external_id, + "title": task.title, + "status": task.status, + "likely_files": task.likely_files, + "prompt_artifacts": task.prompt_artifacts, + "validation_commands": task.validation_commands, + "branch": task.branch, + "pr_url": task.pr_url, + "risk_notes": task.risk_notes, + "metadata": task.metadata, + "evidence_ids": task.evidence_ids, + "created_at": task.created_at.isoformat(), + "updated_at": task.updated_at.isoformat(), + } + + +def _issue_workflow_payload(issue: dict[str, Any]) -> dict[str, Any]: + timeline_count = len(issue.get("timeline") or []) + reproduction_count = len(issue.get("reproduction_steps") or []) + test_links = issue.get("test_links") if isinstance(issue.get("test_links"), list) else [] + repair_task = issue.get("repair_task") if isinstance(issue.get("repair_task"), dict) else None + api_call_count = len(issue.get("api_calls") or []) + replay_count = len(issue.get("sessions") or []) + status = str(issue.get("status") or "") + coverage_states = [str(link.get("coverage_state") or "") for link in test_links] + latest_statuses = [str(link.get("latest_run_status") or "") for link in test_links] + api_links = [ + link + for link in test_links + if "api" in str(link.get("source") or "").lower() + or "/api-tests/" in str(link.get("spec_path") or "") + ] + if not test_links: + coverage_state = "not_covered" + elif "covered_failing" in coverage_states: + coverage_state = "covered_failing" + elif "covered_passing" in coverage_states: + coverage_state = "covered_passing" + elif "covered_flaky" in coverage_states: + coverage_state = "covered_flaky" + else: + coverage_state = coverage_states[0] or "covered_unverified" + + stages = { + "evidence": "complete" if timeline_count else "blocked", + "reproduction": "complete" if reproduction_count or replay_count else "blocked", + "test": "complete" if test_links else "current", + "repair": "complete" if repair_task else "current", + "verification": "complete" if coverage_state == "covered_passing" else "current", + } + if not test_links: + stages["repair"] = "blocked" + stages["verification"] = "blocked" + elif coverage_state == "covered_failing": + stages["verification"] = "blocked" + if status == "ignored": + primary_label = "Ignored fingerprint" + primary_action = "none" + elif not timeline_count: + primary_label = "Review raw evidence" + primary_action = "review_timeline" + elif not test_links: + primary_label = "Generate regression test" + primary_action = "generate_replay_spec" + elif coverage_state == "covered_failing" and not repair_task: + primary_label = "Generate repair task" + primary_action = "generate_repair" + elif coverage_state == "covered_failing": + primary_label = "Fix and rerun linked tests" + primary_action = "run_tests" + elif status == "resolved" and coverage_state != "covered_passing": + primary_label = "Verify resolved issue" + primary_action = "verify_resolved" + elif coverage_state == "covered_passing": + primary_label = "Covered by passing test" + primary_action = "none" + elif not repair_task and status not in {"resolved", "ignored"}: + primary_label = "Generate repair task" + primary_action = "generate_repair" + else: + primary_label = "Run linked tests" + primary_action = "run_tests" + + blockers: list[str] = [] + recommended_actions: list[dict[str, str]] = [] + capture_blocked = False + if not timeline_count: + capture_blocked = True + blockers.append("No normalized evidence timeline is available.") + if not reproduction_count and not replay_count: + capture_blocked = True + blockers.append("No replay or reproduction steps are linked.") + if not test_links: + blockers.append("No regression test covers this issue yet.") + recommended_actions.append( + { + "action": "generate_replay_spec", + "label": "Generate UI regression", + "reason": "Create an editable UI test from replay evidence.", + } + ) + if api_call_count and not api_links: + recommended_actions.append( + { + "action": "generate_api_regression", + "label": "Generate API regression", + "reason": "A failed network call is present without API coverage.", + } + ) + if test_links and coverage_state == "covered_failing" and not repair_task: + recommended_actions.append( + { + "action": "generate_repair", + "label": "Generate repair task", + "reason": "A linked regression still fails and needs repair context.", + } + ) + if status == "resolved" and coverage_state != "covered_passing": + recommended_actions.append( + { + "action": "verify_resolved", + "label": "Verify resolved issue", + "reason": "Resolved issues should pass linked UI/API regressions.", + } + ) + if test_links and not latest_statuses: + recommended_actions.append( + { + "action": "run_tests", + "label": "Run linked tests", + "reason": "Coverage exists but has no recorded run result.", + } + ) + readiness = "ready_for_repair" + if capture_blocked: + readiness = "needs_capture" + elif not test_links: + readiness = "needs_test" + elif coverage_state == "covered_passing": + readiness = "verified" + elif coverage_state == "covered_failing" and repair_task: + readiness = "repair_ready" + elif coverage_state == "covered_failing": + readiness = "needs_repair_task" + + return { + "coverage_state": coverage_state, + "latest_run_statuses": latest_statuses, + "primary_action": primary_action, + "primary_label": primary_label, + "readiness": readiness, + "blockers": blockers, + "recommended_actions": recommended_actions, + "stage_states": stages, + "counts": { + "timeline": timeline_count, + "reproduction_steps": reproduction_count, + "replays": replay_count, + "api_calls": api_call_count, + "tests": len(test_links), + "api_tests": len(api_links), + "repair_tasks": 1 if repair_task else 0, + }, + } + + +def _issue_evidence_stitching_payload(issue: dict[str, Any]) -> dict[str, Any]: + timeline = issue.get("timeline") if isinstance(issue.get("timeline"), list) else [] + api_calls = issue.get("api_calls") if isinstance(issue.get("api_calls"), list) else [] + test_links = issue.get("test_links") if isinstance(issue.get("test_links"), list) else [] + repair_task = issue.get("repair_task") if isinstance(issue.get("repair_task"), dict) else None + api_links = [ + link + for link in test_links + if "api" in str(link.get("source") or "").lower() + or "/api-tests/" in str(link.get("spec_path") or "") + ] + trace_ids: set[str] = set() + source_map_states: list[dict[str, Any]] = [] + for call in api_calls: + trace = call.get("trace") if isinstance(call, dict) else {} + if isinstance(trace, dict): + for value in trace.values(): + if isinstance(value, str) and value.strip(): + trace_ids.add(value.strip()) + elif isinstance(value, list): + trace_ids.update(str(item).strip() for item in value if str(item).strip()) + for event in timeline: + payload = event.get("payload") if isinstance(event, dict) else {} + if not isinstance(payload, dict): + continue + metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {} + for key in ("trace_id", "trace_ids"): + value = payload.get(key, metadata.get(key)) + if isinstance(value, str) and value.strip(): + trace_ids.add(value.strip()) + elif isinstance(value, list): + trace_ids.update(str(item).strip() for item in value if str(item).strip()) + evidence_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {} + frames = evidence_payload.get("stack_frames") if isinstance(evidence_payload, dict) else [] + if not isinstance(frames, list): + frames = [] + for frame in frames: + if not isinstance(frame, dict): + continue + source_map_states.append( + { + "filename": str(frame.get("filename") or frame.get("source") or ""), + "source_mapped": bool(frame.get("source_mapped")), + "reason": str(frame.get("source_map_reason") or ""), + "status": str(frame.get("source_map_status") or ""), + } + ) + stages = [ + { + "id": "frontend_replay", + "label": "Frontend replay", + "status": "complete" if timeline else "missing", + "detail": f"{len(timeline)} timeline event(s), {len(issue.get('sessions') or [])} replay(s)", + }, + { + "id": "network_api", + "label": "Network/API evidence", + "status": "complete" if api_calls else "missing", + "detail": f"{len(api_calls)} failed API call(s), {len(api_links)} linked API regression(s)", + }, + { + "id": "backend_trace", + "label": "Backend trace/log bridge", + "status": "complete" if trace_ids else "missing", + "detail": f"{len(trace_ids)} trace id(s)", + }, + { + "id": "source_maps", + "label": "Source map context", + "status": "complete" + if any(item.get("source_mapped") for item in source_map_states) + else ("partial" if source_map_states else "missing"), + "detail": f"{len(source_map_states)} stack frame mapping result(s)", + }, + { + "id": "repair_context", + "label": "Repair context", + "status": "complete" if repair_task else "missing", + "detail": str((repair_task or {}).get("public_id") or (repair_task or {}).get("id") or ""), + }, + ] + return { + "status": "complete" + if all(stage["status"] == "complete" for stage in stages) + else "partial", + "stages": stages, + "trace_ids": sorted(trace_ids), + "api_regression_spec_ids": [str(link.get("spec_id") or "") for link in api_links], + "source_map_frames": source_map_states[:10], + } + + +def _replay_issue_payload( + row: Any, *, sessions: list[dict[str, Any]] +) -> dict[str, Any]: + canonical_failure_id = row["canonical_failure_id"] + evidence = _json_field(row, "evidence_json", {}) + return { + "id": str(row["id"]), + "public_id": str(row["public_id"]), + "project_id": str(row["project_id"]), + "environment_id": str(row["environment_id"]), + "status": str(row["status"]), + "priority": str(row["priority"]), + "severity": str(row["severity"]), + "confidence": str(row["confidence"]), + "title": str(row["title"]), + "summary": str(row["summary"]), + "likely_cause": str(row["likely_cause"]), + "reproduction_steps": _json_field(row, "reproduction_steps_json", []), + "signal_summary": _json_field(row, "signal_summary_json", {}), + "evidence": evidence, + "api_calls": _replay_api_calls(evidence), + "fingerprint": str(row["fingerprint"]), + "analysis_status": str(row["analysis_status"]), + "analysis_model": str(row["analysis_model"]), + "analysis_prompt_version": str(row["analysis_prompt_version"]), + "analysis_error": str(row["analysis_error"]), + "affected_count": int(row["affected_count"]), + "affected_users": int(row["affected_users"]), + "representative_session_id": str(row["representative_session_id"]), + "external_ticket_state": str(row["external_ticket_state"]), + "external_ticket_url": str(row["external_ticket_url"]), + "external_ticket_id": str(row["external_ticket_id"]), + "canonical_failure_id": ( + None if canonical_failure_id is None else str(canonical_failure_id) + ), + "first_seen_ms": int(row["first_seen_ms"]), + "last_seen_ms": int(row["last_seen_ms"]), + "updated_at": str(row["updated_at"]), + "sessions": sessions, + "timeline": _replay_evidence_timeline(evidence), + "test_links": [], + "repair_task": None, + "workflow": {}, + "share_url": f"#issue={str(row['public_id'])}", + } + + +def _replay_evidence_timeline(evidence: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for event in evidence.get("events") if isinstance(evidence.get("events"), list) else []: + if not isinstance(event, dict): + continue + data_type = event.get("data_type") + if event.get("type") == 4: + title = "Navigation" + summary = str(event.get("href") or "") + kind = "navigation" + elif event.get("type") == 3 and event.get("source") == 2 and data_type == 2: + title = "Click" + summary = f"Clicked element id {event.get('id', 'unknown')}" + kind = "interaction" + elif event.get("type") == 3 and event.get("source") == 5: + title = "Input" + summary = f"Entered text in element id {event.get('id', 'unknown')}" + kind = "interaction" + else: + continue + items.append( + { + "id": "", + "type": "replay_event", + "kind": kind, + "occurred_at_ms": int(event.get("timestamp_ms") or 0), + "source": "replay", + "title": title, + "summary": summary, + "detector": "", + "detector_hit": False, + "confidence": "", + "reason_codes": [], + "payload": event, + } + ) + for signal in evidence.get("signals") if isinstance(evidence.get("signals"), list) else []: + if not isinstance(signal, dict): + continue + details = signal.get("details") if isinstance(signal.get("details"), dict) else {} + detector = str(signal.get("detector") or "") + status = details.get("status") + request_url = details.get("request_url") or details.get("url") + if detector.startswith("network") and request_url: + title = f"Network {status or ''}".strip() + summary = f"{details.get('method') or 'GET'} {request_url} returned {status or 'unknown'}" + kind = "network" + elif detector == "console_error": + title = "Console error" + summary = str(details.get("message") or details.get("payload") or detector) + kind = "error" + else: + title = detector.replace("_", " ").title() or "Detector signal" + summary = json.dumps(details, sort_keys=True) if details else title + kind = "detector" + items.append( + { + "id": "", + "type": "replay_signal", + "kind": kind, + "occurred_at_ms": int(signal.get("timestamp_ms") or 0), + "source": "replay", + "title": title, + "summary": summary, + "detector": detector, + "detector_hit": True, + "confidence": str(signal.get("confidence") or ""), + "reason_codes": signal.get("reason_codes") if isinstance(signal.get("reason_codes"), list) else [], + "payload": signal, + } + ) + return sorted(items, key=lambda item: (int(item.get("occurred_at_ms") or 0), str(item.get("title") or ""))) + + +def _replay_api_calls(evidence: dict[str, Any]) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + for signal in evidence.get("signals") if isinstance(evidence.get("signals"), list) else []: + if not isinstance(signal, dict): + continue + detector = str(signal.get("detector") or "") + if detector not in {"network_4xx", "network_5xx"}: + continue + details = signal.get("details") if isinstance(signal.get("details"), dict) else {} + raw_url = str(details.get("request_url") or details.get("url") or "") + calls.append( + { + "detector": detector, + "timestamp_ms": int(signal.get("timestamp_ms") or 0), + "method": str(details.get("method") or details.get("request_method") or "GET").upper(), + "url": _redacted_url(raw_url), + "status": details.get("status") or details.get("status_code") or "", + "confidence": str(signal.get("confidence") or ""), + "reason_codes": signal.get("reason_codes") if isinstance(signal.get("reason_codes"), list) else [], + "trace": details.get("trace") if isinstance(details.get("trace"), dict) else {}, + } + ) + return calls + + +def _to_replay_dashboard_payload(store: Storage) -> dict[str, Any]: + issues = [] + issue_rows = store.list_recent_replay_issues(limit=50) + issue_sessions_by_id: dict[str, list[dict[str, Any]]] = {} + for session in store.list_replay_issue_sessions_for_issues( + [str(row["id"]) for row in issue_rows] + ): + issue_sessions_by_id.setdefault(str(session["issue_id"]), []).append( + { + "session_id": str(session["session_id"]), + "stable_id": str(session["replay_stable_id"] or session["session_id"]), + "public_id": str(session["replay_public_id"] or ""), + "role": str(session["role"]), + "first_seen_ms": int(session["first_seen_ms"]), + "last_seen_ms": int(session["last_seen_ms"]), + } + ) + for row in issue_rows: + payload = _replay_issue_payload( + row, + sessions=issue_sessions_by_id.get(str(row["id"]), []), + ) + failure_id = str(row["canonical_failure_id"] or "") + if failure_id: + payload["timeline"] = build_evidence_timeline( + store.list_failure_evidence(failure_id=failure_id) + ) + payload["test_links"] = [ + _failure_test_link_payload(link) + for link in store.list_failure_test_links(failure_id=failure_id) + ] + repair_tasks = store.list_repair_tasks(failure_id=failure_id, limit=1) + payload["repair_task"] = _repair_task_payload( + repair_tasks[0] if repair_tasks else None + ) + payload["workflow"] = _issue_workflow_payload(payload) + payload["evidence_stitching"] = _issue_evidence_stitching_payload(payload) + issues.append(payload) + sessions = [] + for row in store.list_recent_replay_sessions(limit=50): + sessions.append( + { + "id": str(row["id"]), + "project_id": str(row["project_id"]), + "environment_id": str(row["environment_id"]), + "stable_id": str(row["stable_id"]), + "public_id": str(row["public_id"]), + "distinct_id": str(row["distinct_id"]), + "status": str(row["status"]), + "event_count": int(row["event_count"]), + "metadata": _json_field(row, "metadata_json", {}), + "preview": _json_field(row, "preview_json", {}), + "last_seen_at": str(row["last_seen_at"]), + "share_url": f"#replay={str(row['public_id'])}", + } + ) + return {"issues": issues, "sessions": sessions} + + +def _generate_replay_issue_spec_payload( + *, + store: Storage, + data_dir: Path, + issue_id: str, + project_id: str, + environment_id: str, + app_url: str = "", +) -> tuple[dict[str, Any], int]: + try: + generated = generate_spec_from_replay_issue( + store=store, + specs_dir=specs_dir_for_data_dir(data_dir), + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + app_url=app_url, + ) + except ValueError as exc: + message = str(exc) + status = 404 if "not found" in message.lower() else 400 + return {"ok": False, "error": message}, status + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + return ( + { + "ok": True, + "spec": generated.spec.__dict__, + "issue_public_id": generated.issue_public_id, + "replay_public_id": generated.replay_public_id, + "confidence": generated.confidence, + "known_gaps": generated.known_gaps, + }, + 200, + ) + + +def _issue_has_replay_regression_link(store: Storage, issue: Any) -> bool: + failure_id = str(issue["canonical_failure_id"] or "") + if not failure_id: + return False + return any( + link.source == "replay_issue" + for link in store.list_failure_test_links(failure_id=failure_id) + ) + + +def _generate_replay_issue_specs_payload( + *, + store: Storage, + data_dir: Path, + project_id: str, + environment_id: str, + issue_ids: list[str] | None = None, + status: str = "", + app_url: str = "", + limit: int = 25, + missing_only: bool = True, +) -> tuple[dict[str, Any], int]: + try: + limit_v = max(1, min(int(limit), 100)) + except (TypeError, ValueError): + limit_v = 25 + clean_issue_ids = [ + str(item).strip() + for item in (issue_ids or []) + if str(item or "").strip() + ][:limit_v] + selected: list[Any] = [] + seen: set[str] = set() + if clean_issue_ids: + for issue_id in clean_issue_ids: + row = store.get_replay_issue( + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + ) + if row is None: + continue + public_id = str(row["public_id"]) + if public_id in seen: + continue + seen.add(public_id) + selected.append(row) + else: + selected = store.list_replay_issues( + project_id=project_id, + environment_id=environment_id, + status=status or None, + )[:limit_v] + + results: list[dict[str, Any]] = [] + skipped: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + for issue in selected: + public_id = str(issue["public_id"]) + issue_status = str(issue["status"] or "") + if issue_status == "ignored": + skipped.append({"issue_public_id": public_id, "reason": "ignored"}) + continue + if missing_only and _issue_has_replay_regression_link(store, issue): + skipped.append( + {"issue_public_id": public_id, "reason": "already_covered"} + ) + continue + try: + generated = generate_spec_from_replay_issue( + store=store, + specs_dir=specs_dir_for_data_dir(data_dir), + project_id=project_id, + environment_id=environment_id, + issue_id=public_id, + app_url=app_url, + ) + except Exception as exc: + failed.append({"issue_public_id": public_id, "error": str(exc)}) + continue + results.append( + { + "issue_public_id": generated.issue_public_id, + "replay_public_id": generated.replay_public_id, + "spec_id": generated.spec.spec_id, + "spec_name": generated.spec.name, + "confidence": generated.confidence, + "known_gaps": generated.known_gaps, + } + ) + ok = not failed + return ( + { + "ok": ok, + "requested": len(clean_issue_ids) if clean_issue_ids else len(selected), + "considered": len(selected), + "generated": len(results), + "skipped": skipped, + "failed": failed, + "results": results, + }, + 200 if ok else 207, + ) + + +def _generate_replay_issue_api_spec_payload( + *, + store: Storage, + data_dir: Path, + issue_id: str, + project_id: str, + environment_id: str, + app_url: str = "", +) -> tuple[dict[str, Any], int]: + try: + generated = generate_api_spec_from_replay_issue( + store=store, + specs_dir=api_specs_dir_for_data_dir(data_dir), + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + app_url=app_url, + ) + except ValueError as exc: + message = str(exc) + status = 404 if "not found" in message.lower() else 400 + return {"ok": False, "error": message}, status + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + return ( + { + "ok": True, + "spec": generated.spec.__dict__, + "issue_public_id": generated.issue_public_id, + "replay_public_id": generated.replay_public_id, + "source_signal": generated.source_signal, + }, + 200, + ) + + +def _run_replay_issue_api_spec_payload( + *, + store: Storage, + data_dir: Path, + spec_id: str, +) -> tuple[dict[str, Any], int]: + try: + spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_id) + result = run_api_spec( + spec=spec, + runs_dir=api_runs_dir_for_data_dir(data_dir), + ) + links = store.list_failure_test_links(spec_id=result.spec_id, limit=10) + if not links: + failure_id = str(spec.fixtures.get("canonical_failure_id") or "") + issue_id = str(spec.fixtures.get("issue_id") or "") + issue_public_id = str(spec.fixtures.get("issue_public_id") or "") + if failure_id: + link_id = store.upsert_failure_test_link( + failure_id=failure_id, + issue_id=issue_id, + issue_public_id=issue_public_id, + spec_id=spec.spec_id, + spec_name=spec.name, + spec_path=str(api_specs_dir_for_data_dir(data_dir) / f"{spec.spec_id}.json"), + source=str(spec.fixtures.get("source") or "replay_issue_api"), + ) + links = store.list_failure_test_links( + spec_id=result.spec_id, + limit=10, + ) + links = [link for link in links if link.id == link_id] or links + updated = [] + for link in links: + updated.extend( + store.update_failure_test_link_run( + spec_id=result.spec_id, + run_result=result, + link_id=link.id, + ) + ) + except FileNotFoundError: + return {"ok": False, "error": f"API spec not found: {spec_id}"}, 404 + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + return ( + { + "ok": result.ok, + "result": result.__dict__, + "updated_links": [_failure_test_link_payload(link) for link in updated], + }, + 200 if result.ok else 400, + ) + + +def _select_repo( + *, store: Storage, repo_full_name: str = "" +) -> tuple[GitHubRepoRow | None, str]: + repos = store.list_github_repos() + requested = repo_full_name.strip() + if requested: + repo = store.get_github_repo(requested) + if repo is None: + return None, ( + f"Repo not connected: {requested}. " + "Run `retrace github connect --repo org/name` first." + ) + return repo, "" + if not repos: + return None, "No connected repo. Run `retrace github connect --repo org/name` first." + return repos[0], "" + + +def _generate_replay_issue_fix_prompts_payload( + *, + store: Storage, + output_dir: Path, + issue_id: str, + project_id: str, + environment_id: str, + repo_full_name: str = "", +) -> tuple[dict[str, Any], int]: + repo, error = _select_repo(store=store, repo_full_name=repo_full_name) + if repo is None: + return {"ok": False, "error": error}, 400 + + try: + issue = store.get_replay_issue( + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + ) + if issue is None: + return {"ok": False, "error": f"Replay issue not found: {issue_id}"}, 404 + if str(issue["status"] or "") == "ignored": + return {"ok": False, "error": f"Replay issue is ignored: {issue_id}"}, 409 + finding = parsed_finding_from_replay_issue(issue) + repo_path = Path(repo.local_path) if repo.local_path else None + result = generate_fix_suggestions( + store=store, + repo=repo, + repo_path=repo_path, + out_dir=output_dir / "fix-prompts", + report_key=replay_issue_report_key(str(issue["public_id"])), + source_label=f"replay issue {issue['public_id']}", + artifact_stem=f"replay-{slugify(str(issue['public_id']))}", + findings=[finding], + project_id=project_id, + environment_id=environment_id, + ) + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + + artifact = result.artifacts[0] if result.artifacts else None + return ( + { + "ok": True, + "issue_public_id": str(issue["public_id"]), + "repo": result.repo_full_name, + "repo_path": result.repo_path, + "out_dir": str(result.out_dir), + "stored": result.stored, + "generated": result.generated, + "regression_counts": result.regression_counts, + "finding_hash": artifact.finding_hash if artifact else "", + "candidates": [ + { + "file_path": c.file_path, + "symbol": c.symbol, + "score": c.score, + "rationale": c.rationale, + } + for c in (artifact.candidates if artifact else []) + ], + "prompts": artifact.prompts if artifact else {}, + "prompt_files": artifact.prompt_files if artifact else {}, + "artifact_json": artifact.artifact_json if artifact else "", + "artifact_manifest_json": artifact.artifact_manifest_json if artifact else "", + "repair_task_id": artifact.repair_task_id if artifact else "", + }, + 200, + ) + + +def _transition_replay_issue_payload( + *, + store: Storage, + issue_id: str, + project_id: str, + environment_id: str, + status: str, +) -> tuple[dict[str, Any], int]: + if status not in {"resolved", "unresolved", "ignored"}: + return {"ok": False, "error": "status must be resolved, unresolved, or ignored"}, 400 + issue = store.get_replay_issue( + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + ) + if issue is None: + return {"ok": False, "error": f"Replay issue not found: {issue_id}"}, 404 + try: + updated = store.transition_replay_issue(str(issue["id"]), status=status) + except ValueError as exc: + return {"ok": False, "error": str(exc)}, 400 + refreshed = store.get_replay_issue( + project_id=project_id, + environment_id=environment_id, + issue_id=issue_id, + ) + return ( + { + "ok": True, + "updated": updated, + "issue": _replay_issue_payload( + refreshed if refreshed is not None else issue, + sessions=[], + ), + }, + 200, + ) + + +def _verify_resolved_issues_payload( + *, + store: Storage, + data_dir: Path, + cwd: Path, + project_id: str, + environment_id: str, + limit: int = 10, + dry_run: bool = False, +) -> tuple[dict[str, Any], int]: + try: + limit_v = max(1, min(int(limit), 100)) + except (TypeError, ValueError): + limit_v = 10 + specs_by_id: dict[str, Any] = {} + specs_by_issue: dict[str, Any] = {} + for spec in list_specs(specs_dir_for_data_dir(data_dir)): + specs_by_id[spec.spec_id] = spec + public_id = str(spec.fixtures.get("issue_public_id") or "").strip() + if not public_id: + continue + existing = specs_by_issue.get(public_id) + if existing is None or spec.updated_at > existing.updated_at: + specs_by_issue[public_id] = spec + api_specs_by_id: dict[str, Any] = {} + api_specs_by_issue: dict[str, Any] = {} + for spec_path in api_specs_dir_for_data_dir(data_dir).glob("*.json"): + try: + spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_path.stem) + except Exception: + continue + api_specs_by_id[spec.spec_id] = spec + public_id = str(spec.fixtures.get("issue_public_id") or "").strip() + if not public_id: + continue + existing = api_specs_by_issue.get(public_id) + if existing is None or spec.updated_at > existing.updated_at: + api_specs_by_issue[public_id] = spec + + resolved = store.list_replay_issues( + project_id=project_id, + environment_id=environment_id, + status="resolved", + ) + plan: list[dict[str, Any]] = [] + for row in resolved[:limit_v]: + public_id = str(row["public_id"]) + failure_id = str(row["canonical_failure_id"] or "") + planned_tests: list[dict[str, str]] = [] + if failure_id: + for link in store.list_failure_test_links(failure_id=failure_id): + linked_spec = specs_by_id.get(link.spec_id) + if linked_spec is not None: + planned_tests.append( + { + "kind": "ui", + "spec_id": linked_spec.spec_id, + "coverage_link_id": link.id, + } + ) + continue + linked_api_spec = api_specs_by_id.get(link.spec_id) + if linked_api_spec is not None: + planned_tests.append( + { + "kind": "api", + "spec_id": linked_api_spec.spec_id, + "coverage_link_id": link.id, + } + ) + if not planned_tests: + spec = specs_by_issue.get(public_id) + if spec is not None: + planned_tests.append( + {"kind": "ui", "spec_id": spec.spec_id, "coverage_link_id": ""} + ) + if not planned_tests: + api_spec = api_specs_by_issue.get(public_id) + if api_spec is not None: + planned_tests.append( + {"kind": "api", "spec_id": api_spec.spec_id, "coverage_link_id": ""} + ) + plan.append( + { + "public_id": public_id, + "issue_id": str(row["id"]), + "failure_id": failure_id, + "title": str(row["title"] or "Replay issue"), + "spec_id": planned_tests[0]["spec_id"] if planned_tests else "", + "spec_kind": planned_tests[0]["kind"] if planned_tests else "", + "coverage_link_id": ( + planned_tests[0]["coverage_link_id"] if planned_tests else "" + ), + "tests": planned_tests, + "has_spec": bool(planned_tests), + } + ) + + if dry_run: + return {"ok": True, "plan": plan, "verified": [], "regressed": []}, 200 + + verified: list[str] = [] + regressed: list[dict[str, Any]] = [] + for entry in plan: + tests = entry.get("tests") if isinstance(entry.get("tests"), list) else [] + if not tests: + continue + failures: list[dict[str, str]] = [] + for test in tests: + spec_id = str(test.get("spec_id") or "") + kind = str(test.get("kind") or "ui") + coverage_link_id = str(test.get("coverage_link_id") or "") + try: + if kind == "api": + api_spec = api_specs_by_id.get(spec_id) + if api_spec is None: + raise FileNotFoundError(f"API spec not found: {spec_id}") + result = run_api_spec( + spec=api_spec, + runs_dir=api_runs_dir_for_data_dir(data_dir), + ) + else: + spec = specs_by_id.get(spec_id) or specs_by_issue[entry["public_id"]] + result = run_spec( + spec=spec, + runs_dir=runs_dir_for_data_dir(data_dir), + cwd=cwd, + ) + except Exception as exc: + failures.append( + {"spec_id": spec_id, "kind": kind, "error": f"run raised: {exc}"} + ) + continue + if coverage_link_id: + try: + store.update_failure_test_link_run( + spec_id=result.spec_id, + run_result=result, + link_id=coverage_link_id, + ) + except Exception: + logger.warning( + "failed to persist failure_test_link run metadata", + extra={"spec_id": result.spec_id, "run_id": result.run_id}, + exc_info=True, + ) + if not result.ok: + failures.append( + { + "spec_id": result.spec_id, + "kind": kind, + "run_id": result.run_id, + "exit_code": str(getattr(result, "exit_code", "")), + "error": result.error, + } + ) + if not failures: + store.transition_replay_issue(entry["issue_id"], status="verified") + verified.append(entry["public_id"]) + continue + store.transition_replay_issue(entry["issue_id"], status="regressed") + regressed.append( + { + "public_id": entry["public_id"], + "issue_id": entry["issue_id"], + "spec_id": failures[0].get("spec_id", ""), + "run_id": failures[0].get("run_id", ""), + "error": failures[0].get("error", ""), + "failures": failures, + } + ) + return {"ok": True, "plan": plan, "verified": verified, "regressed": regressed}, 200 + + +def _github_repos_payload(store: Storage) -> dict[str, Any]: + return { + "repos": [ + { + "repo_full_name": repo.repo_full_name, + "default_branch": repo.default_branch, + "remote_url": repo.remote_url, + "local_path": repo.local_path, + "provider": repo.provider, + "connected_at": repo.connected_at.isoformat(), + } + for repo in store.list_github_repos() + ] + } + + +def _api_suites_payload(data_dir: Path) -> dict[str, Any]: + suites = [] + for suite in list_api_suites(api_suites_dir_for_data_dir(data_dir)): + warnings = suite.import_summary.get("quality_warnings") + if not isinstance(warnings, dict): + warnings = {} + warning_count = sum( + len(value) for value in warnings.values() if isinstance(value, list) + ) + suites.append( + { + "suite_id": suite.suite_id, + "name": suite.name, + "source": suite.source, + "spec_count": len(suite.spec_ids), + "spec_ids": suite.spec_ids, + "auth_profile": suite.auth_profile, + "env_profile": suite.env_profile, + "filters": suite.filters, + "import_summary": suite.import_summary, + "operation_count": len(suite.operations), + "operations": suite.operations[:25], + "skipped_count": len(suite.skipped), + "skipped": suite.skipped[:25], + "quality_warning_count": warning_count, + "metadata": suite.metadata, + "created_at": suite.created_at, + "updated_at": suite.updated_at, + } + ) + return {"suites": suites} + + +def _api_specs_payload(data_dir: Path) -> dict[str, Any]: + specs = [] + for spec in list_api_specs(api_specs_dir_for_data_dir(data_dir)): + fixtures = dict(spec.fixtures or {}) + specs.append( + { + "spec_id": spec.spec_id, + "name": spec.name, + "method": spec.method, + "url": spec.url, + "auth_profile": spec.auth_profile, + "env_profile": spec.env_profile, + "expected_status": spec.expected_status, + "request_count": len(spec.steps) if spec.steps else 1, + "json_assertion_count": len(spec.json_assertions), + "schema_assertion_count": len(spec.schema_assertions), + "source": str(fixtures.get("source") or ""), + "issue_public_id": str(fixtures.get("issue_public_id") or ""), + "operation_id": str(fixtures.get("operation_id") or ""), + "openapi_path": str(fixtures.get("openapi_path") or ""), + "created_at": spec.created_at, + "updated_at": spec.updated_at, + } + ) + return {"specs": specs} + + +def _hosted_onboarding_readiness_payload( + *, + store: Storage, + data_dir: Path, + settings: dict[str, Any], + checks: dict[str, Any], +) -> dict[str, Any]: + sdk_keys = store.list_sdk_keys(include_revoked=False, limit=10) + replay_sessions = store.list_recent_replay_sessions(limit=10) + replay_issues = store.list_recent_replay_issues(limit=10) + fallback_workspace = store.ensure_workspace(project_name="Default") + if replay_issues: + project_id = str(replay_issues[0]["project_id"]) + environment_id = str(replay_issues[0]["environment_id"]) + elif sdk_keys: + project_id = sdk_keys[0].project_id + environment_id = sdk_keys[0].environment_id + else: + project_id = fallback_workspace.project_id + environment_id = fallback_workspace.environment_id + ui_specs = list_specs(specs_dir_for_data_dir(data_dir)) + api_specs = list_api_specs(api_specs_dir_for_data_dir(data_dir)) + api_suites = list_api_suites(api_suites_dir_for_data_dir(data_dir)) + source_maps = store.list_recent_source_maps( + project_id=project_id, + environment_id=environment_id, + limit=10, + ) + alert_rules = store.list_app_error_alert_rules( + project_id=project_id, + environment_id=environment_id, + limit=10, + ) + test_links = store.list_all_failure_test_links() + repair_tasks = store.list_repair_tasks(limit=10) + steps = [ + { + "id": "settings", + "label": "Configure hosted settings", + "status": "complete" + if settings.get("tester_app_url") and checks.get("replay_api", {}).get("reachable") is True + else "current", + "detail": f"Replay API: {checks.get('replay_api', {}).get('detail') or 'not checked'}", + "action": "Save settings and run retrace api serve", + }, + { + "id": "capture_key", + "label": "Create browser capture key", + "status": "complete" if sdk_keys else "current", + "detail": f"{len(sdk_keys)} active browser SDK key(s)", + "action": "Create SDK Key", + }, + { + "id": "capture_smoke", + "label": "Verify replay capture", + "status": "complete" if replay_sessions or replay_issues else "blocked", + "detail": f"{len(replay_sessions)} recent first-party replay session(s)", + "action": "Send a smoke replay from the instrumented app", + }, + { + "id": "issue_grouping", + "label": "Process captured errors into issues", + "status": "complete" if replay_issues else "blocked", + "detail": f"{len(replay_issues)} recent replay issue(s)", + "action": "Process Queued Replays", + }, + { + "id": "ui_tests", + "label": "Generate and review UI regressions", + "status": "complete" if ui_specs and test_links else "current", + "detail": f"{len(ui_specs)} UI spec(s), {sum(1 for spec in ui_specs if dict(spec.fixtures or {}).get('draft_status') == 'draft')} draft(s)", + "action": "Generate regression tests from issues", + }, + { + "id": "api_tests", + "label": "Import or generate API coverage", + "status": "complete" if api_suites or api_specs else "current", + "detail": f"{len(api_suites)} API suite(s), {len(api_specs)} API spec(s)", + "action": "Import OpenAPI or generate API regression", + }, + { + "id": "monitoring", + "label": "Harden monitoring", + "status": "complete" if source_maps and alert_rules else "current", + "detail": f"{len(source_maps)} source map upload(s), {len(alert_rules)} alert rule(s)", + "action": "Upload source maps and create alert rules", + }, + { + "id": "repair_loop", + "label": "Create repair-ready context", + "status": "complete" if repair_tasks else "blocked", + "detail": f"{len(repair_tasks)} repair task(s)", + "action": "Generate fix prompts from a failing issue", + }, + ] + complete = sum(1 for step in steps if step["status"] == "complete") + return { + "workspace": { + "project_id": project_id, + "environment_id": environment_id, + }, + "ready": complete == len(steps), + "complete": complete, + "total": len(steps), + "steps": steps, + "counts": { + "sdk_keys": len(sdk_keys), + "replay_sessions": len(replay_sessions), + "replay_issues": len(replay_issues), + "ui_specs": len(ui_specs), + "api_specs": len(api_specs), + "api_suites": len(api_suites), + "source_maps": len(source_maps), + "alert_rules": len(alert_rules), + "test_links": len(test_links), + "repair_tasks": len(repair_tasks), + }, + } + + +def _run_api_spec_payload(*, data_dir: Path, spec_id: str) -> tuple[dict[str, Any], int]: + clean_spec_id = spec_id.strip() + if not clean_spec_id: + return {"ok": False, "error": "spec_id is required"}, 400 + try: + spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), clean_spec_id) + except Exception: + return {"ok": False, "error": f"API spec not found: {clean_spec_id}"}, 404 + result = run_api_spec( + spec=spec, + runs_dir=api_runs_dir_for_data_dir(data_dir), + ) + return {"ok": result.ok, "result": result.__dict__}, 200 if result.ok else 400 + + +def _run_api_suite_payload(*, data_dir: Path, suite_id: str) -> tuple[dict[str, Any], int]: + clean_suite_id = suite_id.strip() + if not clean_suite_id: + return {"ok": False, "error": "suite_id is required"}, 400 + try: + suite = load_api_suite(api_suites_dir_for_data_dir(data_dir), clean_suite_id) + except Exception: + return {"ok": False, "error": f"API suite not found: {clean_suite_id}"}, 404 + results: list[dict[str, Any]] = [] + for spec_id in suite.spec_ids: + try: + spec = load_api_spec(api_specs_dir_for_data_dir(data_dir), spec_id) + result = run_api_spec( + spec=spec, + runs_dir=api_runs_dir_for_data_dir(data_dir), + ) + results.append( + { + "spec_id": spec.spec_id, + "name": spec.name, + "method": spec.method, + "url": spec.url, + "ok": result.ok, + "status": result.status, + "status_code": result.status_code, + "elapsed_ms": result.elapsed_ms, + "run_id": result.run_id, + "failure_classification": result.failure_classification, + "error": result.error, + } + ) + except Exception as exc: + results.append( + { + "spec_id": str(spec_id), + "name": "", + "method": "", + "url": "", + "ok": False, + "status": "failed", + "status_code": 0, + "elapsed_ms": 0, + "run_id": "", + "failure_classification": "suite_error", + "error": str(exc), + } + ) + passed = sum(1 for item in results if bool(item.get("ok"))) + failed = len(results) - passed + return { + "ok": failed == 0, + "suite_id": suite.suite_id, + "name": suite.name, + "total": len(results), + "passed": passed, + "failed": failed, + "results": results, + }, 200 if failed == 0 else 400 + + +def _json_object_list_payload(value: Any, *, label: str) -> list[dict[str, Any]]: + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise ValueError(f"{label} must be a JSON list of objects") + return [dict(item) for item in value] + + +def _edit_ui_draft_payload( + *, + data_dir: Path, + spec_id: str, + name: str = "", + prompt: str = "", + app_url: str = "", + steps: Any = None, + assertions: Any = None, + review_note: str = "", + accept: bool = False, +) -> tuple[dict[str, Any], int]: + clean_spec_id = spec_id.strip() + if not clean_spec_id: + return {"ok": False, "error": "spec_id is required"}, 400 + specs_dir = specs_dir_for_data_dir(data_dir) + try: + spec = load_spec(specs_dir, clean_spec_id) + except Exception: + return {"ok": False, "error": f"spec not found: {clean_spec_id}"}, 404 + if dict(spec.fixtures or {}).get("draft_status") != "draft": + return {"ok": False, "error": "Spec is not an unaccepted draft."}, 409 + + changed_fields: list[str] = [] + edited_name = name.strip() + if edited_name and edited_name != spec.name: + spec.name = edited_name + changed_fields.append("name") + edited_prompt = prompt.strip() + if edited_prompt and edited_prompt != spec.prompt: + spec.prompt = edited_prompt + changed_fields.append("prompt") + edited_app_url = app_url.strip() + if edited_app_url and edited_app_url != spec.app_url: + spec.app_url = edited_app_url + changed_fields.append("app_url") + try: + if steps is not None: + spec.exact_steps = _json_object_list_payload(steps, label="steps") + changed_fields.append("exact_steps") + if assertions is not None: + spec.assertions = _json_object_list_payload(assertions, label="assertions") + changed_fields.append("assertions") + except ValueError as exc: + return {"ok": False, "error": str(exc)}, 400 + + spec.fixtures = dict(spec.fixtures or {}) + notes = [ + str(item).strip() + for item in list(spec.fixtures.get("review_notes", []) or []) + if str(item).strip() + ] + clean_note = review_note.strip() + if clean_note: + notes.append(clean_note) + spec.fixtures["review_notes"] = notes + changed_fields.append("review_notes") + spec.fixtures["reviewed_at"] = now_iso() + if accept: + spec.fixtures["draft_status"] = "accepted" + spec.fixtures.setdefault("accepted_at", now_iso()) + changed_fields.append("draft_status") + if changed_fields: + spec.fixtures["last_review_edit"] = { + "edited_at": now_iso(), + "fields": sorted(set(changed_fields)), + } + spec.updated_at = now_iso() + try: + validate_spec(spec) + save_spec(specs_dir, spec) + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + return { + "ok": True, + "spec": spec.__dict__, + "draft_status": spec.fixtures.get("draft_status", ""), + "accepted": bool(accept), + "changed_fields": sorted(set(changed_fields)), + "step_count": len(spec.exact_steps or []), + "assertion_count": len(spec.assertions or []), + "review_notes": spec.fixtures.get("review_notes", []), + }, 200 + + +def _connect_github_repo_payload( + *, + store: Storage, + repo_full_name: str, + default_branch: str = "main", + local_path: str = "", +) -> tuple[dict[str, Any], int]: + repo = repo_full_name.strip() + branch = default_branch.strip() or "main" + path_value = local_path.strip() + provider = "github" + remote_url = "" + if path_value: + repo_path = Path(path_value).expanduser() + if not repo_path.exists() or not repo_path.is_dir(): + return { + "ok": False, + "error": f"Local path is not a directory: {path_value}", + }, 400 + path_value = str(repo_path) + if not repo: + repo = f"local/{slugify(repo_path.name or 'codebase')}" + provider = "local" + if not repo: + return { + "ok": False, + "error": "Enter an owner/name repo or a local checkout path.", + }, 400 + parts = repo.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + return {"ok": False, "error": "Repo must use owner/name format."}, 400 + if provider == "github": + remote_url = f"https://github.com/{repo}.git" + store.upsert_github_repo( + repo_full_name=repo, + default_branch=branch, + remote_url=remote_url, + local_path=path_value, + provider=provider, + ) + return {"ok": True, **_github_repos_payload(store)}, 200 + + +def _create_sdk_key_payload( + *, + store: Storage, + project_name: str = "Default", + environment_name: str = "production", + name: str = "Browser SDK", +) -> tuple[dict[str, Any], int]: + project = project_name.strip() or "Default" + environment = environment_name.strip() or "production" + key_name = name.strip() or "Browser SDK" + try: + workspace = store.ensure_workspace( + project_name=project, + environment_name=environment, + ) + created = create_sdk_key( + store, + project_id=workspace.project_id, + environment_id=workspace.environment_id, + name=key_name, + ) + except Exception as exc: + return {"ok": False, "error": str(exc)}, 400 + return ( + { + "ok": True, + "id": created.id, + "project_id": workspace.project_id, + "environment_id": workspace.environment_id, + "project": project, + "environment": environment, + "name": key_name, + "key": created.key, + "prefix": created.prefix, + "last4": created.last4, + "ingest_path": "/api/sdk/replay", + "ingest_url": "http://127.0.0.1:8788/api/sdk/replay", + "sentry_dsn": build_sentry_dsn( + public_key=created.key, + base_url="http://127.0.0.1:8788", + project_id=workspace.project_id, + ), + }, + 200, + ) diff --git a/src/retrace/commands/ui_templates.py b/src/retrace/commands/ui_templates.py new file mode 100644 index 0000000..e67bdc2 --- /dev/null +++ b/src/retrace/commands/ui_templates.py @@ -0,0 +1,1852 @@ + +_INDEX_HTML = """ + + + + + Retrace UI + + + + +
+ + +
+
+
+
+

Issue Detail

Replay-backed failures are the primary workflow surface.
+
+ + + +
+
+ +
+
+
Select a replay-backed issue.
+
+
+

Replays

Recent captured sessions and playback.
+
+
+
Select a first-party replay session.
+
+
+
+

QA Incidents

Unified queue across replay, UI test, API test, error monitor, and PR review.
+
+ + +
+
+
Loading…
+
+
+
+
+
+
Select a finding.
+
+
+
+ + + + +""" + + diff --git a/tests/test_ui_replay_specs.py b/tests/test_ui_replay_specs.py index ed7b1e6..b2f2a01 100644 --- a/tests/test_ui_replay_specs.py +++ b/tests/test_ui_replay_specs.py @@ -770,7 +770,7 @@ def test_create_sdk_key_payload_reports_creation_error( store.init_schema() with patch( - "retrace.commands.ui.create_sdk_key", + "retrace.commands.ui_payloads.create_sdk_key", side_effect=RuntimeError("database locked"), ): payload, status = _create_sdk_key_payload(store=store) @@ -1374,7 +1374,7 @@ def fake_run_spec(**_: object) -> RetraceTesterRunResult: execution_engine="native", ) - monkeypatch.setattr("retrace.commands.ui.run_spec", fake_run_spec) + monkeypatch.setattr("retrace.commands.ui_payloads.run_spec", fake_run_spec) payload, status = _verify_resolved_issues_payload( store=store, @@ -1431,7 +1431,7 @@ def fake_run_spec(**_: object) -> RetraceTesterRunResult: execution_engine="native", ) - monkeypatch.setattr("retrace.commands.ui.run_spec", fake_run_spec) + monkeypatch.setattr("retrace.commands.ui_payloads.run_spec", fake_run_spec) payload, status = _verify_resolved_issues_payload( store=store, @@ -1499,7 +1499,7 @@ def fake_run_api_spec(**_: object) -> APITestRunResult: run_dir=str(tmp_path / "api_run_passed"), ) - monkeypatch.setattr("retrace.commands.ui.run_api_spec", fake_run_api_spec) + monkeypatch.setattr("retrace.commands.ui_payloads.run_api_spec", fake_run_api_spec) payload, status = _verify_resolved_issues_payload( store=store,