#!/usr/bin/env python3 """Privacy-safe operational telemetry primitives for Synderesis. Only aggregate counters with closed, content-free dimensions are persisted. The module deliberately has no fields for account, user, device, session, request, URL, prompt, answer, file, repository, citation, or exception data. """ from __future__ import annotations import json import re import sqlite3 from collections.abc import Iterable, Mapping from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any SCHEMA_VERSION = 1 CONSENT_VERSION = 2 RETENTION_DAYS = 30 MAX_BATCH_EVENTS = 20 MAX_EXPORT_ROWS = 5_000 SOURCES = frozenset({"client", "server"}) SURFACES = frozenset( { "account", "article", "chat", "health", "home", "other", "platform", "privacy", "resources", "share", "solutions", } ) EVENT_NAMES = frozenset( { "api_request", "client_fault", "control_activate", "control_change", "form_submit", "navigation", "network_outcome", "page_view", } ) OUTCOMES = frozenset( { "attempt", "cancelled", "client_error", "disabled", "enabled", "network_error", "observed", "rejected", "server_error", "success", } ) STATUS_BUCKETS = frozenset({"none", "2xx", "3xx", "4xx", "5xx", "network_error"}) DURATION_BUCKETS = frozenset( {"none", "lt_100ms", "100_499ms", "500_1999ms", "2_9s", "10s_plus"} ) ERROR_CODES = frozenset( { "none", "api_error", "http_error", "internal_error", "invalid_request", "js_error", "model_generation_failed", "network_error", "rate_limited", "unhandled_rejection", } ) # Client controls and server route groups share one bounded component dimension. # These names are developer-authored taxonomy, never copied from the DOM or a URL. COMPONENTS = frozenset( { "account_api_key_copy", "account_api_key_create", "account_api_key_revoke", "account_billing_checkout", "account_billing_portal", "account_delete_cancel", "account_delete_confirm", "account_delete_open", "account_extension_approve", "account_extension_cancel", "account_invite_claim", "account_login", "account_login_tab", "account_logout", "account_register", "account_register_tab", "account_waitlist_join", "account_workspace_open", "api_account", "api_admin", "api_auth", "api_billing", "api_browser_chat", "api_demo", "api_device", "api_health", "api_models", "api_other", "api_shares", "api_sources", "api_telemetry", "api_usage", "chat_attachment_add", "chat_attachment_remove", "chat_byok_delete", "chat_byok_enable", "chat_byok_remember", "chat_byok_save", "chat_citation_open", "chat_connections", "chat_dialog_close", "chat_download", "chat_download_format", "chat_download_selected", "chat_download_select_all", "chat_ghost", "chat_github_connect", "chat_github_context", "chat_github_disconnect", "chat_github_refresh", "chat_github_repository", "chat_history_delete", "chat_history_open", "chat_history_pin", "chat_history_rename", "chat_memory_add", "chat_memory_clear", "chat_memory_delete", "chat_memory_enable", "chat_memory_settings", "chat_model_tier", "chat_new", "chat_reading_list", "chat_reading_list_download", "chat_reading_list_format", "chat_reading_list_select_all", "chat_reasoning_effort", "chat_retry", "chat_share", "chat_share_copy", "chat_share_create", "chat_share_revoke", "chat_source_mode", "chat_sources", "chat_starter_prompt", "chat_stop", "chat_submit", "chat_theme", "chat_upload_rules", "chat_web_review", "chat_web_search", "client_runtime", "cookie_preferences", "navigation_external", "navigation_internal", "page", "privacy_preferences", "site_menu", "unclassified_control", } ) ROUTE_COMPONENTS = ( ("/health", "api_health"), ("/v1/account", "api_account"), ("/v1/auth", "api_auth"), ("/v1/billing", "api_billing"), ("/v1/browser/chat", "api_browser_chat"), ("/v1/demo", "api_demo"), ("/v1/device", "api_device"), ("/v1/models", "api_models"), ("/v1/shares", "api_shares"), ("/v1/sources", "api_sources"), ("/v1/telemetry", "api_telemetry"), ("/v1/usage", "api_usage"), ("/v1/admin", "api_admin"), ) SAFE_RELEASE_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$") MARKER_PREFIX = "SYNDERESIS_OPERATIONAL_EVENT " class TelemetryContractError(ValueError): """A telemetry event did not match the closed aggregate contract.""" def utc_hour(value: datetime | None = None) -> str: """Return the server-stamped UTC hour bucket.""" current = value or datetime.now(UTC) if current.tzinfo is None: current = current.replace(tzinfo=UTC) current = current.astimezone(UTC).replace(minute=0, second=0, microsecond=0) return current.strftime("%Y-%m-%dT%H:00:00Z") def release_label(deployment: Mapping[str, Any] | None, api_version: str) -> str: """Build a bounded server-owned release label.""" values = dict(deployment or {}) for key in ("git_commit", "git_sha", "sha", "commit_sha", "version"): raw = str(values.get(key, "") or "").strip().lower() safe = re.sub(r"[^a-z0-9]+", "_", raw).strip("_")[:48] if safe: candidate = f"release_{safe}"[:80] if SAFE_RELEASE_RE.fullmatch(candidate): return candidate safe_version = re.sub(r"[^a-z0-9]+", "_", api_version.lower()).strip("_") candidate = f"api_{safe_version}"[:80] if safe_version else "unknown" return candidate if SAFE_RELEASE_RE.fullmatch(candidate) else "unknown" def route_component(path: str) -> str | None: """Map a request path to a closed route group without returning the path.""" normalized = str(path or "") for prefix, component in ROUTE_COMPONENTS: if normalized == prefix or normalized.startswith(f"{prefix}/"): return component return "api_other" if normalized.startswith("/v1/") else None def surface_for_component(component: str) -> str: """Map a route/control component to a coarse surface.""" if component == "api_health": return "health" if component.startswith("api_account") or component.startswith("account_"): return "account" if component == "api_browser_chat" or component.startswith("chat_"): return "chat" return "other" def status_bucket(status_code: int | None) -> str: """Bucket an HTTP status without retaining its exact value.""" if status_code is None: return "network_error" if 200 <= status_code < 300: return "2xx" if 300 <= status_code < 400: return "3xx" if 400 <= status_code < 500: return "4xx" if 500 <= status_code < 600: return "5xx" return "none" def duration_bucket(duration_ms: float | int | None) -> str: """Bucket request duration into fixed low-cardinality bands.""" if duration_ms is None: return "none" value = max(0.0, float(duration_ms)) if value < 100: return "lt_100ms" if value < 500: return "100_499ms" if value < 2_000: return "500_1999ms" if value < 10_000: return "2_9s" return "10s_plus" def outcome_for_status(status_code: int | None) -> str: """Return a fixed outcome for an HTTP result.""" if status_code is None: return "network_error" if status_code < 400: return "success" if status_code < 500: return "client_error" return "server_error" def error_code_for_status(status_code: int | None, fixed_code: str = "") -> str: """Return only an allowlisted error family.""" if fixed_code in ERROR_CODES: return fixed_code if status_code is None: return "network_error" if status_code == 422: return "invalid_request" if status_code == 429: return "rate_limited" if status_code >= 500: return "internal_error" if status_code >= 400: return "http_error" return "none" def _closed(value: Any, allowed: frozenset[str], field: str) -> str: normalized = str(value or "") if normalized not in allowed: raise TelemetryContractError(f"invalid {field}") return normalized def normalize_event(event: Mapping[str, Any]) -> dict[str, str]: """Validate an aggregate event and return exactly the persisted dimensions.""" if not isinstance(event, Mapping): raise TelemetryContractError("event must be an object") expected = { "source", "surface", "event_name", "component", "outcome", "status_bucket", "duration_bucket", "error_code", "release", } if set(event) != expected: raise TelemetryContractError("event fields do not match the contract") release = str(event.get("release") or "") if not SAFE_RELEASE_RE.fullmatch(release): raise TelemetryContractError("invalid release") return { "source": _closed(event.get("source"), SOURCES, "source"), "surface": _closed(event.get("surface"), SURFACES, "surface"), "event_name": _closed(event.get("event_name"), EVENT_NAMES, "event_name"), "component": _closed(event.get("component"), COMPONENTS, "component"), "outcome": _closed(event.get("outcome"), OUTCOMES, "outcome"), "status_bucket": _closed( event.get("status_bucket"), STATUS_BUCKETS, "status_bucket" ), "duration_bucket": _closed( event.get("duration_bucket"), DURATION_BUCKETS, "duration_bucket" ), "error_code": _closed(event.get("error_code"), ERROR_CODES, "error_code"), "release": release, } def normalize_client_event( event: Mapping[str, Any], *, release: str, ) -> dict[str, str]: """Validate a client event; source and release are always server-owned.""" expected = { "surface", "event_name", "component", "outcome", "status_bucket", "duration_bucket", "error_code", } if not isinstance(event, Mapping) or set(event) != expected: raise TelemetryContractError("client event fields do not match the contract") return normalize_event({"source": "client", "release": release, **dict(event)}) def ensure_schema(connection: sqlite3.Connection) -> None: """Create the aggregate-only telemetry table.""" connection.executescript( """ CREATE TABLE IF NOT EXISTS operational_event_rollups ( bucket_start TEXT NOT NULL, schema_version INTEGER NOT NULL, source TEXT NOT NULL, surface TEXT NOT NULL, event_name TEXT NOT NULL, component TEXT NOT NULL, outcome TEXT NOT NULL, status_bucket TEXT NOT NULL, duration_bucket TEXT NOT NULL, error_code TEXT NOT NULL, release TEXT NOT NULL, event_count INTEGER NOT NULL CHECK (event_count > 0), updated_at TEXT NOT NULL, PRIMARY KEY ( bucket_start, schema_version, source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release ) ); CREATE INDEX IF NOT EXISTS idx_operational_event_rollups_bucket ON operational_event_rollups(bucket_start); """ ) def increment_rollup( connection: sqlite3.Connection, event: Mapping[str, Any], *, count: int = 1, now: datetime | None = None, ) -> None: """Atomically increment one validated aggregate counter.""" normalized = normalize_event(event) if ( isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= 1_000_000 ): raise TelemetryContractError("invalid count") bucket = utc_hour(now) updated_at = ( (now or datetime.now(UTC)).astimezone(UTC).isoformat(timespec="seconds") ) connection.execute( """ INSERT INTO operational_event_rollups ( bucket_start, schema_version, source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release, event_count, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT ( bucket_start, schema_version, source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release ) DO UPDATE SET event_count = operational_event_rollups.event_count + excluded.event_count, updated_at = excluded.updated_at """, ( bucket, SCHEMA_VERSION, normalized["source"], normalized["surface"], normalized["event_name"], normalized["component"], normalized["outcome"], normalized["status_bucket"], normalized["duration_bucket"], normalized["error_code"], normalized["release"], count, updated_at, ), ) def purge_expired( connection: sqlite3.Connection, *, now: datetime | None = None, ) -> int: """Delete aggregate buckets older than the fixed retention period.""" current = now or datetime.now(UTC) cutoff = utc_hour(current - timedelta(days=RETENTION_DAYS)) cursor = connection.execute( "DELETE FROM operational_event_rollups WHERE bucket_start < ?", (cutoff,), ) return max(0, int(cursor.rowcount or 0)) def record_events( db_path: Path, events: Iterable[Mapping[str, Any]], *, now: datetime | None = None, ) -> int: """Persist a bounded batch of already-normalized aggregate events.""" rows = list(events) if not 1 <= len(rows) <= MAX_BATCH_EVENTS: raise TelemetryContractError("invalid batch size") connection = sqlite3.connect(db_path, timeout=5) try: ensure_schema(connection) for event in rows: increment_rollup(connection, event, now=now) purge_expired(connection, now=now) connection.commit() finally: connection.close() return len(rows) def export_rollups( db_path: Path, *, hours: int = 24, limit: int = MAX_EXPORT_ROWS, now: datetime | None = None, ) -> dict[str, Any]: """Return a deterministic, identifier-free operational export.""" if isinstance(hours, bool) or not isinstance(hours, int) or not 1 <= hours <= 720: raise TelemetryContractError("invalid hours") if ( isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 20_000 ): raise TelemetryContractError("invalid limit") current = (now or datetime.now(UTC)).astimezone(UTC) # Include exactly ``hours`` hourly buckets: the current bucket plus the # preceding ``hours - 1``. Daily 24-hour exports therefore do not share a # boundary bucket merely because the comparison is inclusive. cutoff = utc_hour(current - timedelta(hours=hours - 1)) connection = sqlite3.connect(db_path, timeout=5) connection.row_factory = sqlite3.Row try: ensure_schema(connection) rows = connection.execute( """ SELECT source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release, SUM(event_count) AS event_count FROM operational_event_rollups WHERE bucket_start >= ? GROUP BY source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release ORDER BY source, surface, event_name, component, outcome, status_bucket, duration_bucket, error_code, release LIMIT ? """, (cutoff, limit + 1), ).fetchall() finally: connection.close() truncated = len(rows) > limit rows = rows[:limit] return { "object": "synderesis.operational_events.export", "schema_version": SCHEMA_VERSION, "generated_at": current.isoformat(timespec="seconds"), "window_hours": hours, "retention_days": RETENTION_DAYS, "truncated": truncated, "rollups": [ { "source": str(row["source"]), "surface": str(row["surface"]), "event_name": str(row["event_name"]), "component": str(row["component"]), "outcome": str(row["outcome"]), "status_bucket": str(row["status_bucket"]), "duration_bucket": str(row["duration_bucket"]), "error_code": str(row["error_code"]), "release": str(row["release"]), "event_count": int(row["event_count"]), } for row in rows ], } def marker(event: Mapping[str, Any], *, count: int = 1) -> str: """Serialize a single safe aggregate marker for log fallback discovery.""" normalized = normalize_event(event) payload = { "object": "synderesis.operational_event", "schema_version": SCHEMA_VERSION, **normalized, "event_count": count, } return MARKER_PREFIX + json.dumps(payload, sort_keys=True, separators=(",", ":"))