File size: 7,470 Bytes
2129c29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""
Logging configuration for NLProxy.

Features:
- Environment-aware configuration (dev vs production)
- Structured JSON logging for production (machine-readable, ELK/Datadog ready)
- Pretty/colored logging for development (human-readable)
- Request/context binding via thread-local contextual filters
- Log rotation, filtering, and multi-handler support
- Thread-safe and async-compatible

Usage:
>>> from nlproxy.utils.logger import setup_logging, get_request_logger
>>> setup_logging(level="INFO")
>>> logger = get_request_logger("proxy")
>>> ContextFilter.set_context(request_id="abc123")
>>> logger.info("Request processed")

Author: IntelliDeep Labs Team
License: BSL 1.1
"""

from __future__ import annotations

import json
import logging
import os
import sys
import threading
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Any, Dict, Optional


# =============================================================================
# CONTEXT FILTER (Binds request_id, trace_id, etc. to all log records)
# =============================================================================
class ContextFilter(logging.Filter):
    """
    Injects contextual fields (request_id, user_id, etc.) into log records.
    Thread-safe via thread-local storage. Compatible with FastAPI/Starlette middleware.
    """
    _context: threading.local = threading.local()

    def filter(self, record: logging.LogRecord) -> bool:
        ctx = getattr(self._context, "data", {})
        for key, value in ctx.items():
            setattr(record, key, value)
        return True

    @classmethod
    def set_context(cls, **kwargs: Any) -> None:
        """Set context variables for the current thread."""
        cls._context.data = kwargs

    @classmethod
    def get_context(cls) -> Dict[str, Any]:
        """Retrieve current thread context."""
        return getattr(cls._context, "data", {})

    @classmethod
    def clear_context(cls) -> None:
        """Clear thread context (call at request teardown)."""
        cls._context.data = {}


# =============================================================================
# FORMATTERS
# =============================================================================
class JSONFormatter(logging.Formatter):
    """
    Production-ready JSON formatter for structured logging.
    Outputs parseable JSON objects compatible with observability platforms.
    """
    def format(self, record: logging.LogRecord) -> str:
        log_obj = {
            "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
            "line": record.lineno,
        }
        # Inject context fields
        for key, value in record.__dict__.items():
            if key not in log_obj and not key.startswith("_"):
                log_obj[key] = value
        # Handle exceptions
        if record.exc_info and record.exc_info[0] is not None:
            log_obj["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_obj, default=str, ensure_ascii=False)


class PrettyFormatter(logging.Formatter):
    """
    Human-readable formatter for development/debugging.
    Includes ANSI colors if supported by the terminal.
    """
    COLORS = {
        logging.DEBUG: "\033[36m",
        logging.INFO: "\033[32m",
        logging.WARNING: "\033[33m",
        logging.ERROR: "\033[31m",
        logging.CRITICAL: "\033[1;31m",
    }
    RESET = "\033[0m"

    def format(self, record: logging.LogRecord) -> str:
        color = self.COLORS.get(record.levelno, self.RESET)
        ctx = f" [{record.request_id}]" if hasattr(record, "request_id") else ""
        ts = datetime.fromtimestamp(record.created, tz=timezone.utc).strftime("%H:%M:%S")
        return (
            f"{color}[{ts}][{record.levelname}]{self.RESET} "
            f"{record.name}{ctx}: {record.getMessage()}"
        )


# =============================================================================
# CORE SETUP
# =============================================================================
_initialized = False
_root_logger = logging.getLogger("nlproxy")


def setup_logging(
    level: str = "INFO",
    format_type: str = "auto",
    log_dir: Optional[str] = None,
    max_bytes: int = 50 * 1024 * 1024,  # 50MB
    backup_count: int = 5,
    disable_existing: bool = True,
) -> None:
    """
    Initialize logging for the nlproxy application.

    Parameters
    ----------
    level : str
        Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL.
    format_type : str
        "json" for production, "pretty" for dev, "auto" detects environment.
    log_dir : Optional[str]
        Directory for log files. If None, only console logging is used.
    max_bytes : int
        Max size per log file before rotation.
    backup_count : int
        Number of rotated log files to keep.
    disable_existing : bool
        Whether to clear handlers from third-party libraries.
    """
    global _initialized
    if _initialized:
        return

    numeric_level = getattr(logging, level.upper(), logging.INFO)
    env = os.getenv("NLPROXY_ENV", "development").lower()

    if format_type == "auto":
        format_type = "json" if env in ("production", "prod", "staging") else "pretty"

    # Configure root logger
    _root_logger.setLevel(numeric_level)
    _root_logger.propagate = False

    if disable_existing:
        for handler in logging.root.handlers[:]:
            logging.root.removeHandler(handler)

    # Add context filter to root
    context_filter = ContextFilter()
    _root_logger.addFilter(context_filter)

    # Console handler
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(numeric_level)
    console_handler.setFormatter(JSONFormatter() if format_type == "json" else PrettyFormatter())
    _root_logger.addHandler(console_handler)

    # File handler (optional)
    if log_dir:
        os.makedirs(log_dir, exist_ok=True)
        log_file = os.path.join(log_dir, f"nlproxy_{env}.log")
        file_handler = RotatingFileHandler(
            log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
        )
        file_handler.setLevel(numeric_level)
        file_handler.setFormatter(JSONFormatter())  # Always JSON for files
        _root_logger.addHandler(file_handler)

    # Silence noisy third-party loggers
    noisy_loggers = ["urllib3", "httpx", "httpcore", "asyncio", "watchfiles", "uvicorn.access"]
    for name in noisy_loggers:
        logging.getLogger(name).setLevel(logging.WARNING)

    _initialized = True
    _root_logger.info(f"Logging initialized: level={level}, format={format_type}, env={env}")


def get_request_logger(name: str) -> logging.LoggerAdapter:
    """
    Returns a logger adapter bound to request context.

    Automatically injects request_id, user_id, or any context set via
    ContextFilter.set_context().

    Parameters
    ----------
    name : str
        Logger name (typically module __name__ or "proxy", "service", etc.).

    Returns
    -------
    logging.LoggerAdapter
        Context-aware logger.
    """
    base_logger = logging.getLogger(f"nlproxy.{name}")
    return logging.LoggerAdapter(base_logger, extra=ContextFilter.get_context())