Files
StarPunk/starpunk/config.py
Phil Skentelbery b0230b1233 feat: Complete v1.1.2 Phase 1 - Metrics Instrumentation
Implements the metrics instrumentation framework that was missing from v1.1.1.
The monitoring framework existed but was never actually used to collect metrics.

Phase 1 Deliverables:
- Database operation monitoring with query timing and slow query detection
- HTTP request/response metrics with request IDs for all requests
- Memory monitoring via daemon thread with configurable intervals
- Business metrics framework for notes, feeds, and cache operations
- Configuration management with environment variable support

Implementation Details:
- MonitoredConnection wrapper at pool level for transparent DB monitoring
- Flask middleware hooks for HTTP metrics collection
- Background daemon thread for memory statistics (skipped in test mode)
- Simple business metric helpers for integration in Phase 2
- Comprehensive test suite with 28/28 tests passing

Quality Metrics:
- 100% test pass rate (28/28 tests)
- Zero architectural deviations from specifications
- <1% performance overhead achieved
- Production-ready with minimal memory impact (~2MB)

Architect Review: APPROVED with excellent marks

Documentation:
- Implementation report: docs/reports/v1.1.2-phase1-metrics-implementation.md
- Architect review: docs/reviews/2025-11-26-v1.1.2-phase1-review.md
- Updated CHANGELOG.md with Phase 1 additions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:13:44 -07:00

227 lines
8.6 KiB
Python

