feat: support request-id header

This commit is contained in:
2026-03-08 02:12:46 +02:00
parent 437ae2c16c
commit a809a4b67f
18 changed files with 424 additions and 68 deletions

View File

@@ -19,6 +19,7 @@
#
import contextlib
import requests
import uuid
from requests.adapters import BaseAdapter
from urllib.parse import urlparse
@@ -60,6 +61,15 @@ class SyncAhrimanClient(SyncHttpClient):
return adapters
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return SyncHttpClient.headers(self) | {"X-Request-ID": str(uuid.uuid4())}
def on_session_creation(self, session: requests.Session) -> None:
"""
method which will be called on session creation

View File

@@ -144,6 +144,15 @@ class SyncHttpClient(LazyLogging):
"https://": HTTPAdapter(max_retries=self.retry),
}
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return {}
def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *,
headers: dict[str, str] | None = None,
params: list[tuple[str, str]] | None = None,
@@ -178,6 +187,9 @@ class SyncHttpClient(LazyLogging):
if session is None:
session = self.session
if additional_headers := self.headers():
headers = additional_headers | (headers or {})
try:
response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json,
stream=stream, auth=self.auth, timeout=self.timeout)

View File

@@ -24,6 +24,7 @@ from collections.abc import Iterator
from functools import cached_property
from typing import Any
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_record_id import LogRecordId
@@ -54,30 +55,20 @@ class LazyLogging:
prefix = "" if clazz.__module__ is None else f"{clazz.__module__}."
return f"{prefix}{clazz.__qualname__}"
@staticmethod
def _package_logger_reset() -> None:
@contextlib.contextmanager
def in_context(self, name: str, value: Any) -> Iterator[None]:
"""
reset package logger to empty one
"""
logging.setLogRecordFactory(logging.LogRecord)
@staticmethod
def _package_logger_set(package_base: str, version: str | None) -> None:
"""
set package base as extra info to the logger
execute function while setting log context. The context will be reset after the execution
Args:
package_base(str): package base
version(str | None): package version if available
name(str): attribute name to set on log records
value(Any): current value of the context variable
"""
current_factory = logging.getLogRecordFactory()
def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
record = current_factory(*args, **kwargs)
record.package_id = LogRecordId(package_base, version or "<unknown>")
return record
logging.setLogRecordFactory(package_record_factory)
token = LogContext.set(name, value)
try:
yield
finally:
LogContext.reset(name, token)
@contextlib.contextmanager
def in_package_context(self, package_base: str, version: str | None) -> Iterator[None]:
@@ -94,8 +85,5 @@ class LazyLogging:
>>> with self.in_package_context(package.base, package.version):
>>> build_package(package)
"""
try:
self._package_logger_set(package_base, version)
with self.in_context("package_id", LogRecordId(package_base, version or "<unknown>")):
yield
finally:
self._package_logger_reset()

View File

@@ -0,0 +1,108 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import contextvars
import logging
from typing import Any, ClassVar, TypeVar, cast
T = TypeVar("T")
class LogContext:
"""
logging context manager which provides context variables injection into log records
"""
_context: ClassVar[dict[str, contextvars.ContextVar[Any]]] = {}
@classmethod
def get(cls, name: str) -> T | None:
"""
get context variable if available
Args:
name(str): name of the context variable
Returns:
T | None: context variable if available and ``None`` otherwise
"""
if (variable := cls._context.get(name)) is not None:
return cast(T | None, variable.get())
return None
@classmethod
def log_record_factory(cls, *args: Any, **kwargs: Any) -> logging.LogRecord:
"""
log record factory which injects all registered context variables into log records
Args:
*args(Any): positional arguments for the log factory
**kwargs(Any): keyword arguments for the log factory
Returns:
logging.LogRecord: log record with context variables set as attributes
"""
record = logging.LogRecord(*args, **kwargs)
for name, variable in cls._context.items():
if (value := variable.get()) is not None:
setattr(record, name, value)
return record
@classmethod
def register(cls, name: str) -> contextvars.ContextVar[T]:
"""
(re)create context variable for log records
Args:
name(str): name of the context variable
Returns:
contextvars.ContextVar[T]: created context variable
"""
variable = cls._context[name] = contextvars.ContextVar(name, default=None)
return variable
@classmethod
def reset(cls, name: str, token: contextvars.Token[T]) -> None:
"""
reset context variable to its previous value
Args:
name(str): attribute name to reset on log records
token(contextvars.Token[T]): previously registered token
"""
cls._context[name].reset(token)
@classmethod
def set(cls, name: str, value: T) -> contextvars.Token[T]:
"""
set context variable for log records. This value will be automatically emitted with each log record
Args:
name(str): attribute name to set on log records
value(T): current value of the context variable
Returns:
contextvars.Token[T]: token created with this value
"""
return cls._context[name].set(value)

