feat(cloud): add compose.cloud.yml and telemetry consent middleware
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.
This commit is contained in:
parent
357891d335
commit
3b9bd5f551
6 changed files with 245 additions and 0 deletions
|
|
@ -27,3 +27,10 @@ FORGEJO_REPO=pyr0ball/peregrine
|
||||||
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
# GITHUB_TOKEN= # future — enable when public mirror is active
|
# GITHUB_TOKEN= # future — enable when public mirror is active
|
||||||
# GITHUB_REPO= # future
|
# 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:<password>@host.docker.internal:5433/circuitforge_platform
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from scripts.db import (
|
||||||
)
|
)
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
from app.cloud_session import resolve_session, get_db_path
|
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"
|
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
|
||||||
RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml"
|
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)
|
pdf_path = _make_cover_letter_pdf(job, cl_text, DOCS_DIR)
|
||||||
update_cover_letter(get_db_path(), selected_id, cl_text)
|
update_cover_letter(get_db_path(), selected_id, cl_text)
|
||||||
st.success(f"Saved: `{pdf_path.name}`")
|
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:
|
except Exception as e:
|
||||||
st.error(f"PDF error: {e}")
|
st.error(f"PDF error: {e}")
|
||||||
|
|
||||||
|
|
@ -317,6 +320,8 @@ with col_tools:
|
||||||
update_cover_letter(get_db_path(), selected_id, cl_text)
|
update_cover_letter(get_db_path(), selected_id, cl_text)
|
||||||
mark_applied(get_db_path(), [selected_id])
|
mark_applied(get_db_path(), [selected_id])
|
||||||
st.success("Marked as applied!")
|
st.success("Marked as applied!")
|
||||||
|
if user_id := st.session_state.get("user_id"):
|
||||||
|
log_usage_event(user_id, "peregrine", "job_applied")
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if st.button("🚫 Reject listing", use_container_width=True):
|
if st.button("🚫 Reject listing", use_container_width=True):
|
||||||
|
|
|
||||||
90
app/telemetry.py
Normal file
90
app/telemetry.py
Normal file
|
|
@ -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
|
||||||
55
compose.cloud.yml
Normal file
55
compose.cloud.yml
Normal file
|
|
@ -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/<user-id>/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
|
||||||
|
|
@ -56,6 +56,9 @@ python-dotenv
|
||||||
PyJWT>=2.8
|
PyJWT>=2.8
|
||||||
pysqlcipher3
|
pysqlcipher3
|
||||||
|
|
||||||
|
# ── Cloud / telemetry ─────────────────────────────────────────────────────────
|
||||||
|
psycopg2-binary
|
||||||
|
|
||||||
# ── Utilities ─────────────────────────────────────────────────────────────
|
# ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
tqdm
|
tqdm
|
||||||
|
|
|
||||||
85
tests/test_telemetry.py
Normal file
85
tests/test_telemetry.py
Normal file
|
|
@ -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")
|
||||||
Loading…
Reference in a new issue