From 0e3abb5e6348bcfbd1e355c7fba5e97b9da5071c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 9 Mar 2026 22:10:18 -0700 Subject: [PATCH] feat(cloud): add compose.cloud.yml and telemetry consent middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T8: compose.cloud.yml — multi-tenant cloud stack on port 8505, CLOUD_MODE=true, per-user encrypted data at /devl/menagerie-data, joins caddy-proxy_caddy-internal network; .env.example extended with five cloud-only env vars. T10: app/telemetry.py — log_usage_event() is the ONLY entry point to usage_events table; hard kill switch (all_disabled) checked before any DB write; complete no-op in local mode; swallows all exceptions so telemetry never crashes the app; psycopg2-binary added to requirements.txt. Event calls wired into 4_Apply.py at cover_letter_generated and job_applied. 5 tests, 413/413 total passing. --- .env.example | 7 ++++ app/pages/4_Apply.py | 5 +++ app/telemetry.py | 90 +++++++++++++++++++++++++++++++++++++++++ compose.cloud.yml | 55 +++++++++++++++++++++++++ requirements.txt | 3 ++ tests/test_telemetry.py | 85 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+) create mode 100644 app/telemetry.py create mode 100644 compose.cloud.yml create mode 100644 tests/test_telemetry.py diff --git a/.env.example b/.env.example index 8f7b8fd..1ce6672 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,10 @@ FORGEJO_REPO=pyr0ball/peregrine FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1 # GITHUB_TOKEN= # future — enable when public mirror is active # GITHUB_REPO= # future + +# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs) +CLOUD_MODE=false +CLOUD_DATA_ROOT=/devl/menagerie-data +DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value +CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32 +PLATFORM_DB_URL=postgresql://cf_platform:@host.docker.internal:5433/circuitforge_platform diff --git a/app/pages/4_Apply.py b/app/pages/4_Apply.py index bd84033..dd3c5b5 100644 --- a/app/pages/4_Apply.py +++ b/app/pages/4_Apply.py @@ -27,6 +27,7 @@ from scripts.db import ( ) from scripts.task_runner import submit_task from app.cloud_session import resolve_session, get_db_path +from app.telemetry import log_usage_event DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch" RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml" @@ -301,6 +302,8 @@ with col_tools: pdf_path = _make_cover_letter_pdf(job, cl_text, DOCS_DIR) update_cover_letter(get_db_path(), selected_id, cl_text) st.success(f"Saved: `{pdf_path.name}`") + if user_id := st.session_state.get("user_id"): + log_usage_event(user_id, "peregrine", "cover_letter_generated") except Exception as e: st.error(f"PDF error: {e}") @@ -317,6 +320,8 @@ with col_tools: update_cover_letter(get_db_path(), selected_id, cl_text) mark_applied(get_db_path(), [selected_id]) st.success("Marked as applied!") + if user_id := st.session_state.get("user_id"): + log_usage_event(user_id, "peregrine", "job_applied") st.rerun() if st.button("🚫 Reject listing", use_container_width=True): diff --git a/app/telemetry.py b/app/telemetry.py new file mode 100644 index 0000000..fb8a1f7 --- /dev/null +++ b/app/telemetry.py @@ -0,0 +1,90 @@ +# peregrine/app/telemetry.py +""" +Usage event telemetry for cloud-hosted Peregrine. + +In local-first mode (CLOUD_MODE unset/false), all functions are no-ops — +no network calls, no DB writes, no imports of psycopg2. + +In cloud mode, events are written to the platform Postgres DB ONLY after +confirming the user's telemetry consent. + +THE HARD RULE: if telemetry_consent.all_disabled is True for a user, +nothing is written, no exceptions. This function is the ONLY path to +usage_events — no feature may write there directly. +""" +import os +import json +from typing import Any + +CLOUD_MODE: bool = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes") +PLATFORM_DB_URL: str = os.environ.get("PLATFORM_DB_URL", "") + +_platform_conn = None + + +def get_platform_conn(): + """Lazy psycopg2 connection to the platform Postgres DB. Reconnects if closed.""" + global _platform_conn + if _platform_conn is None or _platform_conn.closed: + import psycopg2 + _platform_conn = psycopg2.connect(PLATFORM_DB_URL) + return _platform_conn + + +def get_consent(user_id: str) -> dict: + """ + Fetch telemetry consent for the user. + Returns safe defaults if record doesn't exist yet: + - usage_events_enabled: True (new cloud users start opted-in, per onboarding disclosure) + - all_disabled: False + """ + conn = get_platform_conn() + with conn.cursor() as cur: + cur.execute( + "SELECT all_disabled, usage_events_enabled " + "FROM telemetry_consent WHERE user_id = %s", + (user_id,) + ) + row = cur.fetchone() + if row is None: + return {"all_disabled": False, "usage_events_enabled": True} + return {"all_disabled": row[0], "usage_events_enabled": row[1]} + + +def log_usage_event( + user_id: str, + app: str, + event_type: str, + metadata: dict[str, Any] | None = None, +) -> None: + """ + Write a usage event to the platform DB if consent allows. + + Silent no-op in local mode. Silent no-op if telemetry is disabled. + Swallows all exceptions — telemetry must never crash the app. + + Args: + user_id: Directus user UUID (from st.session_state["user_id"]) + app: App slug ('peregrine', 'falcon', etc.) + event_type: Snake_case event label ('cover_letter_generated', 'job_applied', etc.) + metadata: Optional JSON-serialisable dict — NO PII + """ + if not CLOUD_MODE: + return + + try: + consent = get_consent(user_id) + if consent.get("all_disabled") or not consent.get("usage_events_enabled", True): + return + + conn = get_platform_conn() + with conn.cursor() as cur: + cur.execute( + "INSERT INTO usage_events (user_id, app, event_type, metadata) " + "VALUES (%s, %s, %s, %s)", + (user_id, app, event_type, json.dumps(metadata) if metadata else None), + ) + conn.commit() + except Exception: + # Telemetry must never crash the app + pass diff --git a/compose.cloud.yml b/compose.cloud.yml new file mode 100644 index 0000000..707441b --- /dev/null +++ b/compose.cloud.yml @@ -0,0 +1,55 @@ +# compose.cloud.yml — Multi-tenant cloud stack for menagerie.circuitforge.tech/peregrine +# +# Each authenticated user gets their own encrypted SQLite data tree at +# /devl/menagerie-data//peregrine/ +# +# Caddy injects the Directus session cookie as X-CF-Session header before forwarding. +# cloud_session.py resolves user_id → per-user db_path at session init. +# +# Usage: +# docker compose -f compose.cloud.yml --project-name peregrine-cloud up -d +# docker compose -f compose.cloud.yml --project-name peregrine-cloud down +# docker compose -f compose.cloud.yml --project-name peregrine-cloud logs app -f + +services: + app: + build: . + container_name: peregrine-cloud + ports: + - "8505:8501" + volumes: + - /devl/menagerie-data:/devl/menagerie-data # per-user data trees + environment: + - CLOUD_MODE=true + - CLOUD_DATA_ROOT=/devl/menagerie-data + - DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET} + - CF_SERVER_SECRET=${CF_SERVER_SECRET} + - PLATFORM_DB_URL=${PLATFORM_DB_URL} + - STAGING_DB=/devl/menagerie-data/cloud-default.db # fallback only — never used + - DOCS_DIR=/tmp/cloud-docs + - STREAMLIT_SERVER_BASE_URL_PATH=peregrine + - PYTHONUNBUFFERED=1 + - DEMO_MODE=false + depends_on: + searxng: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + searxng: + image: searxng/searxng:latest + volumes: + - ./docker/searxng:/etc/searxng:ro + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + # No host port — internal only + +networks: + default: + external: true + name: caddy-proxy_caddy-internal diff --git a/requirements.txt b/requirements.txt index b48998c..d3e9dad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -56,6 +56,9 @@ python-dotenv PyJWT>=2.8 pysqlcipher3 +# ── Cloud / telemetry ───────────────────────────────────────────────────────── +psycopg2-binary + # ── Utilities ───────────────────────────────────────────────────────────── sqlalchemy tqdm diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..ca4c338 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,85 @@ +import pytest +import os +from unittest.mock import patch, MagicMock, call + + +def test_no_op_in_local_mode(monkeypatch): + """log_usage_event() is completely silent when CLOUD_MODE is not set.""" + monkeypatch.delenv("CLOUD_MODE", raising=False) + import importlib + import app.telemetry as tel + importlib.reload(tel) + # Should not raise, should not touch anything + tel.log_usage_event("user-1", "peregrine", "any_event") + + +def test_event_not_logged_when_all_disabled(monkeypatch): + """No DB write when telemetry all_disabled is True.""" + monkeypatch.setenv("CLOUD_MODE", "true") + import importlib + import app.telemetry as tel + importlib.reload(tel) + + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(tel, "get_platform_conn", return_value=mock_conn), \ + patch.object(tel, "get_consent", return_value={"all_disabled": True, "usage_events_enabled": True}): + tel.log_usage_event("user-1", "peregrine", "cover_letter_generated") + + mock_cursor.execute.assert_not_called() + + +def test_event_not_logged_when_usage_events_disabled(monkeypatch): + """No DB write when usage_events_enabled is False.""" + monkeypatch.setenv("CLOUD_MODE", "true") + import importlib + import app.telemetry as tel + importlib.reload(tel) + + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(tel, "get_platform_conn", return_value=mock_conn), \ + patch.object(tel, "get_consent", return_value={"all_disabled": False, "usage_events_enabled": False}): + tel.log_usage_event("user-1", "peregrine", "cover_letter_generated") + + mock_cursor.execute.assert_not_called() + + +def test_event_logged_when_consent_given(monkeypatch): + """Usage event is written to usage_events table when consent is given.""" + monkeypatch.setenv("CLOUD_MODE", "true") + import importlib + import app.telemetry as tel + importlib.reload(tel) + + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(tel, "get_platform_conn", return_value=mock_conn), \ + patch.object(tel, "get_consent", return_value={"all_disabled": False, "usage_events_enabled": True}): + tel.log_usage_event("user-1", "peregrine", "cover_letter_generated", {"words": 350}) + + mock_cursor.execute.assert_called_once() + sql = mock_cursor.execute.call_args[0][0] + assert "usage_events" in sql + mock_conn.commit.assert_called_once() + + +def test_telemetry_never_crashes_app(monkeypatch): + """log_usage_event() swallows all exceptions — must never crash the app.""" + monkeypatch.setenv("CLOUD_MODE", "true") + import importlib + import app.telemetry as tel + importlib.reload(tel) + + with patch.object(tel, "get_platform_conn", side_effect=Exception("DB down")): + # Should not raise + tel.log_usage_event("user-1", "peregrine", "any_event")