From c1a36db926bcf7f277524f37c2c92ac48941399b Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Wed, 7 Jan 2026 17:35:59 -0500 Subject: [PATCH] feature: add logging --- .../config/RequestLoggingFilter.java | 105 +++++++++++ .../src/main/resources/application.properties | 13 +- mflix/server/js-express/.env.example | 4 + mflix/server/js-express/package.json | 3 +- mflix/server/js-express/src/app.ts | 28 ++- .../server/js-express/src/config/database.ts | 46 ++++- .../src/controllers/movieController.ts | 3 +- .../src/middleware/requestLogger.ts | 50 +++++ .../js-express/src/utils/errorHandler.ts | 19 +- mflix/server/js-express/src/utils/logger.ts | 171 ++++++++++++++++++ mflix/server/python-fastapi/.env.example | 6 + mflix/server/python-fastapi/main.py | 21 ++- .../python-fastapi/src/middleware/__init__.py | 6 + .../src/middleware/request_logging.py | 97 ++++++++++ .../server/python-fastapi/src/utils/logger.py | 155 ++++++++++++++++ 15 files changed, 687 insertions(+), 40 deletions(-) create mode 100644 mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java create mode 100644 mflix/server/js-express/src/middleware/requestLogger.ts create mode 100644 mflix/server/js-express/src/utils/logger.ts create mode 100644 mflix/server/python-fastapi/src/middleware/__init__.py create mode 100644 mflix/server/python-fastapi/src/middleware/request_logging.py create mode 100644 mflix/server/python-fastapi/src/utils/logger.py diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java new file mode 100644 index 0000000..6097939 --- /dev/null +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/config/RequestLoggingFilter.java @@ -0,0 +1,105 @@ +package com.mongodb.samplemflix.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * HTTP Request Logging Filter + * + *

This filter logs all incoming HTTP requests with useful information + * including method, URL, status code, and response time. + * It helps with debugging and monitoring application traffic. + * + *

Log output format: + *

+ * INFO  - GET /api/movies 200 - 45ms
+ * WARN  - GET /api/movies/invalid 400 - 2ms
+ * ERROR - POST /api/movies 500 - 120ms
+ * 
+ * + *

The filter is ordered to run first in the filter chain to ensure + * accurate timing measurements. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Record the start time + long startTime = System.currentTimeMillis(); + + // Log incoming request at debug level + logger.debug("Incoming request: {} {} from {}", + request.getMethod(), + request.getRequestURI(), + request.getRemoteAddr()); + + try { + // Continue with the filter chain + filterChain.doFilter(request, response); + } finally { + // Calculate response time + long responseTime = System.currentTimeMillis() - startTime; + + // Log the completed request with appropriate level based on status code + logRequest(request.getMethod(), request.getRequestURI(), response.getStatus(), responseTime); + } + } + + /** + * Logs the HTTP request with appropriate log level based on status code. + * + *

Log levels: + *

