diff --git a/nya/common/logging.py b/nya/common/logging.py
new file mode 100644
index 0000000..033d442
--- /dev/null
+++ b/nya/common/logging.py
@@ -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)
diff --git a/nya/config/manager.py b/nya/config/manager.py
index 38ef890..8e500cf 100644
--- a/nya/config/manager.py
+++ b/nya/config/manager.py
@@ -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] = {
diff --git a/nya/core/handler.py b/nya/core/handler.py
index 3052954..735bf25 100644
--- a/nya/core/handler.py
+++ b/nya/core/handler.py
@@ -2,12 +2,12 @@
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
@@ -15,6 +15,8 @@
from ..utils.header import HeaderUtils
from ..utils.substitution import apply_body_substitutions
+logger = logging.getLogger(__name__)
+
if TYPE_CHECKING:
from ..config.manager import ConfigManager
@@ -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)
diff --git a/nya/core/proxy.py b/nya/core/proxy.py
index 258b8b2..31a09e3 100644
--- a/nya/core/proxy.py
+++ b/nya/core/proxy.py
@@ -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 (
@@ -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
diff --git a/nya/core/queue.py b/nya/core/queue.py
index 3cfb214..0bd7a10 100644
--- a/nya/core/queue.py
+++ b/nya/core/queue.py
@@ -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,
@@ -18,6 +17,8 @@
)
from ..utils.redaction import mask_secret
+logger = logging.getLogger(__name__)
+
if TYPE_CHECKING:
from starlette.responses import Response
diff --git a/nya/core/request.py b/nya/core/request.py
index 2e4c905..c595484 100644
--- a/nya/core/request.py
+++ b/nya/core/request.py
@@ -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
@@ -18,6 +18,8 @@
handle_streaming_response,
)
+logger = logging.getLogger(__name__)
+
if TYPE_CHECKING:
from ..common.models import ProxyRequest
from ..config.manager import ConfigManager
@@ -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}")
@@ -100,6 +104,7 @@ async def execute(
request.api_key,
0,
time.time() - actual_start_time,
+ request.trail_path,
)
raise
@@ -128,6 +133,7 @@ async def execute(
request.api_key,
response.status_code,
time.time() - actual_start_time,
+ request.trail_path,
)
)
return streaming
@@ -141,6 +147,7 @@ async def execute(
request.api_key,
0,
time.time() - actual_start_time,
+ request.trail_path,
)
raise
@@ -150,6 +157,7 @@ async def execute(
request.api_key,
response.status_code,
time.time() - actual_start_time,
+ request.trail_path,
)
return result
diff --git a/nya/core/streaming.py b/nya/core/streaming.py
index 7babfee..45eeca9 100644
--- a/nya/core/streaming.py
+++ b/nya/core/streaming.py
@@ -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",
diff --git a/nya/dashboard/api.py b/nya/dashboard/api.py
index f97b8b6..bf621aa 100644
--- a/nya/dashboard/api.py
+++ b/nya/dashboard/api.py
@@ -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 (
@@ -24,6 +24,8 @@
register_queue_routes,
)
+logger = logging.getLogger(__name__)
+
if TYPE_CHECKING:
from ..config.manager import ConfigManager
from ..core.queue import RequestQueue
diff --git a/nya/dashboard/routes/control.py b/nya/dashboard/routes/control.py
index 936cab8..0027a34 100644
--- a/nya/dashboard/routes/control.py
+++ b/nya/dashboard/routes/control.py
@@ -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
diff --git a/nya/dashboard/routes/history.py b/nya/dashboard/routes/history.py
index fda9a1a..6db38a1 100644
--- a/nya/dashboard/routes/history.py
+++ b/nya/dashboard/routes/history.py
@@ -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
diff --git a/nya/dashboard/routes/metrics.py b/nya/dashboard/routes/metrics.py
index 13e6a61..54df321 100644
--- a/nya/dashboard/routes/metrics.py
+++ b/nya/dashboard/routes/metrics.py
@@ -2,11 +2,13 @@
Metrics routes: aggregate metrics, per-API metrics, and key-usage stats.
"""
+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
diff --git a/nya/dashboard/routes/queue.py b/nya/dashboard/routes/queue.py
index 091daf9..aa6cbc5 100644
--- a/nya/dashboard/routes/queue.py
+++ b/nya/dashboard/routes/queue.py
@@ -2,11 +2,13 @@
Read-only queue-status route.
"""
+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
diff --git a/nya/html/index.html b/nya/html/index.html
index 576be6b..041f9bb 100644
--- a/nya/html/index.html
+++ b/nya/html/index.html
@@ -254,15 +254,16 @@
Recent requests Time
API
+
Path
Status
Latency
Key
-
-
-
+
+
+
diff --git a/nya/html/static/css/styles.css b/nya/html/static/css/styles.css
index b1b65bd..1d65c52 100644
--- a/nya/html/static/css/styles.css
+++ b/nya/html/static/css/styles.css
@@ -2210,6 +2210,55 @@ table.data tbody > tr:not(.detail-row):hover {
color: var(--text-2);
}
+/* Paths are the widest, least predictable column: give it the slack and
+ truncate from the left so the distinguishing tail stays visible. */
+/* Constrain the button, not the cell: a max-width on a
is advisory
+ under automatic table layout, so the column would still push its
+ neighbours out. */
+.cell-path .cell-link {
+ display: block;
+ max-width: 190px;
+ direction: rtl;
+ text-align: left;
+}
+
+/* The masked key is short and fixed-width; never let it collapse. */
+.table-history td:last-child,
+.table-history th:last-child {
+ white-space: nowrap;
+}
+
+.cell-path .cell-link::before {
+ content: "\200E"; /* keep the leading slash on the left under rtl */
+}
+
+.path-table {
+ margin-top: var(--sp-4);
+}
+
+.path-table th {
+ padding: 0 0 var(--sp-1);
+ color: var(--text-3);
+ font-size: 10.5px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ text-align: left;
+}
+
+.path-table th.col-num {
+ text-align: right;
+}
+
+.path-table .path-filter-btn {
+ max-width: 100%;
+ padding: 0;
+ overflow: hidden;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* Latency reads against the slowest response currently on screen. */
.cell-latency {
position: relative;
@@ -2410,8 +2459,11 @@ table.data tbody > tr:not(.detail-row):hover {
flex: none;
}
- .panel-history table th:nth-child(5),
- .panel-history table td:nth-child(5) {
+ /* Drop the key, then the path: both are recoverable from the API detail. */
+ .panel-history table th:nth-child(6),
+ .panel-history table td:nth-child(6),
+ .panel-history table th:nth-child(3),
+ .panel-history table td:nth-child(3) {
display: none;
}
diff --git a/nya/html/static/js/dashboard.js b/nya/html/static/js/dashboard.js
index 87ae5f4..ef77503 100644
--- a/nya/html/static/js/dashboard.js
+++ b/nya/html/static/js/dashboard.js
@@ -47,6 +47,7 @@ const state = {
statusChip: "all",
historyApi: null, // cross-filter: show only this API in the traffic log
historyKey: null, // cross-filter: show only this credential
+ historyPath: null, // cross-filter: show only this upstream path
sort: { key: "requests", dir: "desc" },
openApi: null,
idleOpen: false,
@@ -247,6 +248,7 @@ function deriveAnalytics(history, metrics) {
total: 0,
errors: 0,
latencies: [],
+ paths: new Map(),
};
perApi.set(e.api_name, a);
}
@@ -254,6 +256,17 @@ function deriveAnalytics(history, metrics) {
a.total += 1;
if (err) a.errors += 1;
if (ms) a.latencies.push(ms);
+
+ // Which endpoints an upstream is actually serving, and how well.
+ const route = e.path || "/";
+ let p = a.paths.get(route);
+ if (!p) {
+ p = { total: 0, errors: 0, latencies: [] };
+ a.paths.set(route, p);
+ }
+ p.total += 1;
+ if (err) p.errors += 1;
+ if (ms) p.latencies.push(ms);
}
if (e.key_id != null) {
@@ -291,12 +304,25 @@ function deriveAnalytics(history, metrics) {
const apis = {};
for (const [name, a] of perApi) {
const sorted = a.latencies.slice().sort((x, y) => x - y);
+ const paths = [...a.paths.entries()]
+ .map(([route, p]) => ({
+ path: route,
+ total: p.total,
+ errors: p.errors,
+ errorRate: p.total ? (p.errors / p.total) * 100 : 0,
+ p95: percentile(
+ p.latencies.slice().sort((x, y) => x - y),
+ 95
+ ),
+ }))
+ .sort((x, y) => y.total - x.total || x.path.localeCompare(y.path));
apis[name] = {
series: a.buckets,
total: a.total,
errors: a.errors,
errorRate: a.total ? (a.errors / a.total) * 100 : 0,
p95: percentile(sorted, 95),
+ paths,
};
}
@@ -905,6 +931,29 @@ function detailRowHtml(name, a) {
)
.join("");
+ // Which endpoints this upstream actually serves. Derived from the recent
+ // sample, so it is labelled as such rather than shown beside all-time counts.
+ const pathRows = ((trend && trend.paths) || [])
+ .slice(0, 8)
+ .map(
+ (p) =>
+ `