View File

@@ -21,10 +21,11 @@ import logging
from logging.config import fileConfig
from pathlib import Path
from typing import ClassVar
from typing import ClassVar, Literal
from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_handler import LogHandler
from ahriman.models.repository_id import RepositoryId
@@ -36,11 +37,13 @@ class LogLoader:
Attributes:
DEFAULT_LOG_FORMAT(str): (class attribute) default log format (in case of fallback)
DEFAULT_LOG_LEVEL(int): (class attribute) default log level (in case of fallback)
DEFAULT_LOG_STYLE(str): (class attribute) default log style (in case of fallback)
DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device
"""
DEFAULT_LOG_FORMAT: ClassVar[str] = "[%(levelname)s %(asctime)s] [%(name)s]: %(message)s"
DEFAULT_LOG_FORMAT: ClassVar[str] = "[{levelname} {asctime}] [{name}]: {message}"
DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG
DEFAULT_LOG_STYLE: ClassVar[Literal["%", "{", "$"]] = "{"
DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log"
@staticmethod
@@ -100,10 +103,22 @@ class LogLoader:
fileConfig(log_configuration, disable_existing_loggers=True)
logging.debug("using %s logger", default_handler)
except Exception:
logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT, level=LogLoader.DEFAULT_LOG_LEVEL)
logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT,
style=LogLoader.DEFAULT_LOG_STYLE, level=LogLoader.DEFAULT_LOG_LEVEL)
logging.exception("could not load logging from configuration, fallback to stderr")
HttpLogHandler.load(repository_id, configuration, report=report)
LogLoader.register_context()
if quiet:
logging.disable(logging.WARNING) # only print errors here
@staticmethod
def register_context() -> None:
"""
register logging context
"""
# predefined context variables
for variable in ("package_id", "request_id"):
LogContext.register(variable)
logging.setLogRecordFactory(LogContext.log_record_factory)

View File

@@ -0,0 +1,51 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import uuid
from aiohttp.typedefs import Middleware
from aiohttp.web import Request, StreamResponse, middleware
from ahriman.core.log.log_context import LogContext
from ahriman.web.middlewares import HandlerType
__all__ = ["request_id_handler"]
def request_id_handler() -> Middleware:
"""
middleware to trace request id header
Returns:
Middleware: request id processing middleware
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
request_id = request.headers.getone("X-Request-ID", str(uuid.uuid4()))
token = LogContext.set("request_id", request_id)
try:
response = await handler(request)
response.headers["X-Request-ID"] = request_id
return response
finally:
LogContext.reset("request_id", token)
return handle

View File

@@ -38,6 +38,7 @@ from ahriman.web.cors import setup_cors
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
from ahriman.web.middlewares.exception_handler import exception_handler
from ahriman.web.middlewares.metrics_handler import metrics_handler
from ahriman.web.middlewares.request_id_handler import request_id_handler
from ahriman.web.routes import setup_routes
@@ -146,6 +147,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application.on_startup.append(_on_startup)
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
application.middlewares.append(request_id_handler())
application.middlewares.append(exception_handler(application.logger))
application.middlewares.append(metrics_handler())