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:
pyr0ball 2026-03-09 22:10:18 -07:00
parent 5a1fceda84
commit 8f9955fa96
6 changed files with 245 additions and 0 deletions

View file

@ -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

View file

@ -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
View 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
View 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

View file

@ -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
View 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")