Skip to content
Merged

Dev #120

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions nya/common/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
One logging configuration for the whole process.

Everything logs through the standard library — NyaProxy, Uvicorn, Starlette,
watchfiles, httpx, and nacho — so a single root handler gives every line the
same shape without any bridging between logging frameworks.

The format deliberately stops at the logger name:

2026-07-19 23:08:44.076 | INFO | nya.server.app - Setting up routes
2026-07-19 23:08:50.770 | INFO | uvicorn.access - 127.0.0.1 - "GET /health" 200

The emitting module, function, and line are omitted: the name already says
which subsystem spoke, and anything worth locating precisely arrives with a
traceback that carries the exact position.
"""

import logging
import logging.handlers
import os
import sys
from typing import Optional

#: Shared line format. ``levelcolor`` is filled in by the formatter so the
#: level can be padded before any escape codes are added.
LOG_FORMAT = "%(asctime)s.%(msecs)03d | %(levelcolor)s | %(name)s - %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"

#: Size-based rotation, matching what the configuration documents.
_MAX_BYTES = 10 * 1024 * 1024
_BACKUP_COUNT = 5

_RESET = "\033[0m"
_LEVEL_COLORS = {
"DEBUG": "\033[36m", # cyan
"INFO": "\033[32m", # green
"WARNING": "\033[33m", # yellow
"ERROR": "\033[31m", # red
"CRITICAL": "\033[1;31m", # bold red
}

#: Third-party loggers that install handlers or pin levels of their own.
_MANAGED_LOGGERS = (
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"uvicorn.asgi",
"fastapi",
"starlette",
"watchfiles",
"watchfiles.main",
"httpx",
"httpcore",
"nacho",
)


class Formatter(logging.Formatter):
"""Shared formatter, optionally colouring the level for a terminal."""

def __init__(self, *, color: bool = False) -> None:
super().__init__(LOG_FORMAT, datefmt=DATE_FORMAT)
self.color = color

def format(self, record: logging.LogRecord) -> str:
# Pad first, then colour: escape codes count towards a %-width and
# would otherwise break the column alignment.
level = f"{record.levelname:<8}"
if self.color:
level = f"{_LEVEL_COLORS.get(record.levelname, '')}{level}{_RESET}"
record.levelcolor = level
return super().format(record)


def _supports_color(stream) -> bool:
if os.environ.get("NO_COLOR"):
return False
return bool(getattr(stream, "isatty", lambda: False)())


def configure_logging(
level: str = "INFO",
log_file: Optional[str] = None,
enabled: bool = True,
) -> None:
"""
Install the shared configuration. Safe to call more than once.

Called at import so Uvicorn's startup lines are covered, and again once
the configuration file has been read.
"""
normalized = (level or "INFO").upper()
root = logging.getLogger()

for handler in root.handlers[:]:
root.removeHandler(handler)
handler.close()

if not enabled:
root.addHandler(logging.NullHandler())
root.setLevel(logging.CRITICAL + 1)
_reset_managed_loggers()
return

stream = logging.StreamHandler(sys.stderr)
stream.setFormatter(Formatter(color=_supports_color(sys.stderr)))
root.addHandler(stream)

if log_file:
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setFormatter(Formatter(color=False))
root.addHandler(file_handler)

root.setLevel(logging.getLevelName(normalized))
_reset_managed_loggers()


def _reset_managed_loggers() -> None:
"""
Hand the managed loggers back to the root configuration.

Uvicorn attaches its own handlers and pins an explicit level (that is what
--log-level does). An explicit level beats the root's, so leaving one in
place would silently override the level from the configuration file.
"""
for name in _MANAGED_LOGGERS:
std_logger = logging.getLogger(name)
for handler in std_logger.handlers[:]:
std_logger.removeHandler(handler)
std_logger.propagate = True
std_logger.setLevel(logging.NOTSET)
4 changes: 3 additions & 1 deletion nya/config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@
Configuration manager for NyaProxy using Nacho.
"""

import logging
import os
import re
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union

from loguru import logger
from nacho import FileStorageBackend, Nacho, NachoOrchestrator, RemoteStorageBackend

from nya.common.constants import DEFAULT_HOST, DEFAULT_PORT
from nya.common.exceptions import ConfigurationError

logger = logging.getLogger(__name__)

T = TypeVar("T")

