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
5a1fceda84
commit
8f9955fa96
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
|
||||
# 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:<password>@host.docker.internal:5433/circuitforge_platform
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
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
|
||||
pysqlcipher3
|
||||
|
||||
# ── Cloud / telemetry ─────────────────────────────────────────────────────────
|
||||
psycopg2-binary
|
||||
|
||||
# ── Utilities ─────────────────────────────────────────────────────────────
|
||||
sqlalchemy
|
||||
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