+ * + * @param method HTTP method (GET, POST, etc.) + * @param uri Request URI + * @param statusCode HTTP response status code + * @param responseTime Response time in milliseconds + */ + private void logRequest(String method, String uri, int statusCode, long responseTime) { + String message = String.format("%s %s %d - %dms", method, uri, statusCode, responseTime); + + if (statusCode >= 500) { + logger.error(message); + } else if (statusCode >= 400) { + logger.warn(message); + } else { + logger.info(message); + } + } + + /** + * Skip logging for static resources and health checks to reduce noise. + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/swagger-ui") + || path.startsWith("/api-docs") + || path.startsWith("/v3/api-docs") + || path.equals("/favicon.ico") + || path.startsWith("/actuator"); + } +} + diff --git a/mflix/server/java-spring/src/main/resources/application.properties b/mflix/server/java-spring/src/main/resources/application.properties index 58b8785..ffe611f 100644 --- a/mflix/server/java-spring/src/main/resources/application.properties +++ b/mflix/server/java-spring/src/main/resources/application.properties @@ -19,11 +19,22 @@ voyage.api.key=${VOYAGE_API_KEY:} spring.application.name=sample-app-java-mflix # Logging Configuration -logging.level.com.mongodb.samplemflix=INFO +# Log level can be overridden with LOG_LEVEL environment variable +# Available levels: TRACE, DEBUG, INFO, WARN, ERROR +logging.level.com.mongodb.samplemflix=${LOG_LEVEL:INFO} logging.level.org.mongodb.driver=WARN # Suppress connection pool maintenance warnings (these are usually harmless) logging.level.org.mongodb.driver.connection=ERROR +# Console logging pattern with colors and timestamps +logging.pattern.console=%clr(%d{HH:mm:ss}){faint} %clr(%5p){highlight} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n + +# File logging (optional - enabled when LOG_FILE is set) +logging.file.name=${LOG_FILE:} +logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %5p --- [%15.15t] %-40.40logger{39} : %m%n +logging.logback.rollingpolicy.max-file-size=5MB +logging.logback.rollingpolicy.max-history=5 + # Jackson Configuration (JSON serialization) spring.jackson.default-property-inclusion=non_null spring.jackson.serialization.write-dates-as-timestamps=false diff --git a/mflix/server/js-express/.env.example b/mflix/server/js-express/.env.example index 725cc0d..c870ee0 100644 --- a/mflix/server/js-express/.env.example +++ b/mflix/server/js-express/.env.example @@ -10,6 +10,10 @@ VOYAGE_API_KEY=your_voyage_api_key PORT=3001 NODE_ENV=development +# Logging Configuration +# Available levels: error, warn, info, http, debug +# Default: debug (development), info (production), error (test) +LOG_LEVEL=debug # CORS Configuration # Allowed origin for cross-origin requests (frontend URL) diff --git a/mflix/server/js-express/package.json b/mflix/server/js-express/package.json index 01311ba..85f09a2 100644 --- a/mflix/server/js-express/package.json +++ b/mflix/server/js-express/package.json @@ -24,7 +24,8 @@ "express": "^5.1.0", "mongodb": "^7.0.0", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "winston": "^3.19.0" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/mflix/server/js-express/src/app.ts b/mflix/server/js-express/src/app.ts index 9c5a7e8..6e3d389 100644 --- a/mflix/server/js-express/src/app.ts +++ b/mflix/server/js-express/src/app.ts @@ -18,6 +18,8 @@ import { import { errorHandler } from "./utils/errorHandler"; import moviesRouter from "./routes/movies"; import { swaggerSpec } from "./config/swagger"; +import logger from "./utils/logger"; +import { requestLogger } from "./middleware/requestLogger"; // Load environment variables from .env file // This must be called before any other imports that use environment variables @@ -46,6 +48,12 @@ app.use( app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); +/** + * Request Logging Middleware + * Logs all incoming HTTP requests with method, URL, status code, and response time + */ +app.use(requestLogger); + /** * Swagger API Documentation * Provides interactive API documentation at /api-docs @@ -119,25 +127,25 @@ app.use(errorHandler); */ async function startServer() { try { - console.log("Starting MongoDB Sample MFlix API..."); + logger.info("Starting MongoDB Sample MFlix API..."); // Connect to MongoDB database - console.log("Connecting to MongoDB..."); + logger.info("Connecting to MongoDB..."); await connectToDatabase(); - console.log("Connected to MongoDB successfully"); + logger.info("Connected to MongoDB successfully"); // Verify that all required indexes and sample data exist - console.log("Verifying requirements (indexes and sample data)..."); + logger.info("Verifying requirements (indexes and sample data)..."); await verifyRequirements(); - console.log("All requirements verified successfully"); + logger.info("All requirements verified successfully"); // Start the Express server app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - console.log(`API documentation available at http://localhost:${PORT}/api-docs`); + logger.info(`Server running on port ${PORT}`); + logger.info(`API documentation available at http://localhost:${PORT}/api-docs`); }); } catch (error) { - console.error("Failed to start server:", error); + logger.error("Failed to start server:", error); // Exit the process if we can't start properly // This ensures the application doesn't run in a broken state @@ -150,13 +158,13 @@ async function startServer() { * Ensures the application shuts down cleanly when terminated */ process.on("SIGINT", () => { - console.log("\nReceived SIGINT. Shutting down..."); + logger.info("Received SIGINT. Shutting down gracefully..."); closeDatabaseConnection(); process.exit(0); }); process.on("SIGTERM", () => { - console.log("\nReceived SIGTERM. Shutting down..."); + logger.info("Received SIGTERM. Shutting down gracefully..."); closeDatabaseConnection(); process.exit(0); }); diff --git a/mflix/server/js-express/src/config/database.ts b/mflix/server/js-express/src/config/database.ts index 09c5884..62d5a7e 100644 --- a/mflix/server/js-express/src/config/database.ts +++ b/mflix/server/js-express/src/config/database.ts @@ -7,6 +7,7 @@ */ import { MongoClient, Db, Collection, Document } from "mongodb"; +import logger from "../utils/logger"; let client: MongoClient; let database: Db; @@ -40,7 +41,7 @@ async function _connectToDatabase(): Promise { // Get reference to the sample_mflix database database = client.db("sample_mflix"); - console.log(`Connected to database: ${database.databaseName}`); + logger.debug(`Connected to database: ${database.databaseName}`); return database; } catch (error) { @@ -85,7 +86,7 @@ export function getCollection( export async function closeDatabaseConnection(): Promise { if (client) { await client.close(); - console.log("Database connection closed"); + logger.info("Database connection closed"); } } @@ -100,9 +101,9 @@ export async function verifyRequirements(): Promise { // Check if the movies collection exists and has data await verifyMoviesCollection(db); - console.log("All database requirements verified successfully"); + logger.debug("All database requirements verified successfully"); } catch (error) { - console.error("Requirements verification failed:", error); + logger.error("Requirements verification failed:", error); throw error; } } @@ -117,22 +118,51 @@ async function verifyMoviesCollection(db: Db): Promise { const movieCount = await moviesCollection.estimatedDocumentCount(); if (movieCount === 0) { - console.warn( + logger.warn( "Movies collection is empty. Please ensure sample_mflix data is loaded." ); } // Create text search index on plot field for full-text search + await createTextSearchIndex(moviesCollection); +} + +/** + * Creates a text search index on the movies collection if it doesn't already exist. + * + * MongoDB only allows one text index per collection, so we check for any existing + * text index before attempting to create one. + */ +async function createTextSearchIndex(moviesCollection: Collection): Promise { + const TEXT_INDEX_NAME = "text_search_index"; + try { + // Check if any text index already exists + const existingIndexes = await moviesCollection.listIndexes().toArray(); + const textIndexExists = existingIndexes.some( + (index) => index.key && index.key._fts === "text" + ); + + if (textIndexExists) { + const existingTextIndex = existingIndexes.find( + (index) => index.key && index.key._fts === "text" + ); + logger.debug(`Text search index '${existingTextIndex?.name}' already exists on movies collection`); + return; + } + + // Create the text index await moviesCollection.createIndex( { plot: "text", title: "text", fullplot: "text" }, { - name: "text_search_index", + name: TEXT_INDEX_NAME, background: true, } ); - console.log("Text search index created for movies collection"); + logger.info(`Text search index '${TEXT_INDEX_NAME}' created successfully for movies collection`); } catch (error) { - console.error("Could not create text search index:", error); + // Log as warning, not error - the application can still function without the index + logger.warn("Could not create text search index:", error); + logger.warn("Text search functionality may not work without the index"); } } diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 0d107e5..8861892 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -24,6 +24,7 @@ import { createSuccessResponse, validateRequiredFields, } from "../utils/errorHandler"; +import logger from "../utils/logger"; import { CreateMovieRequest, UpdateMovieRequest, @@ -874,7 +875,7 @@ export async function vectorSearchMovies(req: Request, res: Response): Promise 0 ? req.query : undefined, + ip: req.ip, + }); + + // Log when response finishes + res.on("finish", () => { + const responseTime = Date.now() - startTime; + logHttpRequest(req.method, req.url, res.statusCode, responseTime); + }); + + next(); +} diff --git a/mflix/server/js-express/src/utils/errorHandler.ts b/mflix/server/js-express/src/utils/errorHandler.ts index 35d79a6..865f906 100644 --- a/mflix/server/js-express/src/utils/errorHandler.ts +++ b/mflix/server/js-express/src/utils/errorHandler.ts @@ -8,6 +8,7 @@ import { Request, Response, NextFunction } from "express"; import { MongoError } from "mongodb"; import { SuccessResponse, ErrorResponse } from "../types"; +import logger from "./logger"; /** * Custom ValidationError class for field validation errors @@ -37,17 +38,13 @@ export function errorHandler( next: NextFunction ): void { // Log the error for debugging purposes - // In production, we recommend using a logging service - // Suppress error logging during tests to keep test output clean - if (process.env.NODE_ENV !== "test") { - console.error("Error occurred:", { - message: err.message, - stack: err.stack, - url: req.url, - method: req.method, - timestamp: new Date().toISOString(), - }); - } + // The logger automatically handles environment-specific behavior + logger.error("Error occurred:", { + message: err.message, + stack: err.stack, + url: req.url, + method: req.method, + }); // Determine the appropriate HTTP status code and error message const errorDetails = parseErrorDetails(err); diff --git a/mflix/server/js-express/src/utils/logger.ts b/mflix/server/js-express/src/utils/logger.ts new file mode 100644 index 0000000..60474e1 --- /dev/null +++ b/mflix/server/js-express/src/utils/logger.ts @@ -0,0 +1,171 @@ +/** + * Logger Utility + * + * This module provides a centralized logging solution using Winston. + * It supports multiple log levels, console and file transports, and + * environment-aware formatting for better developer and user experience. + * + * Log Levels (from highest to lowest priority): + * - error: Error events that might still allow the application to continue + * - warn: Potentially harmful situations + * - info: Informational messages highlighting application progress + * - http: HTTP request logging + * - debug: Detailed debug information + */ + +import winston from "winston"; +import path from "path"; + +// Define log levels with custom colors +const levels = { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, +}; + +// Define colors for each log level +const colors = { + error: "red", + warn: "yellow", + info: "green", + http: "magenta", + debug: "cyan", +}; + +// Add colors to Winston +winston.addColors(colors); + +/** + * Determine the log level based on environment + * - In development: show all logs (debug level) + * - In production: show info and above + * - In test: show only errors (or suppress entirely) + */ +function getLogLevel(): string { + const env = process.env.NODE_ENV || "development"; + const envLogLevel = process.env.LOG_LEVEL; + + // Allow explicit override via LOG_LEVEL env var + if (envLogLevel) { + return envLogLevel; + } + + // Default levels based on environment + switch (env) { + case "production": + return "info"; + case "test": + return "error"; + default: + return "debug"; + } +} + +/** + * Console format for development - colorized and readable + */ +const devConsoleFormat = winston.format.combine( + winston.format.timestamp({ format: "HH:mm:ss" }), + winston.format.colorize({ all: true }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ""; + return `[${timestamp}] ${level}: ${message}${metaStr}`; + }) +); + +/** + * Console format for production - structured JSON + */ +const prodConsoleFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.json() +); + +/** + * File format - JSON for easy parsing + */ +const fileFormat = winston.format.combine( + winston.format.timestamp(), + winston.format.uncolorize(), + winston.format.json() +); + +/** + * Create transports based on environment + */ +function createTransports(): winston.transport[] { + const env = process.env.NODE_ENV || "development"; + const transports: winston.transport[] = []; + + // Console transport - always enabled except in test + if (env !== "test") { + transports.push( + new winston.transports.Console({ + format: env === "production" ? prodConsoleFormat : devConsoleFormat, + }) + ); + } + + // File transports - enabled in production and development (not test) + if (env !== "test") { + const logsDir = path.join(process.cwd(), "logs"); + + // Error log - only errors + transports.push( + new winston.transports.File({ + filename: path.join(logsDir, "error.log"), + level: "error", + format: fileFormat, + maxsize: 5242880, // 5MB + maxFiles: 5, + }) + ); + + // Combined log - all logs + transports.push( + new winston.transports.File({ + filename: path.join(logsDir, "combined.log"), + format: fileFormat, + maxsize: 5242880, // 5MB + maxFiles: 5, + }) + ); + } + + return transports; +} + +/** + * Create the Winston logger instance + */ +const logger = winston.createLogger({ + level: getLogLevel(), + levels, + transports: createTransports(), + // Don't exit on handled exceptions + exitOnError: false, +}); + +/** + * Log an HTTP request + * @param method - HTTP method (GET, POST, etc.) + * @param url - Request URL + * @param statusCode - Response status code + * @param responseTime - Response time in milliseconds + */ +export function logHttpRequest( + method: string, + url: string, + statusCode: number, + responseTime: number +): void { + const statusColor = + statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "http"; + + logger.log(statusColor, `${method} ${url} ${statusCode} - ${responseTime}ms`); +} + +export default logger; + diff --git a/mflix/server/python-fastapi/.env.example b/mflix/server/python-fastapi/.env.example index d42141d..6dc1d0d 100644 --- a/mflix/server/python-fastapi/.env.example +++ b/mflix/server/python-fastapi/.env.example @@ -10,3 +10,9 @@ VOYAGE_API_KEY=your_voyage_api_key # CORS Configuration # Comma-separated list of allowed origins for CORS CORS_ORIGINS="http://localhost:3000,http://localhost:3001" + +# Logging Configuration +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) +LOG_LEVEL=INFO +# Optional: Path to log file (if not set, logs only to console) +# LOG_FILE=app.log diff --git a/mflix/server/python-fastapi/main.py b/mflix/server/python-fastapi/main.py index 99913ae..81048f9 100644 --- a/mflix/server/python-fastapi/main.py +++ b/mflix/server/python-fastapi/main.py @@ -6,6 +6,8 @@ from src.database.mongo_client import db, get_collection from src.utils.exceptions import VoyageAuthError, VoyageAPIError from src.utils.errorResponse import create_error_response +from src.utils.logger import logger +from src.middleware.request_logging import RequestLoggingMiddleware import os from dotenv import load_dotenv @@ -21,12 +23,12 @@ async def lifespan(app: FastAPI): await ensure_vector_search_index() await ensure_standard_index() - # Print server information - print(f"\n{'='*60}") - print(f" Server started at http://127.0.0.1:3001") - print(f" Documentation at http://127.0.0.1:3001/docs") - print(f" Interactive API docs at http://127.0.0.1:3001/redoc") - print(f"{'='*60}\n") + # Log server information + logger.info("=" * 60) + logger.info(" Server started at http://127.0.0.1:3001") + logger.info(" Documentation at http://127.0.0.1:3001/docs") + logger.info(" Interactive API docs at http://127.0.0.1:3001/redoc") + logger.info("=" * 60) yield # Shutdown: Clean up resources if needed @@ -133,8 +135,8 @@ async def ensure_standard_index(): await comments_collection.create_index([("movie_id", 1)], name=standard_index_name) except Exception as e: - print(f"Failed to create standard index on 'comments' collection: {str(e)}. ") - print(f"Performance may be degraded. Please check your MongoDB configuration.") + logger.warning(f"Failed to create standard index on 'comments' collection: {str(e)}") + logger.warning("Performance may be degraded. Please check your MongoDB configuration.") app = FastAPI(lifespan=lifespan) @@ -174,5 +176,8 @@ async def voyage_api_error_handler(request: Request, exc: VoyageAPIError): allow_headers=["*"], ) +# Add request logging middleware +app.add_middleware(RequestLoggingMiddleware) + app.include_router(movies.router, prefix="/api/movies", tags=["movies"]) diff --git a/mflix/server/python-fastapi/src/middleware/__init__.py b/mflix/server/python-fastapi/src/middleware/__init__.py new file mode 100644 index 0000000..674d994 --- /dev/null +++ b/mflix/server/python-fastapi/src/middleware/__init__.py @@ -0,0 +1,6 @@ +"""Middleware package for FastAPI application.""" + +from src.middleware.request_logging import RequestLoggingMiddleware + +__all__ = ["RequestLoggingMiddleware"] + diff --git a/mflix/server/python-fastapi/src/middleware/request_logging.py b/mflix/server/python-fastapi/src/middleware/request_logging.py new file mode 100644 index 0000000..fc997bf --- /dev/null +++ b/mflix/server/python-fastapi/src/middleware/request_logging.py @@ -0,0 +1,97 @@ +""" +Request logging middleware for FastAPI. + +This middleware logs all incoming HTTP requests with useful information +including method, URL, status code, and response time. + +Log output format: + INFO - GET /api/movies 200 - 45ms + WARN - GET /api/movies/invalid 400 - 2ms + ERROR - POST /api/movies 500 - 120ms +""" + +import time +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from src.utils.logger import logger + + +# Paths to skip logging (reduces noise) +SKIP_PATHS = { + "/docs", + "/redoc", + "/openapi.json", + "/favicon.ico", + "/health", +} + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """ + Middleware that logs HTTP requests with timing information. + + Features: + - Logs method, path, status code, and response time + - Uses appropriate log level based on status code: + - ERROR: 5xx server errors + - WARNING: 4xx client errors + - INFO: 2xx and 3xx success/redirect + - Skips logging for documentation and static paths + """ + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + # Skip logging for certain paths + if request.url.path in SKIP_PATHS: + return await call_next(request) + + # Record start time + start_time = time.perf_counter() + + # Log incoming request at debug level + logger.debug( + f"Incoming request: {request.method} {request.url.path} from {request.client.host if request.client else 'unknown'}" + ) + + # Process the request + response = await call_next(request) + + # Calculate response time in milliseconds + response_time_ms = (time.perf_counter() - start_time) * 1000 + + # Log the completed request with appropriate level + self._log_request( + method=request.method, + path=request.url.path, + status_code=response.status_code, + response_time_ms=response_time_ms + ) + + return response + + def _log_request( + self, + method: str, + path: str, + status_code: int, + response_time_ms: float + ) -> None: + """ + Log the HTTP request with appropriate log level based on status code. + + Args: + method: HTTP method (GET, POST, etc.) + path: Request path + status_code: HTTP response status code + response_time_ms: Response time in milliseconds + """ + message = f"{method} {path} {status_code} - {response_time_ms:.0f}ms" + + if status_code >= 500: + logger.error(message) + elif status_code >= 400: + logger.warning(message) + else: + logger.info(message) + diff --git a/mflix/server/python-fastapi/src/utils/logger.py b/mflix/server/python-fastapi/src/utils/logger.py new file mode 100644 index 0000000..703ce3b --- /dev/null +++ b/mflix/server/python-fastapi/src/utils/logger.py @@ -0,0 +1,155 @@ +""" +Logging configuration for the FastAPI application. + +This module provides a centralized logging setup with: +- Colorized console output for better readability +- Configurable log levels via environment variables +- Optional file logging +- Request logging middleware + +Usage: + from src.utils.logger import logger + + logger.info("Server started") + logger.debug("Processing request") + logger.error("Something went wrong", exc_info=True) +""" + +import logging +import os +import sys +from datetime import datetime +from typing import Optional + +# ANSI color codes for terminal output +class Colors: + """ANSI color codes for colorized log output.""" + RESET = "\033[0m" + BOLD = "\033[1m" + FAINT = "\033[2m" + + # Log level colors + DEBUG = "\033[36m" # Cyan + INFO = "\033[32m" # Green + WARNING = "\033[33m" # Yellow + ERROR = "\033[31m" # Red + CRITICAL = "\033[35m" # Magenta + + # Component colors + TIMESTAMP = "\033[90m" # Gray + LOGGER_NAME = "\033[36m" # Cyan + + +class ColoredFormatter(logging.Formatter): + """ + Custom formatter that adds colors to log output. + + Format: HH:MM:SS LEVEL --- [logger_name] : message + """ + + LEVEL_COLORS = { + logging.DEBUG: Colors.DEBUG, + logging.INFO: Colors.INFO, + logging.WARNING: Colors.WARNING, + logging.ERROR: Colors.ERROR, + logging.CRITICAL: Colors.CRITICAL, + } + + def format(self, record: logging.LogRecord) -> str: + # Get the color for this log level + level_color = self.LEVEL_COLORS.get(record.levelno, Colors.RESET) + + # Format timestamp + timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") + + # Format level name (padded to 5 chars) + level_name = f"{record.levelname:>5}" + + # Format logger name (truncated to 40 chars) + logger_name = record.name[-40:] if len(record.name) > 40 else record.name + + # Build the formatted message + formatted = ( + f"{Colors.FAINT}{timestamp}{Colors.RESET} " + f"{level_color}{level_name}{Colors.RESET} " + f"{Colors.FAINT}---{Colors.RESET} " + f"{Colors.FAINT}[{Colors.RESET}" + f"{Colors.LOGGER_NAME}{logger_name:>40}{Colors.RESET}" + f"{Colors.FAINT}]{Colors.RESET} " + f"{Colors.FAINT}:{Colors.RESET} " + f"{record.getMessage()}" + ) + + # Add exception info if present + if record.exc_info: + formatted += "\n" + self.formatException(record.exc_info) + + return formatted + + +class PlainFormatter(logging.Formatter): + """Plain text formatter for file logging (no colors).""" + + def format(self, record: logging.LogRecord) -> str: + timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") + level_name = f"{record.levelname:>5}" + logger_name = record.name[-40:] if len(record.name) > 40 else record.name + + formatted = f"{timestamp} {level_name} --- [{logger_name:>40}] : {record.getMessage()}" + + if record.exc_info: + formatted += "\n" + self.formatException(record.exc_info) + + return formatted + + +def setup_logger( + name: str = "mflix", + level: Optional[str] = None, + log_file: Optional[str] = None +) -> logging.Logger: + """ + Set up and configure a logger instance. + + Args: + name: Logger name (default: "mflix") + level: Log level (default: from LOG_LEVEL env var or INFO) + log_file: Optional file path for file logging + + Returns: + Configured logger instance + """ + # Get log level from environment or parameter + log_level_str = level or os.getenv("LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_str, logging.INFO) + + # Create logger + logger = logging.getLogger(name) + logger.setLevel(log_level) + + # Remove existing handlers to avoid duplicates + logger.handlers.clear() + + # Console handler with colors + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(log_level) + console_handler.setFormatter(ColoredFormatter()) + logger.addHandler(console_handler) + + # File handler (optional) + file_path = log_file or os.getenv("LOG_FILE") + if file_path: + file_handler = logging.FileHandler(file_path) + file_handler.setLevel(log_level) + file_handler.setFormatter(PlainFormatter()) + logger.addHandler(file_handler) + + # Prevent propagation to root logger + logger.propagate = False + + return logger + + +# Create the default application logger +logger = setup_logger() +