BUILTIN_API_DEFAULTS: Dict[str, Any] = {
Expand Down
19 changes: 17 additions & 2 deletions nya/core/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
Request handler for intercepting and forwarding HTTP requests with token rotation.
"""

import logging
import random
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse

import orjson
from loguru import logger

from ..common.constants import API_PATH_PREFIX
from ..common.exceptions import MissingAPIKeyError, VariablesConfigurationError
from ..common.models import ProxyRequest
from ..utils.header import HeaderUtils
from ..utils.substitution import apply_body_substitutions

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..config.manager import ConfigManager

Expand Down Expand Up @@ -67,7 +69,20 @@ def prepare_request(self, request: ProxyRequest) -> None:
)
if HeaderUtils.is_trusted_proxy(request.ip, trusted_proxies):
proxy_ip = HeaderUtils.parse_source_ip_address(request.headers)
request.ip = proxy_ip if proxy_ip else request.ip
if proxy_ip:
# The access log only ever shows the socket peer, so without
# this there is no way to tell whether forwarding headers are
# being honoured or every client is sharing one quota.
logger.debug(
f"Resolved client IP {proxy_ip} from headers "
f"(trusted peer {request.ip})"
)
request.ip = proxy_ip
elif trusted_proxies:
logger.debug(
f"Peer {request.ip} is not in trusted_proxies; "
"forwarding headers ignored and quotas apply to the peer"
)

request.user = self.get_proxy_api_key(request)

Expand Down
4 changes: 3 additions & 1 deletion nya/core/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
"""

import asyncio
import logging
import math
import random
import traceback
from typing import TYPE_CHECKING, Dict, Optional, Union

import httpx
from loguru import logger
from starlette.responses import JSONResponse, Response, StreamingResponse

from ..common.exceptions import (
Expand All @@ -24,6 +24,8 @@
from .queue import RequestQueue
from .request import RequestExecutor

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..common.models import ProxyRequest
from ..config.manager import ConfigManager
Expand Down
5 changes: 3 additions & 2 deletions nya/core/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
"""

import asyncio
import logging
import random
import time
import traceback
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional, Tuple

from loguru import logger

from ..common.exceptions import (
QueueFullError,
ReachedMaxQuotaError,
Expand All @@ -18,6 +17,8 @@
)
from ..utils.redaction import mask_secret

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from starlette.responses import Response

Expand Down
12 changes: 10 additions & 2 deletions nya/core/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
Simplified request executor focused on HTTP execution only.
"""

import logging
import time
from typing import TYPE_CHECKING, Optional, Union

import httpx
from loguru import logger
from starlette.responses import JSONResponse, Response, StreamingResponse

from ..utils.formatting import format_elapsed_time, json_safe_dumps
Expand All @@ -18,6 +18,8 @@
handle_streaming_response,
)

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..common.models import ProxyRequest
from ..config.manager import ConfigManager
Expand Down Expand Up @@ -84,7 +86,9 @@ async def execute(
track = bool(self.metrics_collector)

if track:
self.metrics_collector.record_request(api_name, request.api_key)
self.metrics_collector.record_request(
api_name, request.api_key, request.trail_path
)

logger.debug(f"[Request] Method: {request.method.upper()}, URL: {request.url}")

Expand All @@ -100,6 +104,7 @@ async def execute(
request.api_key,
0,
time.time() - actual_start_time,
request.trail_path,
)
raise

Expand Down Expand Up @@ -128,6 +133,7 @@ async def execute(
request.api_key,
response.status_code,
time.time() - actual_start_time,
request.trail_path,
)
)
return streaming
Expand All @@ -141,6 +147,7 @@ async def execute(
request.api_key,
0,
time.time() - actual_start_time,
request.trail_path,
)
raise

Expand All @@ -150,6 +157,7 @@ async def execute(
request.api_key,
response.status_code,
time.time() - actual_start_time,
request.trail_path,
)
return result

Expand Down
4 changes: 3 additions & 1 deletion nya/core/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
Streaming response handling utilities for NyaProxy.
"""

import logging
import traceback
from collections.abc import Callable
from typing import Awaitable, Optional

import httpx
from loguru import logger
from starlette.responses import StreamingResponse

logger = logging.getLogger(__name__)

__all__ = [
"handle_streaming_response",
"detect_streaming_content",
Expand Down
4 changes: 3 additions & 1 deletion nya/dashboard/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
"""

import importlib.resources
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Optional

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from loguru import logger

from .._version import __version__
from .routes import (
Expand All @@ -24,6 +24,8 @@
register_queue_routes,
)

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..config.manager import ConfigManager
from ..core.queue import RequestQueue
Expand Down
4 changes: 3 additions & 1 deletion nya/dashboard/routes/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
``enable_control=True``.
"""

import logging
from typing import TYPE_CHECKING

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from loguru import logger

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..api import DashboardAPI
Expand Down
4 changes: 3 additions & 1 deletion nya/dashboard/routes/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
via the ``/metrics`` endpoint.
"""

import logging
from typing import TYPE_CHECKING, Any, Dict, Optional

from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
from loguru import logger

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from ..api import DashboardAPI
Expand Down
Loading
Loading