feat: add structured JSON logging for all feature usage (Phase 2)
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s

- Create feature_logger.py with JSON logging infrastructure
- Add log_feature_usage() calls to all 9 routers after check_feature_access()
- Logs written to /app/logs/feature-usage.log
- Tracks all usage (not just violations) for future analysis
- Phase 2: Non-blocking monitoring complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-20 22:18:12 +01:00
parent ddcd2f4350
commit 1298bd235f
10 changed files with 115 additions and 1 deletions

76
backend/feature_logger.py Normal file
View File

@ -0,0 +1,76 @@
"""
Feature Usage Logger for Mitai Jinkendo
Logs all feature access checks to a separate JSON log file for analysis.
Phase 2: Non-blocking monitoring of feature usage.
"""
import logging
import json
from datetime import datetime
from pathlib import Path
# ── Setup Feature Usage Logger ───────────────────────────────────────────────
feature_usage_logger = logging.getLogger('feature_usage')
feature_usage_logger.setLevel(logging.INFO)
feature_usage_logger.propagate = False # Don't propagate to root logger
# Ensure logs directory exists
LOG_DIR = Path('/app/logs')
LOG_DIR.mkdir(parents=True, exist_ok=True)
# FileHandler for JSON logs
log_file = LOG_DIR / 'feature-usage.log'
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only
feature_usage_logger.addHandler(file_handler)
# Also log to console in dev (optional)
# console_handler = logging.StreamHandler()
# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s'))
# feature_usage_logger.addHandler(console_handler)
# ── Logging Function ──────────────────────────────────────────────────────────
def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str):
"""
Log feature usage in structured JSON format.
Args:
user_id: Profile UUID
feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls')
access: Result from check_feature_access() containing:
- allowed: bool
- limit: int | None
- used: int
- remaining: int | None
- reason: str
action: Type of action (e.g., 'create', 'export', 'analyze')
Example log entry:
{
"timestamp": "2026-03-20T15:30:45.123456",
"user_id": "abc-123",
"feature": "weight_entries",
"action": "create",
"used": 5,
"limit": 100,
"remaining": 95,
"allowed": true,
"reason": "within_limit"
}
"""
entry = {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"feature": feature_id,
"action": action,
"used": access.get('used', 0),
"limit": access.get('limit'), # None for unlimited
"remaining": access.get('remaining'), # None for unlimited
"allowed": access.get('allowed', True),
"reason": access.get('reason', 'unknown')
}
feature_usage_logger.info(json.dumps(entry))

View File

@ -15,6 +15,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry from models import ActivityEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/activity", tags=["activity"]) router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,6 +39,8 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'activity_entries') access = check_feature_access(pid, 'activity_entries')
log_feature_usage(pid, 'activity_entries', access, 'create')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -13,6 +13,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from models import CaliperEntry from models import CaliperEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/caliper", tags=["caliper"]) router = APIRouter(prefix="/api/caliper", tags=["caliper"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,6 +37,8 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'caliper_entries') access = check_feature_access(pid, 'caliper_entries')
log_feature_usage(pid, 'caliper_entries', access, 'create')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -13,6 +13,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from models import CircumferenceEntry from models import CircumferenceEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/circumferences", tags=["circumference"]) router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,6 +37,8 @@ def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(defaul
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'circumference_entries') access = check_feature_access(pid, 'circumference_entries')
log_feature_usage(pid, 'circumference_entries', access, 'create')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -20,6 +20,7 @@ from fastapi.responses import StreamingResponse, Response
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/export", tags=["export"]) router = APIRouter(prefix="/api/export", tags=["export"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +35,8 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_csv')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
@ -103,6 +106,8 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_json')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
@ -167,6 +172,8 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_export') access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_zip')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor from db import get_db, get_cursor
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/import", tags=["import"]) router = APIRouter(prefix="/api/import", tags=["import"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,6 +46,8 @@ async def import_zip(
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'data_import') access = check_feature_access(pid, 'data_import')
log_feature_usage(pid, 'data_import', access, 'import_zip')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api", tags=["insights"]) router = APIRouter(prefix="/api", tags=["insights"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -256,6 +257,8 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'ai_calls') access = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access, 'analyze')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
@ -329,6 +332,8 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
# Phase 2: Check pipeline feature access (boolean - enabled/disabled) # Phase 2: Check pipeline feature access (boolean - enabled/disabled)
access_pipeline = check_feature_access(pid, 'ai_pipeline') access_pipeline = check_feature_access(pid, 'ai_pipeline')
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
if not access_pipeline['allowed']: if not access_pipeline['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
@ -338,10 +343,12 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
# Also check ai_calls (pipeline uses API calls too) # Also check ai_calls (pipeline uses API calls too)
access_calls = check_feature_access(pid, 'ai_calls') access_calls = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls')
if not access_calls['allowed']: if not access_calls['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})" f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access['limit']})"
) )
# Old check (keep for now) # Old check (keep for now)

View File

@ -15,6 +15,7 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,6 +37,8 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
# Note: CSV import can create many entries - we check once before import # Note: CSV import can create many entries - we check once before import
access = check_feature_access(pid, 'nutrition_entries') access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -16,6 +16,7 @@ import aiofiles
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/photos", tags=["photos"]) router = APIRouter(prefix="/api/photos", tags=["photos"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,6 +33,8 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'photos') access = check_feature_access(pid, 'photos')
log_feature_usage(pid, 'photos', access, 'upload')
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "

View File

@ -13,6 +13,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage from auth import require_auth, check_feature_access, increment_feature_usage
from models import WeightEntry from models import WeightEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/weight", tags=["weight"]) router = APIRouter(prefix="/api/weight", tags=["weight"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,6 +37,11 @@ def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=Non
# Phase 2: Check feature access (non-blocking, log only) # Phase 2: Check feature access (non-blocking, log only)
access = check_feature_access(pid, 'weight_entries') access = check_feature_access(pid, 'weight_entries')
# Structured logging (always)
log_feature_usage(pid, 'weight_entries', access, 'create')
# Warning if limit exceeded (legacy)
if not access['allowed']: if not access['allowed']:
logger.warning( logger.warning(
f"[FEATURE-LIMIT] User {pid} would be blocked: " f"[FEATURE-LIMIT] User {pid} would be blocked: "