"""
Configuration management for StarPunk
Loads settings from environment variables and .env file
"""
import os
from pathlib import Path
from dotenv import load_dotenv
def load_config(app, config_override=None):
"""
Load configuration into Flask app
Args:
app: Flask application instance
config_override: Optional dict to override config values
"""
# Load .env file
load_dotenv()
# Site configuration
# IndieWeb/OAuth specs require trailing slash for root URLs used as client_id
# See: https://indielogin.com/ OAuth client requirements
site_url = os.getenv("SITE_URL", "http://localhost:5000")
app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/'
app.config["SITE_NAME"] = os.getenv("SITE_NAME", "StarPunk")
app.config["SITE_AUTHOR"] = os.getenv("SITE_AUTHOR", "Unknown")
app.config["SITE_DESCRIPTION"] = os.getenv(
"SITE_DESCRIPTION", "A minimal IndieWeb CMS"
)
# Authentication
app.config["ADMIN_ME"] = os.getenv("ADMIN_ME")
app.config["SESSION_SECRET"] = os.getenv("SESSION_SECRET")
app.config["SESSION_LIFETIME"] = int(os.getenv("SESSION_LIFETIME", "30"))
app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com")
# DEPRECATED: TOKEN_ENDPOINT no longer used (v1.0.0-rc.5+)
# Endpoints are now discovered from ADMIN_ME profile (ADR-031)
if 'TOKEN_ENDPOINT' in os.environ:
app.logger.warning(
"TOKEN_ENDPOINT is deprecated and will be ignored. "
"Remove it from your configuration. "
"Endpoints are now discovered automatically from your ADMIN_ME profile. "
"See docs/migration/fix-hardcoded-endpoints.md for details."
)
# Validate required configuration
if not app.config["SESSION_SECRET"]:
raise ValueError(
"SESSION_SECRET must be set in .env file. "
'Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"'
)
# Flask secret key (uses SESSION_SECRET by default)
# Note: We check for truthy value to handle empty string in .env
flask_secret = os.getenv("FLASK_SECRET_KEY")
app.config["SECRET_KEY"] = flask_secret if flask_secret else app.config["SESSION_SECRET"]
# Data paths
app.config["DATA_PATH"] = Path(os.getenv("DATA_PATH", "./data"))
app.config["NOTES_PATH"] = Path(os.getenv("NOTES_PATH", "./data/notes"))
app.config["DATABASE_PATH"] = Path(os.getenv("DATABASE_PATH", "./data/starpunk.db"))
# Flask environment
app.config["ENV"] = os.getenv("FLASK_ENV", "development")
app.config["DEBUG"] = os.getenv("FLASK_DEBUG", "1") == "1"
# Logging
app.config["LOG_LEVEL"] = os.getenv("LOG_LEVEL", "INFO")
# Development mode configuration
app.config["DEV_MODE"] = os.getenv("DEV_MODE", "false").lower() == "true"
app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "")
# Application version (use __version__ from package)
from starpunk import __version__
app.config["VERSION"] = os.getenv("VERSION", __version__)
# RSS feed configuration
app.config["FEED_MAX_ITEMS"] = int(os.getenv("FEED_MAX_ITEMS", "50"))
app.config["FEED_CACHE_SECONDS"] = int(os.getenv("FEED_CACHE_SECONDS", "300"))
# Metrics configuration (v1.1.2 Phase 1)
app.config["METRICS_ENABLED"] = os.getenv("METRICS_ENABLED", "true").lower() == "true"
app.config["METRICS_SLOW_QUERY_THRESHOLD"] = float(os.getenv("METRICS_SLOW_QUERY_THRESHOLD", "1.0"))
app.config["METRICS_SAMPLING_RATE"] = float(os.getenv("METRICS_SAMPLING_RATE", "1.0"))
app.config["METRICS_BUFFER_SIZE"] = int(os.getenv("METRICS_BUFFER_SIZE", "1000"))
app.config["METRICS_MEMORY_INTERVAL"] = int(os.getenv("METRICS_MEMORY_INTERVAL", "30"))
# Apply overrides if provided
if config_override:
app.config.update(config_override)
# Normalize SITE_URL trailing slash (in case override provided URL without slash)
if "SITE_URL" in app.config:
site_url = app.config["SITE_URL"]
app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/'
# Convert path strings to Path objects (in case overrides provided strings)
if isinstance(app.config["DATA_PATH"], str):
app.config["DATA_PATH"] = Path(app.config["DATA_PATH"])
if isinstance(app.config["NOTES_PATH"], str):
app.config["NOTES_PATH"] = Path(app.config["NOTES_PATH"])
if isinstance(app.config["DATABASE_PATH"], str):
app.config["DATABASE_PATH"] = Path(app.config["DATABASE_PATH"])
# Validate configuration
validate_config(app)
# Ensure data directories exist
app.config["DATA_PATH"].mkdir(parents=True, exist_ok=True)
app.config["NOTES_PATH"].mkdir(parents=True, exist_ok=True)
def validate_config(app):
"""
Validate application configuration on startup
Per ADR-052 and developer Q&A Q14:
- Validates at startup (fail fast)
- Checks both presence and type of required values
- Provides clear error messages
- Exits with non-zero status on failure
Ensures required configuration is present based on mode (dev/production)
and warns prominently if development mode is enabled.
Args:
app: Flask application instance
Raises:
ValueError: If required configuration is missing or invalid
"""
errors = []
# Validate required string fields
required_strings = {
'SITE_URL': app.config.get('SITE_URL'),
'SITE_NAME': app.config.get('SITE_NAME'),
'SITE_AUTHOR': app.config.get('SITE_AUTHOR'),
'SESSION_SECRET': app.config.get('SESSION_SECRET'),
'SECRET_KEY': app.config.get('SECRET_KEY'),
}
for field, value in required_strings.items():
if not value:
errors.append(f"{field} is required but not set")
elif not isinstance(value, str):
errors.append(f"{field} must be a string, got {type(value).__name__}")
# Validate required integer fields
required_ints = {
'SESSION_LIFETIME': app.config.get('SESSION_LIFETIME'),
'FEED_MAX_ITEMS': app.config.get('FEED_MAX_ITEMS'),
'FEED_CACHE_SECONDS': app.config.get('FEED_CACHE_SECONDS'),
}
for field, value in required_ints.items():
if value is None:
errors.append(f"{field} is required but not set")
elif not isinstance(value, int):
errors.append(f"{field} must be an integer, got {type(value).__name__}")
elif value < 0:
errors.append(f"{field} must be non-negative, got {value}")
# Validate required Path fields
required_paths = {
'DATA_PATH': app.config.get('DATA_PATH'),
'NOTES_PATH': app.config.get('NOTES_PATH'),
'DATABASE_PATH': app.config.get('DATABASE_PATH'),
}
for field, value in required_paths.items():
if not value:
errors.append(f"{field} is required but not set")
elif not isinstance(value, Path):
errors.append(f"{field} must be a Path object, got {type(value).__name__}")
# Validate LOG_LEVEL
log_level = app.config.get('LOG_LEVEL', 'INFO').upper()
valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
if log_level not in valid_log_levels:
errors.append(f"LOG_LEVEL must be one of {valid_log_levels}, got '{log_level}'")
# Mode-specific validation
dev_mode = app.config.get("DEV_MODE", False)
if dev_mode:
# Prominently warn about development mode
app.logger.warning(
"=" * 60 + "\n"
"WARNING: Development authentication enabled!\n"
"This should NEVER be used in production.\n"
"Set DEV_MODE=false for production deployments.\n" + "=" * 60
)
# Require DEV_ADMIN_ME in dev mode
if not app.config.get("DEV_ADMIN_ME"):
errors.append(
"DEV_MODE=true requires DEV_ADMIN_ME to be set. "
"Set DEV_ADMIN_ME=https://your-dev-identity.example.com in .env"
)
else:
# Production mode: ADMIN_ME is required
if not app.config.get("ADMIN_ME"):
errors.append(
"Production mode requires ADMIN_ME to be set. "
"Set ADMIN_ME=https://your-site.com in .env"
)
# If there are validation errors, fail fast with clear message
if errors:
error_msg = "\n".join([
"=" * 70,
"CONFIGURATION VALIDATION FAILED",
"=" * 70,
"The following configuration errors were found:",
"",
*[f" - {error}" for error in errors],
"",
"Please fix these errors in your .env file and restart.",
"=" * 70
])
raise ValueError(error_msg)