diff --git a/.env.example b/.env.example index 7738fecb1..35503587e 100644 --- a/.env.example +++ b/.env.example @@ -171,8 +171,26 @@ LOG_LEVEL=INFO # Log format: json or text LOG_FORMAT=json -# Optional path to write logs to file (leave empty to log to stdout) -#LOG_FILE=./logs/gateway.log +# Enable file logging (default: false - logs to stdout/stderr only) +LOG_TO_FILE=false + +# Log filename (when file logging enabled) +#LOG_FILE=gateway.log + +# Log directory (when file logging enabled) +#LOG_FOLDER=./logs + +# File write mode: a+ (append) or w (overwrite) +#LOG_FILEMODE=a+ + +# Enable log rotation (default: false) +#LOG_ROTATION_ENABLED=false + +# Max file size before rotation (MB) +#LOG_MAX_SIZE_MB=1 + +# Number of backup files to keep +#LOG_BACKUP_COUNT=5 ##################################### # Transport Configuration diff --git a/CLAUDE.md b/CLAUDE.md index 3ffbec494..75df6b792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,8 +139,22 @@ MCPGATEWAY_ADMIN_API_ENABLED=true MCPGATEWAY_ENABLE_MDNS_DISCOVERY=true MCPGATEWAY_ENABLE_FEDERATION=true -# Development +# Logging (Dual Output Support) LOG_LEVEL=INFO +LOG_TO_FILE=false # Enable file logging (default: stdout/stderr only) +LOG_ROTATION_ENABLED=false # Enable log rotation when file logging is enabled +LOG_MAX_SIZE_MB=1 # Max file size before rotation (MB) +LOG_BACKUP_COUNT=5 # Number of backup files to keep +LOG_FILE=mcpgateway.log # Log filename (when file logging enabled) +LOG_FOLDER=logs # Log directory (when file logging enabled) + +# Dual Logging Features: +# - Console logs: Human-readable text format (always enabled) +# - File logs: Structured JSON format (when LOG_TO_FILE=true) +# - All logs appear in both outputs: application, services, HTTP access logs +# - File rotation: Configurable size-based rotation with backup retention + +# Development RELOAD=true # For development hot-reload ``` diff --git a/README.md b/README.md index 6f6572a9b..52405f5dd 100644 --- a/README.md +++ b/README.md @@ -1016,11 +1016,53 @@ You can get started by copying the provided [.env.example](.env.example) to `.en ### Logging -| Setting | Description | Default | Options | -| ------------ | ----------------- | ------- | ------------------ | -| `LOG_LEVEL` | Minimum log level | `INFO` | `DEBUG`...`CRITICAL` | -| `LOG_FORMAT` | Log format | `json` | `json`, `text` | -| `LOG_FILE` | Log output file | (none) | path or empty | +MCP Gateway provides flexible logging with **stdout/stderr output by default** and **optional file-based logging**. When file logging is enabled, it provides JSON formatting for structured logs and text formatting for console output. + +| Setting | Description | Default | Options | +| ----------------------- | ---------------------------------- | ----------------- | -------------------------- | +| `LOG_LEVEL` | Minimum log level | `INFO` | `DEBUG`...`CRITICAL` | +| `LOG_FORMAT` | Console log format | `json` | `json`, `text` | +| `LOG_TO_FILE` | **Enable file logging** | **`false`** | **`true`, `false`** | +| `LOG_FILE` | Log filename (when enabled) | `null` | `mcpgateway.log` | +| `LOG_FOLDER` | Directory for log files | `null` | `logs`, `/var/log/gateway` | +| `LOG_FILEMODE` | File write mode | `a+` | `a+` (append), `w` (overwrite)| +| `LOG_ROTATION_ENABLED` | **Enable log file rotation** | **`false`** | **`true`, `false`** | +| `LOG_MAX_SIZE_MB` | Max file size before rotation (MB) | `1` | Any positive integer | +| `LOG_BACKUP_COUNT` | Number of backup files to keep | `5` | Any non-negative integer | + +**Logging Behavior:** +- **Default**: Logs only to **stdout/stderr** with human-readable text format +- **File Logging**: When `LOG_TO_FILE=true`, logs to **both** file (JSON format) and console (text format) +- **Log Rotation**: When `LOG_ROTATION_ENABLED=true`, files rotate at `LOG_MAX_SIZE_MB` with `LOG_BACKUP_COUNT` backup files (e.g., `.log.1`, `.log.2`) +- **Directory Creation**: Log folder is automatically created if it doesn't exist +- **Centralized Service**: All modules use the unified `LoggingService` for consistent formatting + +**Example Configurations:** + +```bash +# Default: stdout/stderr only (recommended for containers) +LOG_LEVEL=INFO +# No additional config needed - logs to stdout/stderr + +# Optional: Enable file logging (no rotation) +LOG_TO_FILE=true +LOG_FOLDER=/var/log/mcpgateway +LOG_FILE=gateway.log +LOG_FILEMODE=a+ + +# Optional: Enable file logging with rotation +LOG_TO_FILE=true +LOG_ROTATION_ENABLED=true +LOG_MAX_SIZE_MB=10 +LOG_BACKUP_COUNT=3 +LOG_FOLDER=/var/log/mcpgateway +LOG_FILE=gateway.log +``` + +**Default Behavior:** +- Logs are written **only to stdout/stderr** in human-readable text format +- File logging is **disabled by default** (no files created) +- Set `LOG_TO_FILE=true` to enable optional file logging with JSON format ### Transport diff --git a/docs/docs/architecture/adr/005-structured-json-logging.md b/docs/docs/architecture/adr/005-structured-json-logging.md index 90fa39cc0..3fff5c8d5 100644 --- a/docs/docs/architecture/adr/005-structured-json-logging.md +++ b/docs/docs/architecture/adr/005-structured-json-logging.md @@ -1,7 +1,7 @@ # ADR-0005: Structured JSON Logging -- *Status:* Accepted -- *Date:* 2025-02-21 +- *Status:* Implemented +- *Date:* 2025-01-09 - *Deciders:* Core Engineering Team ## Context @@ -14,27 +14,38 @@ The gateway must emit logs that: Our configuration supports: -- `LOG_FORMAT`: `json` or `plain` -- `LOG_LEVEL`: standard Python levels -- `LOG_FILE`: optional log file destination +- `LOG_FORMAT`: `json` or `text` (console format only) +- `LOG_LEVEL`: standard Python levels (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) +- `LOG_TO_FILE`: enable file logging (default: `false` - stdout/stderr only) +- `LOG_FILE`: log filename when file logging is enabled (default: `null`) +- `LOG_FOLDER`: directory for log files when enabled (default: `null`) +- `LOG_FILEMODE`: file write mode (default: `a+` for append) +- `LOG_ROTATION_ENABLED`: enable automatic log rotation (default: `false`) +- `LOG_MAX_SIZE_MB`: maximum file size before rotation in MB (default: `1`) +- `LOG_BACKUP_COUNT`: number of backup files to keep (default: `5`) -Logs are initialized at startup via `LoggingService`. +Logs are initialized at startup via centralized `LoggingService`. By default, logs go only to stdout/stderr. File logging with dual-format output is optional via `LOG_TO_FILE=true`. ## Decision -Use the Python standard `logging` module with: +Use the Python standard `logging` module with centralized `LoggingService`: -- A **custom JSON formatter** for structured logs (e.g. `{"level": "INFO", "msg": ..., "request_id": ...}`) -- **Plain text output** when `LOG_FORMAT=plain` -- Per-request context via filters or middleware -- Global setup at app startup to avoid late binding issues +- **JSON formatter** for file logs using `python-json-logger` library +- **Text formatter** for console logs for human readability +- **Dual output**: JSON to files, text to console +- **Optional rotating file handler** for automatic log management (configurable) +- **Centralized service** integrated across all 22+ modules +- Global setup at app startup with lazy handler initialization ## Consequences -- ๐Ÿ“‹ Easily parsed logs suitable for production observability pipelines -- โš™๏ธ Compatible with `stdout`, file, or syslog targets -- ๐Ÿงช Local development uses plain logs for readability -- ๐Ÿงฑ Minimal dependency footprint (no third-party logging libraries) +- ๐Ÿ“‹ **Structured JSON logs** suitable for production observability pipelines (ELK, Datadog, etc.) +- โš™๏ธ **Dual format support**: JSON files for machines, text console for humans +- ๐Ÿ”„ **Optional log rotation** prevents disk space issues when enabled +- ๐Ÿงช **Development-friendly** with human-readable console output +- ๐Ÿ“ **Organized storage** with configurable log directories and retention +- ๐Ÿงฑ **Minimal dependencies**: Uses standard library + `python-json-logger` +- ๐ŸŽฏ **Consistent logging** across all application modules ## Alternatives Considered @@ -47,4 +58,15 @@ Use the Python standard `logging` module with: ## Status -Structured logging is implemented in `LoggingService`, configurable via environment variables. +**โœ… Implemented** - Structured logging is fully implemented in `LoggingService` with: + +- Centralized logging service integrated across all modules +- Dual-format output (JSON to files, text to console) +- HTTP access log capture (uvicorn.access and uvicorn.error loggers) +- Optional log rotation with configurable size limits and retention +- Environment variable configuration support +- Production-ready with proper error handling and lazy initialization + +**Files Modified**: 22 modules updated to use `LoggingService` +**Dependencies Added**: `python-json-logger>=2.0.0` +**Configuration**: Via `LOG_LEVEL`, `LOG_FORMAT`, `LOG_TO_FILE`, `LOG_FILE`, `LOG_FOLDER`, `LOG_FILEMODE`, `LOG_ROTATION_ENABLED`, `LOG_MAX_SIZE_MB`, `LOG_BACKUP_COUNT` diff --git a/docs/docs/faq/index.md b/docs/docs/faq/index.md index 15ea89b24..48d7ba69b 100644 --- a/docs/docs/faq/index.md +++ b/docs/docs/faq/index.md @@ -66,7 +66,7 @@ ???+ example "๐Ÿช› What are some advanced environment variables I can configure?" - Basic: `HOST`, `PORT`, `APP_ROOT_PATH` - Auth: `AUTH_REQUIRED`, `BASIC_AUTH_*`, `JWT_SECRET_KEY` - - Logging: `LOG_LEVEL`, `LOG_FORMAT`, `LOG_FILE` + - Logging: `LOG_LEVEL`, `LOG_FORMAT`, `LOG_TO_FILE`, `LOG_FILE`, `LOG_FOLDER`, `LOG_ROTATION_ENABLED`, `LOG_MAX_SIZE_MB`, `LOG_BACKUP_COUNT` - Transport: `TRANSPORT_TYPE`, `WEBSOCKET_PING_INTERVAL`, `SSE_RETRY_TIMEOUT` - Tools: `TOOL_TIMEOUT`, `MAX_TOOL_RETRIES`, `TOOL_RATE_LIMIT`, `TOOL_CONCURRENT_LIMIT` - Federation: `FEDERATION_ENABLED`, `FEDERATION_PEERS`, `FEDERATION_SYNC_INTERVAL` diff --git a/docs/docs/manage/.pages b/docs/docs/manage/.pages index c0c81e865..914468632 100644 --- a/docs/docs/manage/.pages +++ b/docs/docs/manage/.pages @@ -2,6 +2,7 @@ nav: - index.md - backup.md - logging.md + - logging-examples.md - upgrade.md - tuning.md - securing.md diff --git a/docs/docs/manage/logging-examples.md b/docs/docs/manage/logging-examples.md new file mode 100644 index 000000000..ffaf6633c --- /dev/null +++ b/docs/docs/manage/logging-examples.md @@ -0,0 +1,366 @@ +# Logging Examples for MCP Gateway + +This document provides practical examples of using the logging features in MCP Gateway. + +## Quick Start Examples + +### 1. Default Setup (Recommended) +```bash +# Default: logs only to stdout/stderr (great for containers) +export LOG_LEVEL=INFO +mcpgateway --host 0.0.0.0 --port 4444 +# Logs appear in console only - no files created +``` + +### 2. Development with File Logging (No Rotation) +```bash +# Enable file logging for development without rotation +export LOG_TO_FILE=true +export LOG_LEVEL=DEBUG +export LOG_FOLDER=./dev-logs +export LOG_FILE=debug.log +mcpgateway --host 0.0.0.0 --port 4444 +# Logs to both console (text format) AND ./dev-logs/debug.log (JSON format, grows indefinitely) +``` + +### 3. Development with File Rotation +```bash +# Enable file logging with small rotation for development +export LOG_TO_FILE=true +export LOG_ROTATION_ENABLED=true +export LOG_MAX_SIZE_MB=1 +export LOG_BACKUP_COUNT=3 +export LOG_LEVEL=DEBUG +export LOG_FOLDER=./dev-logs +export LOG_FILE=debug.log +mcpgateway --host 0.0.0.0 --port 4444 +# Logs rotate at 1MB with 3 backup files kept (console: text, files: JSON) +``` + +### 4. Production with File Logging (No Rotation) +```bash +# Production logging with JSON format, no rotation (managed externally) +export LOG_TO_FILE=true +export LOG_LEVEL=INFO +export LOG_FOLDER=/var/log/mcpgateway +export LOG_FILE=gateway.log +export LOG_FILEMODE=a+ +mcpgateway --host 0.0.0.0 --port 4444 +# Logs to both console AND /var/log/mcpgateway/gateway.log +``` + +### 5. Production with File Rotation +```bash +# Production logging with automatic rotation +export LOG_TO_FILE=true +export LOG_ROTATION_ENABLED=true +export LOG_MAX_SIZE_MB=50 +export LOG_BACKUP_COUNT=7 +export LOG_LEVEL=INFO +export LOG_FOLDER=/var/log/mcpgateway +export LOG_FILE=gateway.log +mcpgateway --host 0.0.0.0 --port 4444 +# Files rotate at 50MB with 7 backup files (weekly retention) +``` + +### 6. Monitoring Specific Components (requires file logging) +```bash +# First enable file logging +export LOG_TO_FILE=true +export LOG_FILE=mcpgateway.log +export LOG_FOLDER=logs + +# Then monitor tool service activities +tail -f logs/mcpgateway.log | grep "tool_service" + +# Watch for errors across all services +tail -f logs/mcpgateway.log | grep "ERROR\|WARNING" + +# Pretty-print JSON logs +tail -f logs/mcpgateway.log | jq '.' +``` + +## Configuration Examples + +### .env File Configuration +```env +# Default: stdout/stderr only +LOG_LEVEL=INFO +LOG_FORMAT=json + +# Optional: Enable file logging (no rotation) +LOG_TO_FILE=true +LOG_FILE=mcpgateway.log +LOG_FOLDER=logs +LOG_FILEMODE=a+ + +# Optional: Enable file logging with rotation +LOG_TO_FILE=true +LOG_ROTATION_ENABLED=true +LOG_MAX_SIZE_MB=10 +LOG_BACKUP_COUNT=5 +LOG_FILE=mcpgateway.log +LOG_FOLDER=logs +``` + +### Docker/Container Configuration +```yaml +# docker-compose.yml +services: + mcpgateway: + image: ghcr.io/ibm/mcp-context-forge:latest + environment: + - LOG_LEVEL=INFO + # Default: logs to stdout/stderr only (recommended for containers) + # Optional: Enable file logging (no rotation) + # - LOG_TO_FILE=true + # - LOG_FOLDER=/app/logs + # - LOG_FILE=gateway.log + # Optional: Enable file logging with rotation + # - LOG_TO_FILE=true + # - LOG_ROTATION_ENABLED=true + # - LOG_MAX_SIZE_MB=10 + # - LOG_BACKUP_COUNT=3 + # - LOG_FOLDER=/app/logs + # - LOG_FILE=gateway.log + # volumes: + # - ./logs:/app/logs # Only needed if LOG_TO_FILE=true +``` + +### Kubernetes Configuration +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcpgateway +spec: + template: + spec: + containers: + - name: mcpgateway + env: + - name: LOG_LEVEL + value: "INFO" + # Default: logs to stdout/stderr (recommended for Kubernetes) + # Optional: Enable file logging (no rotation) + # - name: LOG_TO_FILE + # value: "true" + # - name: LOG_FOLDER + # value: "/var/log/mcpgateway" + # - name: LOG_FILE + # value: "gateway.log" + # Optional: Enable file logging with rotation + # - name: LOG_TO_FILE + # value: "true" + # - name: LOG_ROTATION_ENABLED + # value: "true" + # - name: LOG_MAX_SIZE_MB + # value: "20" + # - name: LOG_BACKUP_COUNT + # value: "5" + # - name: LOG_FOLDER + # value: "/var/log/mcpgateway" + # - name: LOG_FILE + # value: "gateway.log" + # volumeMounts: # Only needed if LOG_TO_FILE=true + # - name: log-storage + # mountPath: /var/log/mcpgateway +``` + +## Log Analysis Examples + +**Note**: The following examples require file logging to be enabled with `LOG_TO_FILE=true`. For stdout/stderr logs, use standard shell redirection and pipes instead. + +### 1. Finding Errors and Issues +```bash +# Find all errors +grep "ERROR" logs/mcpgateway.log + +# Find warnings and errors +grep -E "ERROR|WARNING" logs/mcpgateway.log + +# Get context around errors (5 lines before and after) +grep -B5 -A5 "ERROR" logs/mcpgateway.log +``` + +### 2. Monitoring Service Activity +```bash +# Gateway service activity +grep "gateway_service" logs/mcpgateway.log | tail -20 + +# Tool invocations +grep "tool_service.*invoke" logs/mcpgateway.log + +# Federation activity +grep "federation" logs/mcpgateway.log +``` + +### 3. Performance Analysis +```bash +# Look for slow operations (if duration logging is enabled) +grep "duration" logs/mcpgateway.log | sort -k5 -nr + +# Database operations +grep "database" logs/mcpgateway.log + +# HTTP request/response logs +grep -E "HTTP|request" logs/mcpgateway.log +``` + +## Log Format Examples + +### JSON Format (File Output) +```json +{ + "asctime": "2025-01-09 17:30:15,123", + "name": "mcpgateway.gateway_service", + "levelname": "INFO", + "message": "Gateway peer-gateway-1 registered successfully", + "funcName": "register_gateway", + "lineno": 245, + "module": "gateway_service", + "pathname": "/app/mcpgateway/services/gateway_service.py" +} +``` + +### Text Format (Console Output) +``` +2025-01-09 17:30:15,123 - mcpgateway.gateway_service - INFO - Gateway peer-gateway-1 registered successfully +2025-01-09 17:30:16,456 - mcpgateway.tool_service - DEBUG - Tool 'get_weather' invoked with args: {'location': 'New York'} +2025-01-09 17:30:17,789 - mcpgateway.admin - WARNING - Authentication failed for user: anonymous +``` + +## Integration Examples + +### 1. ELK Stack Integration +```bash +# Configure Filebeat to ship logs +# filebeat.yml +filebeat.inputs: +- type: log + paths: + - /var/log/mcpgateway/*.log + json.keys_under_root: true + json.add_error_key: true +``` + +### 2. Datadog Integration +```bash +# Configure Datadog agent +# datadog.yaml +logs_config: + logs_dd_url: intake.logs.datadoghq.com:10516 + +logs: + - type: file + path: "/var/log/mcpgateway/*.log" + service: mcpgateway + source: python + sourcecategory: mcp +``` + +### 3. Prometheus/Grafana Monitoring +```bash +# Use log-based metrics with promtail +# promtail-config.yml +scrape_configs: +- job_name: mcpgateway + static_configs: + - targets: + - localhost + labels: + job: mcpgateway + __path__: /var/log/mcpgateway/*.log +``` + +## Troubleshooting Examples + +### Common Issues and Solutions + +1. **Log files not rotating** + ```bash + # Check if rotation is enabled + echo "LOG_ROTATION_ENABLED: $LOG_ROTATION_ENABLED" + echo "LOG_MAX_SIZE_MB: $LOG_MAX_SIZE_MB" + echo "LOG_BACKUP_COUNT: $LOG_BACKUP_COUNT" + + # Check file permissions and available disk space + ls -la logs/ + df -h + + # Check current file size (should be under LOG_MAX_SIZE_MB) + ls -lh logs/mcpgateway.log + ``` + +2. **Missing log directory** + ```bash + # The directory is created automatically, but check permissions + mkdir -p logs + chmod 755 logs + ``` + +3. **Too many log files (with rotation disabled)** + ```bash + # Clean up old rotated logs beyond LOG_BACKUP_COUNT + # For LOG_BACKUP_COUNT=5, remove .log.6 and higher + find logs/ -name "*.log.[6-9]" -delete + find logs/ -name "*.log.1[0-9]" -delete + ``` + +4. **Files not rotating despite size limit** + ```bash + # Check if rotation is properly enabled + grep -i "rotation" logs/mcpgateway.log | tail -5 + + # Force check file size vs limit + actual_size=$(stat -c%s logs/mcpgateway.log) + limit_bytes=$((LOG_MAX_SIZE_MB * 1024 * 1024)) + echo "Actual: $actual_size bytes, Limit: $limit_bytes bytes" + ``` + +5. **Rotation happening too frequently** + ```bash + # Increase LOG_MAX_SIZE_MB if files rotate too often + export LOG_MAX_SIZE_MB=50 # Increase from default 1MB to 50MB + + # Or disable rotation for external log management + export LOG_ROTATION_ENABLED=false + ``` + +6. **JSON parsing errors** + ```bash + # Validate JSON format + cat logs/mcpgateway.log | jq empty + + # Show only invalid JSON lines + cat logs/mcpgateway.log | while read line; do + echo "$line" | jq empty 2>/dev/null || echo "Invalid: $line" + done + ``` + +## Best Practices + +1. **Production Logging** + - Use `INFO` level for production + - Enable JSON format for log aggregation + - Configure log rotation based on expected volume: + - High traffic: `LOG_MAX_SIZE_MB=50`, `LOG_BACKUP_COUNT=7` + - Medium traffic: `LOG_MAX_SIZE_MB=10`, `LOG_BACKUP_COUNT=5` + - Low traffic: Consider disabling rotation + - Monitor disk space usage + +2. **Development Logging** + - Use `DEBUG` level for detailed troubleshooting + - Use text format for human readability + - Enable rotation with small files: `LOG_MAX_SIZE_MB=1`, `LOG_BACKUP_COUNT=3` + - Keep log files local for quick access + +3. **Security Considerations** + - Ensure log files don't contain sensitive data + - Protect log directories with proper permissions + - Rotate logs regularly to prevent disk filling + +4. **Performance Considerations** + - Avoid excessive DEBUG logging in production + - Monitor log I/O performance + - Use appropriate log levels for different components diff --git a/docs/docs/manage/logging.md b/docs/docs/manage/logging.md index 0f81ae8fc..623b8ddee 100644 --- a/docs/docs/manage/logging.md +++ b/docs/docs/manage/logging.md @@ -1,35 +1,162 @@ # Logging -MCP Gateway emits structured logs that can be viewed locally or forwarded to a log aggregation service. This guide shows how to configure log levels, formats, and destinations. +MCP Gateway provides comprehensive file-based logging with automatic rotation, dual-format output (JSON for files, text for console), and centralized logging service integration. This guide shows how to configure log levels, formats, destinations, and file management. --- ## ๐Ÿงพ Log Structure -Logs are emitted in JSON or text format, depending on your configuration. +MCP Gateway uses dual-format logging: -Example (JSON format): +- **File logs**: Structured JSON format for machine processing and log aggregation +- **Console logs**: Human-readable text format for development and debugging +### JSON Format (File Output) ```json { - "timestamp": "2025-05-15T10:32:10Z", - "level": "INFO", - "module": "gateway_service", + "asctime": "2025-01-09 17:30:15,123", + "name": "mcpgateway.gateway_service", + "levelname": "INFO", "message": "Registered gateway: peer-gateway-1" } ``` +#### HTTP Access Logs (JSON) +```json +{ + "asctime": "2025-01-09 17:30:22,456", + "name": "uvicorn.access", + "levelname": "INFO", + "message": "127.0.0.1:43926 - \"GET /version HTTP/1.1\" 401" +} +``` + +### Text Format (Console Output) +``` +2025-01-09 17:30:15,123 - mcpgateway.gateway_service - INFO - Registered gateway: peer-gateway-1 +``` + --- ## ๐Ÿ”ง Configuring Logs -You can control logging behavior using `.env` settings: +MCP Gateway provides flexible logging with **stdout/stderr by default** and **optional file logging**. You can control logging behavior using `.env` settings or environment variables: + +| Variable | Description | Default | Example | +| ----------------------- | ---------------------------------- | ----------------- | --------------------------- | +| `LOG_LEVEL` | Minimum log level | `INFO` | `DEBUG`, `INFO`, `WARNING` | +| `LOG_FORMAT` | Console log format | `json` | `json` or `text` | +| `LOG_TO_FILE` | **Enable file logging** | **`false`** | **`true`, `false`** | +| `LOG_FILE` | Log filename (when enabled) | `null` | `gateway.log` | +| `LOG_FOLDER` | Directory for log files | `null` | `/var/log/mcpgateway` | +| `LOG_FILEMODE` | File write mode | `a+` | `a+` (append), `w` (overwrite) | +| `LOG_ROTATION_ENABLED` | **Enable log file rotation** | **`false`** | **`true`, `false`** | +| `LOG_MAX_SIZE_MB` | Max file size before rotation (MB) | `1` | `10`, `50`, `100` | +| `LOG_BACKUP_COUNT` | Number of backup files to keep | `5` | `3`, `10`, `0` (no backups) | + +### Logging Behavior + +- **Default**: Logs **only to stdout/stderr** with human-readable text format (recommended for containers) +- **File Logging**: When `LOG_TO_FILE=true`, logs to **both** file (JSON format) and console (text format) +- **Log Rotation**: When `LOG_ROTATION_ENABLED=true`, files rotate at `LOG_MAX_SIZE_MB` with `LOG_BACKUP_COUNT` backup files +- **No Rotation**: When `LOG_ROTATION_ENABLED=false`, files grow indefinitely (append mode) +- **Directory Creation**: Log folder is created automatically if it doesn't exist +- **Dual Output**: JSON logs to file, text logs to console simultaneously (when file logging enabled) + +### Example Configurations + +```bash +# Default: stdout/stderr only (recommended for containers) +LOG_LEVEL=INFO +# No additional config needed - logs to stdout/stderr only + +# Optional: Enable file logging (no rotation) +LOG_TO_FILE=true +LOG_FOLDER=/var/log/mcpgateway +LOG_FILE=gateway.log +LOG_FILEMODE=a+ + +# Production with file rotation +LOG_TO_FILE=true +LOG_ROTATION_ENABLED=true +LOG_MAX_SIZE_MB=10 +LOG_BACKUP_COUNT=7 +LOG_FOLDER=/var/log/mcpgateway +LOG_FILE=gateway.log + +# Development with file logging and rotation +LOG_TO_FILE=true +LOG_ROTATION_ENABLED=true +LOG_MAX_SIZE_MB=1 +LOG_BACKUP_COUNT=3 +LOG_LEVEL=DEBUG +LOG_FOLDER=./logs +LOG_FILE=debug.log +LOG_FORMAT=text +``` + +--- + +## ๐Ÿ“‚ Log File Management + +**Note**: This section applies only when file logging is enabled with `LOG_TO_FILE=true`. By default, MCP Gateway logs only to stdout/stderr. + +### Viewing Log Files + +```bash +# View current log file +cat logs/mcpgateway.log + +# Follow log file in real-time +tail -f logs/mcpgateway.log -| Variable | Description | Example | -| ------------ | ------------------------------- | ------------------------- | -| `LOG_LEVEL` | Minimum log level | `INFO`, `DEBUG`, `ERROR` | -| `LOG_FORMAT` | Log output format | `json` or `text` | -| `LOG_FILE` | Write logs to a file (optional) | `/var/log/mcpgateway.log` | +# View with JSON formatting (requires jq) +tail -f logs/mcpgateway.log | jq '.' + +# Search logs for specific patterns +grep "ERROR" logs/mcpgateway.log +grep "gateway_service" logs/*.log +``` + +### Log Rotation + +**Log rotation is optional** and only applies when both file logging and rotation are enabled: + +- `LOG_TO_FILE=true` - Enable file logging +- `LOG_ROTATION_ENABLED=true` - Enable rotation + +When enabled, files automatically rotate based on the configured size limit (`LOG_MAX_SIZE_MB`): + +``` +logs/ +โ”œโ”€โ”€ mcpgateway.log (current, active log) +โ”œโ”€โ”€ mcpgateway.log.1 (most recent backup) +โ”œโ”€โ”€ mcpgateway.log.2 (second backup) +โ”œโ”€โ”€ mcpgateway.log.3 (third backup) +โ””โ”€โ”€ ... (up to LOG_BACKUP_COUNT backups) +``` + +**Configuration Options:** +- `LOG_MAX_SIZE_MB=10` - Rotate when file reaches 10MB +- `LOG_BACKUP_COUNT=3` - Keep 3 backup files (plus current file = 4 total) +- `LOG_BACKUP_COUNT=0` - No backup files (only current file kept) + +**Without Rotation:** +- When `LOG_ROTATION_ENABLED=false`, files grow indefinitely +- Use external log management tools for cleanup if needed + +### Cleanup and Maintenance + +```bash +# Archive old logs (optional) +tar -czf mcpgateway-logs-$(date +%Y%m%d).tar.gz logs/mcpgateway.log.* + +# Clear all log files (be careful!) +rm logs/mcpgateway.log* + +# Check log file sizes +du -sh logs/* +``` --- @@ -62,14 +189,37 @@ You can: ## ๐Ÿงช Debug Mode -For development, enable verbose logs by setting: +For development and troubleshooting, enable verbose logging: ```env -LOG_LEVEL=debug +# Enable debug logging +LOG_LEVEL=DEBUG LOG_FORMAT=text +LOG_FOLDER=./debug-logs +LOG_FILE=debug.log DEBUG=true ``` -This enables detailed request traces and internal service logs. +### Debug Features + +- **HTTP Access Logs**: All HTTP requests with IP, method, path, status code (via `uvicorn.access`) +- **HTTP Error Logs**: Server errors, invalid requests (via `uvicorn.error`) +- **Internal Service Logs**: Database queries, cache operations, federation +- **Transport Layer Logs**: WebSocket, SSE, and stdio communication +- **Plugin System Logs**: Hook execution and plugin lifecycle events + +### Useful Debug Commands + +```bash +# Start with debug logging +LOG_LEVEL=DEBUG mcpgateway --host 0.0.0.0 --port 4444 + +# Debug specific components +grep "gateway_service" logs/mcpgateway.log | tail -20 +grep "ERROR\|WARNING" logs/mcpgateway.log + +# Monitor in real-time during development +tail -f logs/mcpgateway.log | grep "tool_service" +``` --- diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 2f3428cbf..001c19a02 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -21,7 +21,6 @@ from collections import defaultdict from functools import wraps import json -import logging import time from typing import Any, Dict, List, Optional, Union @@ -63,6 +62,7 @@ ToolUpdate, ) from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayNotFoundError, GatewayService +from mcpgateway.services.logging_service import LoggingService from mcpgateway.services.prompt_service import PromptNotFoundError, PromptService from mcpgateway.services.resource_service import ResourceNotFoundError, ResourceService from mcpgateway.services.root_service import RootService @@ -74,6 +74,10 @@ from mcpgateway.utils.retry_manager import ResilientHttpClient from mcpgateway.utils.verify_credentials import require_auth, require_basic_auth +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger("mcpgateway") + # Initialize services server_service = ServerService() tool_service = ToolService() @@ -83,7 +87,6 @@ root_service = RootService() # Set up basic authentication -logger = logging.getLogger("mcpgateway") # Rate limiting storage rate_limit_storage = defaultdict(list) diff --git a/mcpgateway/alembic/versions/cc7b95fec5d9_add_tags_support_to_all_entities.py b/mcpgateway/alembic/versions/cc7b95fec5d9_add_tags_support_to_all_entities.py index eb12018d4..2ff6d565f 100644 --- a/mcpgateway/alembic/versions/cc7b95fec5d9_add_tags_support_to_all_entities.py +++ b/mcpgateway/alembic/versions/cc7b95fec5d9_add_tags_support_to_all_entities.py @@ -46,7 +46,7 @@ def upgrade() -> None: op.create_index("idx_prompts_tags", "prompts", ["tags"], postgresql_using="gin") op.create_index("idx_servers_tags", "servers", ["tags"], postgresql_using="gin") op.create_index("idx_gateways_tags", "gateways", ["tags"], postgresql_using="gin") - except Exception: + except Exception: # nosec B110 - database compatibility # SQLite doesn't support GIN indexes, skip silently pass @@ -60,7 +60,7 @@ def downgrade() -> None: op.drop_index("idx_prompts_tags", "prompts") op.drop_index("idx_servers_tags", "servers") op.drop_index("idx_gateways_tags", "gateways") - except Exception: + except Exception: # nosec B110 - database compatibility # Indexes might not exist on SQLite pass diff --git a/mcpgateway/bootstrap_db.py b/mcpgateway/bootstrap_db.py index 7142c377c..0d7f4761c 100644 --- a/mcpgateway/bootstrap_db.py +++ b/mcpgateway/bootstrap_db.py @@ -22,7 +22,6 @@ # Standard import asyncio from importlib.resources import files -import logging # Third-Party from alembic import command @@ -32,8 +31,11 @@ # First-Party from mcpgateway.config import settings from mcpgateway.db import Base +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) async def main() -> None: diff --git a/mcpgateway/cache/resource_cache.py b/mcpgateway/cache/resource_cache.py index ba03f55bb..d42c13051 100644 --- a/mcpgateway/cache/resource_cache.py +++ b/mcpgateway/cache/resource_cache.py @@ -37,11 +37,15 @@ # Standard import asyncio from dataclasses import dataclass -import logging import time from typing import Any, Dict, Optional -logger = logging.getLogger(__name__) +# First-Party +from mcpgateway.services.logging_service import LoggingService + +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) @dataclass diff --git a/mcpgateway/cache/session_registry.py b/mcpgateway/cache/session_registry.py index 19bd09097..ffd65a7eb 100644 --- a/mcpgateway/cache/session_registry.py +++ b/mcpgateway/cache/session_registry.py @@ -64,10 +64,13 @@ from mcpgateway.db import get_db, SessionMessageRecord, SessionRecord from mcpgateway.models import Implementation, InitializeResult, ServerCapabilities from mcpgateway.services import PromptService, ResourceService, ToolService +from mcpgateway.services.logging_service import LoggingService from mcpgateway.transports import SSETransport from mcpgateway.utils.retry_manager import ResilientHttpClient -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) tool_service = ToolService() resource_service = ResourceService() diff --git a/mcpgateway/config.py b/mcpgateway/config.py index bd7e68170..da8ecd266 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -65,11 +65,14 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - datefmt="%H:%M:%S", -) +# Only configure basic logging if no handlers exist yet +# This prevents conflicts with LoggingService while ensuring config logging works +if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%H:%M:%S", + ) logger = logging.getLogger(__name__) @@ -191,7 +194,15 @@ def _parse_allowed_origins(cls, v): # Logging log_level: str = "INFO" log_format: str = "json" # json or text - log_file: Optional[Path] = None + log_to_file: bool = False # Enable file logging (default: stdout/stderr only) + log_filemode: str = "a+" # append or overwrite + log_file: Optional[str] = None # Only used if log_to_file=True + log_folder: Optional[str] = None # Only used if log_to_file=True + + # Log Rotation (optional - only used if log_to_file=True) + log_rotation_enabled: bool = False # Enable log file rotation + log_max_size_mb: int = 1 # Max file size in MB before rotation (default: 1MB) + log_backup_count: int = 5 # Number of backup files to keep (default: 5) # Transport transport_type: str = "all" # http, ws, sse, all diff --git a/mcpgateway/federation/discovery.py b/mcpgateway/federation/discovery.py index 894f16f23..2ecc90ceb 100644 --- a/mcpgateway/federation/discovery.py +++ b/mcpgateway/federation/discovery.py @@ -65,7 +65,6 @@ import asyncio from dataclasses import dataclass from datetime import datetime, timedelta, timezone -import logging import os import socket from typing import Dict, List, Optional @@ -80,8 +79,11 @@ from mcpgateway import __version__ from mcpgateway.config import settings from mcpgateway.models import ServerCapabilities +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) PROTOCOL_VERSION = os.getenv("PROTOCOL_VERSION", "2025-03-26") diff --git a/mcpgateway/federation/forward.py b/mcpgateway/federation/forward.py index 159ef52d6..10586811e 100644 --- a/mcpgateway/federation/forward.py +++ b/mcpgateway/federation/forward.py @@ -22,7 +22,6 @@ # Standard import asyncio from datetime import datetime, timezone -import logging from typing import Any, Dict, List, Optional, Set, Tuple, Union # Third-Party @@ -35,9 +34,12 @@ from mcpgateway.db import Gateway as DbGateway from mcpgateway.db import Tool as DbTool from mcpgateway.models import ToolResult +from mcpgateway.services.logging_service import LoggingService from mcpgateway.utils.passthrough_headers import get_passthrough_headers -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class ForwardingError(Exception): diff --git a/mcpgateway/handlers/sampling.py b/mcpgateway/handlers/sampling.py index 40b7d1cfb..bb6a4464f 100644 --- a/mcpgateway/handlers/sampling.py +++ b/mcpgateway/handlers/sampling.py @@ -42,7 +42,6 @@ """ # Standard -import logging from typing import Any, Dict, List # Third-Party @@ -50,8 +49,11 @@ # First-Party from mcpgateway.models import CreateMessageResult, ModelPreferences, Role, TextContent +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class SamplingError(Exception): diff --git a/mcpgateway/main.py b/mcpgateway/main.py index 550a22227..af6ddab72 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -29,7 +29,6 @@ import asyncio from contextlib import asynccontextmanager import json -import logging from typing import Any, AsyncIterator, Dict, List, Optional, Union from urllib.parse import urlparse, urlunparse @@ -148,11 +147,8 @@ logging_service = LoggingService() logger = logging_service.get_logger("mcpgateway") -# Configure root logger level -logging.basicConfig( - level=getattr(logging, settings.log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) +# Note: Logging configuration is handled by LoggingService during startup +# Don't use basicConfig here as it conflicts with our dual logging setup # Wait for database to be ready before creating tables wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s @@ -220,6 +216,8 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: Exception: Any unhandled error that occurs during service initialisation or shutdown is re-raised to the caller. """ + # Initialize logging service FIRST to ensure all logging goes to dual output + await logging_service.initialize() logger.info("Starting MCP Gateway services") try: if plugin_manager: @@ -231,13 +229,16 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: await gateway_service.initialize() await root_service.initialize() await completion_service.initialize() - await logging_service.initialize() await sampling_handler.initialize() await resource_cache.initialize() await streamable_http_session.initialize() refresh_slugs_on_startup() logger.info("All services initialized successfully") + + # Reconfigure uvicorn loggers after startup to capture access logs in dual output + logging_service.configure_uvicorn_after_startup() + yield except Exception as e: logger.error(f"Error during startup: {str(e)}") diff --git a/mcpgateway/plugins/framework/loader/plugin.py b/mcpgateway/plugins/framework/loader/plugin.py index b4847e1f5..ed75bc8f6 100644 --- a/mcpgateway/plugins/framework/loader/plugin.py +++ b/mcpgateway/plugins/framework/loader/plugin.py @@ -17,6 +17,7 @@ from mcpgateway.plugins.framework.models import PluginConfig from mcpgateway.plugins.framework.utils import import_module, parse_class_name +# Use standard logging to avoid circular imports (plugins -> services -> plugins) logger = logging.getLogger(__name__) diff --git a/mcpgateway/plugins/framework/manager.py b/mcpgateway/plugins/framework/manager.py index 0c6714821..f8c143729 100644 --- a/mcpgateway/plugins/framework/manager.py +++ b/mcpgateway/plugins/framework/manager.py @@ -54,6 +54,7 @@ from mcpgateway.plugins.framework.registry import PluginInstanceRegistry from mcpgateway.plugins.framework.utils import post_prompt_matches, post_tool_matches, pre_prompt_matches, pre_tool_matches +# Use standard logging to avoid circular imports (plugins -> services -> plugins) logger = logging.getLogger(__name__) T = TypeVar("T") diff --git a/mcpgateway/plugins/framework/registry.py b/mcpgateway/plugins/framework/registry.py index 74a6440b0..9c5683056 100644 --- a/mcpgateway/plugins/framework/registry.py +++ b/mcpgateway/plugins/framework/registry.py @@ -17,6 +17,7 @@ from mcpgateway.plugins.framework.base import Plugin, PluginRef from mcpgateway.plugins.framework.models import HookType +# Use standard logging to avoid circular imports (plugins -> services -> plugins) logger = logging.getLogger(__name__) diff --git a/mcpgateway/services/completion_service.py b/mcpgateway/services/completion_service.py index 873a82565..658383936 100644 --- a/mcpgateway/services/completion_service.py +++ b/mcpgateway/services/completion_service.py @@ -18,7 +18,6 @@ """ # Standard -import logging from typing import Any, Dict, List # Third-Party @@ -29,8 +28,11 @@ from mcpgateway.db import Prompt as DbPrompt from mcpgateway.db import Resource as DbResource from mcpgateway.models import CompleteResult +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class CompletionError(Exception): diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 9e5dec4d4..5d665b46c 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -71,13 +71,17 @@ from mcpgateway.db import SessionLocal from mcpgateway.db import Tool as DbTool from mcpgateway.schemas import GatewayCreate, GatewayRead, GatewayUpdate, ToolCreate + +# logging.getLogger("httpx").setLevel(logging.WARNING) # Disables httpx logs for regular health checks +from mcpgateway.services.logging_service import LoggingService from mcpgateway.services.tool_service import ToolService from mcpgateway.utils.create_slug import slugify from mcpgateway.utils.retry_manager import ResilientHttpClient from mcpgateway.utils.services_auth import decode_auth -# logging.getLogger("httpx").setLevel(logging.WARNING) # Disables httpx logs for regular health checks -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) GW_FAILURE_THRESHOLD = settings.unhealthy_threshold diff --git a/mcpgateway/services/logging_service.py b/mcpgateway/services/logging_service.py index 43ea75ed4..33b7d97ad 100644 --- a/mcpgateway/services/logging_service.py +++ b/mcpgateway/services/logging_service.py @@ -13,11 +13,76 @@ import asyncio from datetime import datetime, timezone import logging +from logging.handlers import RotatingFileHandler +import os from typing import Any, AsyncGenerator, Dict, List, Optional +# Third-Party +from pythonjsonlogger import jsonlogger # You may need to install python-json-logger package + # First-Party +from mcpgateway.config import settings from mcpgateway.models import LogLevel +# Create a text formatter +text_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + +# Create a JSON formatter +json_formatter = jsonlogger.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") + +# Note: Don't use basicConfig here as it conflicts with our custom dual logging setup +# The LoggingService.initialize() method will properly configure all handlers + +# Global handlers will be created lazily +_file_handler: Optional[logging.Handler] = None +_text_handler: Optional[logging.StreamHandler] = None + + +def _get_file_handler() -> logging.Handler: + """Get or create the file handler. + + Returns: + logging.Handler: Either a RotatingFileHandler or regular FileHandler for JSON logging. + + Raises: + ValueError: If file logging is disabled or no log file specified. + """ + global _file_handler # pylint: disable=global-statement + if _file_handler is None: + # Only create if file logging is enabled and file is specified + if not settings.log_to_file or not settings.log_file: + raise ValueError("File logging is disabled or no log file specified") + + # Ensure log folder exists + if settings.log_folder: + os.makedirs(settings.log_folder, exist_ok=True) + log_path = os.path.join(settings.log_folder, settings.log_file) + else: + log_path = settings.log_file + + # Create appropriate handler based on rotation settings + if settings.log_rotation_enabled: + max_bytes = settings.log_max_size_mb * 1024 * 1024 # Convert MB to bytes + _file_handler = RotatingFileHandler(log_path, maxBytes=max_bytes, backupCount=settings.log_backup_count, mode=settings.log_filemode) + else: + _file_handler = logging.FileHandler(log_path, mode=settings.log_filemode) + + _file_handler.setFormatter(json_formatter) + return _file_handler + + +def _get_text_handler() -> logging.StreamHandler: + """Get or create the text handler. + + Returns: + logging.StreamHandler: The stream handler for console logging. + """ + global _text_handler # pylint: disable=global-statement + if _text_handler is None: + _text_handler = logging.StreamHandler() + _text_handler.setFormatter(text_formatter) + return _text_handler + class LoggingService: """MCP logging service. @@ -44,12 +109,32 @@ async def initialize(self) -> None: >>> service = LoggingService() >>> asyncio.run(service.initialize()) """ - # Configure root logger - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - self._loggers[""] = logging.getLogger() + root_logger = logging.getLogger() + self._loggers[""] = root_logger + + # Clear existing handlers to avoid duplicates + root_logger.handlers.clear() + + # Always add console/text handler for stdout/stderr + root_logger.addHandler(_get_text_handler()) + + # Only add file handler if enabled + if settings.log_to_file and settings.log_file: + try: + root_logger.addHandler(_get_file_handler()) + if settings.log_rotation_enabled: + logging.info(f"File logging enabled with rotation: {settings.log_folder or '.'}/{settings.log_file} " f"(max: {settings.log_max_size_mb}MB, backups: {settings.log_backup_count})") + else: + logging.info(f"File logging enabled (no rotation): {settings.log_folder or '.'}/{settings.log_file}") + except Exception as e: + logging.warning(f"Failed to initialize file logging: {e}") + else: + logging.info("File logging disabled - logging to stdout/stderr only") + + # Configure uvicorn loggers to use our handlers (for access logs) + # Note: This needs to be done both at init and dynamically as uvicorn creates loggers later + self._configure_uvicorn_loggers() + logging.info("Logging service initialized") async def shutdown(self) -> None: @@ -85,6 +170,10 @@ def get_logger(self, name: str) -> logging.Logger: if name not in self._loggers: logger = logging.getLogger(name) + # Don't add handlers to child loggers - let them inherit from root + # This prevents duplicate logging while maintaining dual output (console + file) + logger.propagate = True + # Set level to match service level log_level = getattr(logging, self._level.upper()) logger.setLevel(log_level) @@ -150,7 +239,22 @@ async def notify(self, data: Any, level: LogLevel, logger_name: Optional[str] = # Log through standard logging logger = self.get_logger(logger_name or "") - log_func = getattr(logger, level.lower()) + + # Map MCP log levels to Python logging levels + # NOTICE, ALERT, and EMERGENCY don't have direct Python equivalents + level_map = { + LogLevel.DEBUG: "debug", + LogLevel.INFO: "info", + LogLevel.NOTICE: "info", # Map NOTICE to INFO + LogLevel.WARNING: "warning", + LogLevel.ERROR: "error", + LogLevel.CRITICAL: "critical", + LogLevel.ALERT: "critical", # Map ALERT to CRITICAL + LogLevel.EMERGENCY: "critical", # Map EMERGENCY to CRITICAL + } + + log_method = level_map.get(level, "info") + log_func = getattr(logger, log_method) log_func(data) # Notify subscribers @@ -201,3 +305,37 @@ def _should_log(self, level: LogLevel) -> bool: } return level_values[level] >= level_values[self._level] + + def _configure_uvicorn_loggers(self) -> None: + """Configure uvicorn loggers to use our dual logging setup. + + This method handles uvicorn's logging setup which can happen after our initialization. + Uvicorn creates its own loggers and handlers, so we need to redirect them to our setup. + """ + uvicorn_loggers = ["uvicorn", "uvicorn.access", "uvicorn.error", "uvicorn.asgi"] + + for logger_name in uvicorn_loggers: + uvicorn_logger = logging.getLogger(logger_name) + + # Clear any handlers that uvicorn may have added + uvicorn_logger.handlers.clear() + + # Make sure they propagate to root (which has our dual handlers) + uvicorn_logger.propagate = True + + # Set level to match our logging service level + if hasattr(self, "_level"): + log_level = getattr(logging, self._level.upper()) + uvicorn_logger.setLevel(log_level) + + # Track the logger + self._loggers[logger_name] = uvicorn_logger + + def configure_uvicorn_after_startup(self) -> None: + """Public method to reconfigure uvicorn loggers after server startup. + + Call this after uvicorn has started to ensure access logs go to dual output. + This handles the case where uvicorn creates loggers after our initialization. + """ + self._configure_uvicorn_loggers() + logging.info("Uvicorn loggers reconfigured for dual logging") diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index 8283bfb7d..8094ddd62 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -17,7 +17,6 @@ # Standard import asyncio from datetime import datetime, timezone -import logging from string import Formatter from typing import Any, AsyncGenerator, Dict, List, Optional, Set import uuid @@ -35,8 +34,11 @@ from mcpgateway.models import Message, PromptResult, Role, TextContent from mcpgateway.plugins import GlobalContext, PluginManager, PluginViolationError, PromptPosthookPayload, PromptPrehookPayload from mcpgateway.schemas import PromptCreate, PromptRead, PromptUpdate +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class PromptError(Exception): diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index e359a77b9..1030b74d3 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -27,7 +27,6 @@ # Standard import asyncio from datetime import datetime, timezone -import logging import mimetypes import re from typing import Any, AsyncGenerator, Dict, List, Optional, Union @@ -51,8 +50,11 @@ ResourceSubscription, ResourceUpdate, ) +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class ResourceError(Exception): diff --git a/mcpgateway/services/root_service.py b/mcpgateway/services/root_service.py index d11465f09..ac85f11e4 100644 --- a/mcpgateway/services/root_service.py +++ b/mcpgateway/services/root_service.py @@ -11,7 +11,6 @@ # Standard import asyncio -import logging import os from typing import AsyncGenerator, Dict, List, Optional from urllib.parse import urlparse @@ -19,8 +18,11 @@ # First-Party from mcpgateway.config import settings from mcpgateway.models import Root +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class RootServiceError(Exception): diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index ddb1f9e3c..96b7c37a5 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -15,7 +15,6 @@ # Standard import asyncio from datetime import datetime, timezone -import logging from typing import Any, AsyncGenerator, Dict, List, Optional # Third-Party @@ -32,8 +31,11 @@ from mcpgateway.db import ServerMetric from mcpgateway.db import Tool as DbTool from mcpgateway.schemas import ServerCreate, ServerMetrics, ServerRead, ServerUpdate +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class ServerError(Exception): diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index da21aaeba..2fd0812a1 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -19,7 +19,6 @@ import base64 from datetime import datetime, timezone import json -import logging import re import time from typing import Any, AsyncGenerator, Dict, List, Optional @@ -47,6 +46,7 @@ ToolRead, ToolUpdate, ) +from mcpgateway.services.logging_service import LoggingService from mcpgateway.utils.create_slug import slugify from mcpgateway.utils.passthrough_headers import get_passthrough_headers from mcpgateway.utils.retry_manager import ResilientHttpClient @@ -55,7 +55,9 @@ # Local from ..config import extract_using_jq -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class ToolError(Exception): diff --git a/mcpgateway/translate.py b/mcpgateway/translate.py index a9f33a050..6284efc9f 100644 --- a/mcpgateway/translate.py +++ b/mcpgateway/translate.py @@ -77,7 +77,12 @@ except ImportError: httpx = None # type: ignore[assignment] -LOGGER = logging.getLogger("mcpgateway.translate") +# First-Party +from mcpgateway.services.logging_service import LoggingService + +# Initialize logging service first +logging_service = LoggingService() +LOGGER = logging_service.get_logger("mcpgateway.translate") # Import settings for default keepalive interval try: diff --git a/mcpgateway/transports/sse_transport.py b/mcpgateway/transports/sse_transport.py index 5191dd11f..14356891b 100644 --- a/mcpgateway/transports/sse_transport.py +++ b/mcpgateway/transports/sse_transport.py @@ -13,7 +13,6 @@ import asyncio from datetime import datetime import json -import logging from typing import Any, AsyncGenerator, Dict import uuid @@ -23,9 +22,12 @@ # First-Party from mcpgateway.config import settings +from mcpgateway.services.logging_service import LoggingService from mcpgateway.transports.base import Transport -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class SSETransport(Transport): diff --git a/mcpgateway/transports/stdio_transport.py b/mcpgateway/transports/stdio_transport.py index 7c266f1ba..122cd6728 100644 --- a/mcpgateway/transports/stdio_transport.py +++ b/mcpgateway/transports/stdio_transport.py @@ -31,14 +31,16 @@ # Standard import asyncio import json -import logging import sys from typing import Any, AsyncGenerator, Dict, Optional # First-Party +from mcpgateway.services.logging_service import LoggingService from mcpgateway.transports.base import Transport -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class StdioTransport(Transport): diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index 718c2ea62..f42676111 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -34,7 +34,6 @@ from contextlib import asynccontextmanager, AsyncExitStack import contextvars from dataclasses import dataclass -import logging import re from typing import List, Union from uuid import uuid4 @@ -60,11 +59,13 @@ # First-Party from mcpgateway.config import settings from mcpgateway.db import SessionLocal +from mcpgateway.services.logging_service import LoggingService from mcpgateway.services.tool_service import ToolService from mcpgateway.utils.verify_credentials import verify_credentials -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) # Initialize ToolService and MCP Server tool_service = ToolService() diff --git a/mcpgateway/transports/websocket_transport.py b/mcpgateway/transports/websocket_transport.py index e778f171f..ffe3715c1 100644 --- a/mcpgateway/transports/websocket_transport.py +++ b/mcpgateway/transports/websocket_transport.py @@ -11,7 +11,6 @@ # Standard import asyncio -import logging from typing import Any, AsyncGenerator, Dict, Optional # Third-Party @@ -19,9 +18,12 @@ # First-Party from mcpgateway.config import settings +from mcpgateway.services.logging_service import LoggingService from mcpgateway.transports.base import Transport -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class WebSocketTransport(Transport): diff --git a/mcpgateway/utils/error_formatter.py b/mcpgateway/utils/error_formatter.py index ba264acef..9591cb313 100644 --- a/mcpgateway/utils/error_formatter.py +++ b/mcpgateway/utils/error_formatter.py @@ -25,14 +25,18 @@ """ # Standard -import logging from typing import Any, Dict # Third-Party from pydantic import ValidationError from sqlalchemy.exc import DatabaseError, IntegrityError -logger = logging.getLogger(__name__) +# First-Party +from mcpgateway.services.logging_service import LoggingService + +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class ErrorFormatter: diff --git a/mcpgateway/utils/verify_credentials.py b/mcpgateway/utils/verify_credentials.py index e24496a30..f843797f7 100644 --- a/mcpgateway/utils/verify_credentials.py +++ b/mcpgateway/utils/verify_credentials.py @@ -43,7 +43,6 @@ # Standard from base64 import b64decode import binascii -import logging from typing import Optional # Third-Party @@ -59,12 +58,14 @@ # First-Party from mcpgateway.config import settings +from mcpgateway.services.logging_service import LoggingService basic_security = HTTPBasic(auto_error=False) security = HTTPBearer(auto_error=False) -# Standard -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) async def verify_jwt_token(token: str) -> dict: diff --git a/mcpgateway/validators.py b/mcpgateway/validators.py index a242a54b6..ed3131d84 100644 --- a/mcpgateway/validators.py +++ b/mcpgateway/validators.py @@ -385,7 +385,7 @@ def validate_url(cls, value: str, field_name: str = "URL") -> str: hostname = result.hostname if hostname: # Block 0.0.0.0 (all interfaces) - if hostname == "0.0.0.0": + if hostname == "0.0.0.0": # nosec B104 - we're blocking this for security raise ValueError(f"{field_name} contains invalid IP address (0.0.0.0)") # Block AWS metadata service diff --git a/mcpgateway/wrapper.py b/mcpgateway/wrapper.py index 8361c3ef0..e9816fc9b 100644 --- a/mcpgateway/wrapper.py +++ b/mcpgateway/wrapper.py @@ -51,6 +51,7 @@ # First-Party from mcpgateway import __version__ +from mcpgateway.services.logging_service import LoggingService from mcpgateway.utils.retry_manager import ResilientHttpClient # ----------------------------------------------------------------------------- @@ -148,7 +149,9 @@ def _extract_base_url(url: str) -> str: stream=sys.stderr, ) -logger = logging.getLogger("mcpgateway.wrapper") +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger("mcpgateway.wrapper") logger.info(f"Starting MCP wrapper {__version__}: base_url={BASE_URL}, timeout={TOOL_CALL_TIMEOUT}") diff --git a/plugins/deny_filter/deny.py b/plugins/deny_filter/deny.py index 029d0d415..339c928ff 100644 --- a/plugins/deny_filter/deny.py +++ b/plugins/deny_filter/deny.py @@ -17,8 +17,11 @@ from mcpgateway.plugins.framework.base import Plugin from mcpgateway.plugins.framework.models import PluginConfig, PluginViolation from mcpgateway.plugins.framework.plugin_types import PluginContext, PromptPrehookPayload, PromptPrehookResult +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class DenyListConfig(BaseModel): diff --git a/plugins/pii_filter/pii_filter.py b/plugins/pii_filter/pii_filter.py index f60a14b18..3aa34aad6 100644 --- a/plugins/pii_filter/pii_filter.py +++ b/plugins/pii_filter/pii_filter.py @@ -32,8 +32,11 @@ ToolPostInvokePayload, ToolPostInvokeResult, ) +from mcpgateway.services.logging_service import LoggingService -logger = logging.getLogger(__name__) +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class PIIType(str, Enum): diff --git a/pyproject.toml b/pyproject.toml index 29c605340..c0d783b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dependencies = [ "pydantic>=2.11.7", "pydantic-settings>=2.10.1", "pyjwt>=2.10.1", + "python-json-logger>=2.0.0", "PyYAML>=6.0.2", "sqlalchemy>=2.0.42", "sse-starlette>=3.0.2", diff --git a/tests/unit/mcpgateway/services/test_logging_service_comprehensive.py b/tests/unit/mcpgateway/services/test_logging_service_comprehensive.py new file mode 100644 index 000000000..a95a8a452 --- /dev/null +++ b/tests/unit/mcpgateway/services/test_logging_service_comprehensive.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +""" +Comprehensive unit tests for LoggingService to improve coverage. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +""" + +# Standard +import asyncio +import logging +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch, PropertyMock + +# Third-Party +import pytest + +# First-Party +from mcpgateway.models import LogLevel +from mcpgateway.services.logging_service import LoggingService, _get_file_handler, _get_text_handler + + +# --------------------------------------------------------------------------- +# Test file handler creation +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_file_handler_creation_with_rotation(): + """Test that file handler is created with rotation when enabled.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = "test.log" + log_folder = tmpdir + + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = log_file + mock_settings.log_folder = log_folder + mock_settings.log_rotation_enabled = True + mock_settings.log_max_size_mb = 1 + mock_settings.log_backup_count = 3 + mock_settings.log_filemode = "a" + + handler = _get_file_handler() + assert handler is not None + assert handler.maxBytes == 1 * 1024 * 1024 # 1MB + assert handler.backupCount == 3 + + +@pytest.mark.asyncio +async def test_file_handler_creation_without_rotation(): + """Test that file handler is created without rotation when disabled.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = "test.log" + log_folder = tmpdir + + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = log_file + mock_settings.log_folder = log_folder + mock_settings.log_rotation_enabled = False + mock_settings.log_filemode = "a" + + # Reset global handler + import mcpgateway.services.logging_service as ls + ls._file_handler = None + + handler = _get_file_handler() + assert handler is not None + assert not hasattr(handler, 'maxBytes') # Regular FileHandler doesn't have this + + +@pytest.mark.asyncio +async def test_file_handler_raises_when_disabled(): + """Test that file handler raises ValueError when file logging is disabled.""" + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = False + + # Reset global handler + import mcpgateway.services.logging_service as ls + ls._file_handler = None + + with pytest.raises(ValueError, match="File logging is disabled"): + _get_file_handler() + + +@pytest.mark.asyncio +async def test_text_handler_creation(): + """Test that text handler is created properly.""" + handler = _get_text_handler() + assert handler is not None + assert isinstance(handler, logging.StreamHandler) + assert handler.formatter is not None + + +# --------------------------------------------------------------------------- +# Test LoggingService initialization +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_initialize_with_file_logging_enabled(): + """Test LoggingService initialization with file logging enabled.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = "test.log" + mock_settings.log_folder = tmpdir + mock_settings.log_rotation_enabled = True + mock_settings.log_max_size_mb = 2 + mock_settings.log_backup_count = 3 + mock_settings.log_filemode = "a" + + service = LoggingService() + await service.initialize() + + root_logger = logging.getLogger() + # Should have both text and file handlers + handler_types = [type(h).__name__ for h in root_logger.handlers] + assert 'StreamHandler' in handler_types + assert 'RotatingFileHandler' in handler_types + + await service.shutdown() + + +@pytest.mark.asyncio +async def test_initialize_with_file_logging_disabled(): + """Test LoggingService initialization with file logging disabled.""" + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = False + mock_settings.log_file = None + + service = LoggingService() + await service.initialize() + + root_logger = logging.getLogger() + # Should only have text handler + handler_types = [type(h).__name__ for h in root_logger.handlers] + assert 'StreamHandler' in handler_types + + await service.shutdown() + + +@pytest.mark.asyncio +async def test_initialize_with_file_logging_error(): + """Test LoggingService handles file logging initialization errors gracefully.""" + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = "/invalid/path/test.log" + mock_settings.log_folder = "/invalid/path" + mock_settings.log_rotation_enabled = False + mock_settings.log_filemode = "a" + + # Mock the file handler to raise an exception + with patch("mcpgateway.services.logging_service._get_file_handler", side_effect=Exception("Cannot create file")): + service = LoggingService() + await service.initialize() # Should not raise, just log warning + + await service.shutdown() + + +# --------------------------------------------------------------------------- +# Test uvicorn logger configuration +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_configure_uvicorn_loggers(): + """Test that uvicorn loggers are configured properly.""" + service = LoggingService() + service._configure_uvicorn_loggers() + + uvicorn_loggers = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'uvicorn.asgi'] + for logger_name in uvicorn_loggers: + logger = logging.getLogger(logger_name) + assert logger.propagate == True + assert len(logger.handlers) == 0 # Handlers cleared + assert logger_name in service._loggers + + +@pytest.mark.asyncio +async def test_configure_uvicorn_after_startup(): + """Test public method to reconfigure uvicorn loggers after startup.""" + service = LoggingService() + + with patch.object(service, '_configure_uvicorn_loggers') as mock_config: + service.configure_uvicorn_after_startup() + mock_config.assert_called_once() + + +# --------------------------------------------------------------------------- +# Test log level management +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_level_updates_all_loggers(): + """Test that set_level updates all registered loggers.""" + service = LoggingService() + + # Create some loggers + logger1 = service.get_logger("test1") + logger2 = service.get_logger("test2") + + # Change level to ERROR + await service.set_level(LogLevel.ERROR) + + # All loggers should be updated + assert logger1.level == logging.ERROR + assert logger2.level == logging.ERROR + assert service._level == LogLevel.ERROR + + +@pytest.mark.asyncio +async def test_should_log_all_levels(): + """Test _should_log for all log levels.""" + service = LoggingService() + + # Test each level (NOTICE, ALERT, EMERGENCY are also valid levels) + test_cases = [ + (LogLevel.DEBUG, [LogLevel.DEBUG, LogLevel.INFO, LogLevel.NOTICE, LogLevel.WARNING, LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + (LogLevel.INFO, [LogLevel.INFO, LogLevel.NOTICE, LogLevel.WARNING, LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + (LogLevel.NOTICE, [LogLevel.NOTICE, LogLevel.WARNING, LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + (LogLevel.WARNING, [LogLevel.WARNING, LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + (LogLevel.ERROR, [LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + (LogLevel.CRITICAL, [LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]), + ] + + for min_level, should_pass in test_cases: + service._level = min_level + for level in [LogLevel.DEBUG, LogLevel.INFO, LogLevel.NOTICE, LogLevel.WARNING, + LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]: + if level in should_pass: + assert service._should_log(level), f"{level} should log at {min_level}" + else: + assert not service._should_log(level), f"{level} should not log at {min_level}" + + +# --------------------------------------------------------------------------- +# Test notify with different scenarios +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_notify_with_logger_name(): + """Test notify with a specific logger name.""" + service = LoggingService() + + with patch.object(service, 'get_logger') as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + await service.notify("test message", LogLevel.INFO, logger_name="custom.logger") + + mock_get_logger.assert_called_with("custom.logger") + mock_logger.info.assert_called_with("test message") + + +@pytest.mark.asyncio +async def test_notify_without_logger_name(): + """Test notify without a specific logger name uses root logger.""" + service = LoggingService() + + with patch.object(service, 'get_logger') as mock_get_logger: + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + await service.notify("test message", LogLevel.WARNING) + + mock_get_logger.assert_called_with("") + mock_logger.warning.assert_called_with("test message") + + +@pytest.mark.asyncio +async def test_notify_with_failed_subscriber(): + """Test notify handles failed subscriber gracefully.""" + service = LoggingService() + + # Create a mock queue that raises an exception + mock_queue = MagicMock() + mock_queue.put = MagicMock(side_effect=Exception("Queue error")) + service._subscribers.append(mock_queue) + + # Should not raise, just log the error + await service.notify("test message", LogLevel.ERROR) + + +# --------------------------------------------------------------------------- +# Test get_logger with file handler scenarios +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_logger_with_file_handler_error(): + """Test get_logger handles file handler errors gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = "test.log" + mock_settings.log_folder = tmpdir + + service = LoggingService() + + # Mock file handler to raise exception + with patch("mcpgateway.services.logging_service._get_file_handler", side_effect=Exception("File error")): + logger = service.get_logger("test.logger") + + # Logger should still be created despite file handler error + assert logger is not None + assert logger.name == "test.logger" + + +@pytest.mark.asyncio +async def test_get_logger_reuses_existing(): + """Test get_logger returns existing logger instance.""" + service = LoggingService() + + logger1 = service.get_logger("test.app") + logger2 = service.get_logger("test.app") + + assert logger1 is logger2 + assert len(service._loggers) == 1 + + +# --------------------------------------------------------------------------- +# Test shutdown +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_shutdown_clears_subscribers(): + """Test shutdown clears all subscribers.""" + service = LoggingService() + + # Add some mock subscribers + service._subscribers.append(MagicMock()) + service._subscribers.append(MagicMock()) + + assert len(service._subscribers) == 2 + + await service.shutdown() + + assert len(service._subscribers) == 0 + + +# --------------------------------------------------------------------------- +# Integration test with real file writing +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dual_logging_integration(): + """Integration test for dual logging to console and file.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_file = os.path.join(tmpdir, "integration.log") + + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = "integration.log" + mock_settings.log_folder = tmpdir + mock_settings.log_rotation_enabled = False + mock_settings.log_filemode = "w" + + # Reset global handlers + import mcpgateway.services.logging_service as ls + ls._file_handler = None + ls._text_handler = None + + service = LoggingService() + await service.initialize() + + # Log some messages + logger = service.get_logger("integration.test") + logger.info("Integration test message") + logger.error("Integration error message") + + # Configure uvicorn loggers + service.configure_uvicorn_after_startup() + uvicorn_logger = logging.getLogger("uvicorn.access") + uvicorn_logger.info("127.0.0.1:8000 - \"GET /test HTTP/1.1\" 200") + + await service.shutdown() + + # Check file was created and contains expected content + assert os.path.exists(log_file) + with open(log_file, 'r') as f: + content = f.read() + assert "Integration test message" in content + assert "Integration error message" in content + assert "GET /test HTTP/1.1" in content + assert "json" in content.lower() or "{" in content # Should be JSON formatted + + +# --------------------------------------------------------------------------- +# Test edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_logger_with_empty_name(): + """Test get_logger with empty name returns root logger.""" + service = LoggingService() + + logger = service.get_logger("") + assert logger.name == "root" + + +@pytest.mark.asyncio +async def test_notify_with_all_log_levels(): + """Test notify works with all log level values including special ones.""" + service = LoggingService() + + # Test all levels including NOTICE, ALERT, EMERGENCY + # which are now mapped to appropriate Python levels + for level in [LogLevel.DEBUG, LogLevel.INFO, LogLevel.NOTICE, LogLevel.WARNING, + LogLevel.ERROR, LogLevel.CRITICAL, LogLevel.ALERT, LogLevel.EMERGENCY]: + await service.notify(f"Test {level}", level) + # Should not raise any exceptions now that we have proper mapping + + +@pytest.mark.asyncio +async def test_file_handler_creates_directory(): + """Test that file handler creates log directory if it doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + log_folder = os.path.join(tmpdir, "new_logs") + + with patch("mcpgateway.services.logging_service.settings") as mock_settings: + mock_settings.log_to_file = True + mock_settings.log_file = "test.log" + mock_settings.log_folder = log_folder + mock_settings.log_rotation_enabled = False + mock_settings.log_filemode = "a" + + # Reset global handler + import mcpgateway.services.logging_service as ls + ls._file_handler = None + + handler = _get_file_handler() + assert handler is not None + assert os.path.exists(log_folder) \ No newline at end of file