Compare commits
2 Commits
64dc595a7a
...
2d18ae7505
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d18ae7505 | ||
|
|
5761400b0d |
@ -7,6 +7,7 @@ requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"python-ulid>=3.1.0",
|
||||
"starlette>=1.3.1",
|
||||
"structlog>=26.1.0",
|
||||
"typing-extensions>=4.15.0",
|
||||
]
|
||||
|
||||
|
||||
125
src/elexam_core/envelope.py
Normal file
125
src/elexam_core/envelope.py
Normal file
@ -0,0 +1,125 @@
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from typing import Any, Optional
|
||||
|
||||
from elexam_core.context import trace_id_context
|
||||
|
||||
|
||||
def success(
|
||||
data: Optional[Any] = None,
|
||||
message: str = "OK",
|
||||
event_type: Optional[str] = None,
|
||||
metadata: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Формирует стандартный envelope успешного ответа или события со статусом ``"success"``.
|
||||
|
||||
Используется во всех эндпоинтах и обработчиках событий, когда операция завершилась
|
||||
без ошибок. Поле ``errors`` всегда равно ``None``, поле ``data`` содержит результат.
|
||||
|
||||
:param data: Полезная нагрузка ответа/события — объект, список или любое
|
||||
сериализуемое значение, которое получит клиент. ``None``, если ответ
|
||||
не предполагает тела (например, DELETE без содержимого).
|
||||
:param message: Человекочитаемое сообщение о результате операции.
|
||||
По умолчанию ``"OK"``.
|
||||
:param event_type: Имя типа события для RabbitMQ-сообщений (например,
|
||||
``"exam.published"``). Оборачивается в структуру
|
||||
``{"id": <ULID>, "name": event_type}``. ``None`` для обычных HTTP-ответов.
|
||||
:param metadata: Дополнительные метаданные, не входящие в основную нагрузку
|
||||
(например, пагинация, версия снимка). Кладутся в поле ``meta``.
|
||||
:return: Словарь-envelope с полями ``status``, ``data``, ``message``,
|
||||
``trace_id``, ``errors``, ``meta``, ``event_type``, ``processed_at``.
|
||||
"""
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": data,
|
||||
"message": message,
|
||||
"trace_id": trace_id_context.get(),
|
||||
"errors": None,
|
||||
"meta": metadata,
|
||||
"event_type": {"id": str(ULID()), "name": event_type},
|
||||
"processed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def error(
|
||||
message: str = "ERROR",
|
||||
errors: Optional[str] = None,
|
||||
event_type: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Формирует стандартный envelope ответа об ошибке со статусом ``"error"``.
|
||||
|
||||
Используется во всех обработчиках исключений и в местах, где операция завершилась
|
||||
неуспешно. Поле ``data`` всегда равно ``None``, поле ``meta`` всегда равно ``None``.
|
||||
|
||||
:param message: Человекочитаемое описание ошибки, предназначенное для отображения
|
||||
клиенту или логирования. По умолчанию ``"ERROR"``.
|
||||
:param errors: Детали ошибки — строка с кодом из каталога ошибок, список
|
||||
валидационных нарушений или иное машиночитаемое описание причины сбоя.
|
||||
``None``, если дополнительная детализация не требуется.
|
||||
:param event_type: Имя типа события для RabbitMQ-сообщений (например,
|
||||
``"exam.failed"``). Оборачивается в структуру
|
||||
``{"id": <ULID>, "name": event_type}``. ``None`` для обычных HTTP-ответов.
|
||||
:return: Словарь-envelope с полями ``status``, ``data``, ``message``,
|
||||
``trace_id``, ``errors``, ``meta``, ``event_type``, ``processed_at``.
|
||||
"""
|
||||
|
||||
return {
|
||||
"status": "error",
|
||||
"data": None,
|
||||
"message": message,
|
||||
"trace_id": trace_id_context.get(),
|
||||
"errors": errors,
|
||||
"meta": None,
|
||||
"event_type": {"id": str(ULID()), "name": event_type},
|
||||
"processed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def custom_info(
|
||||
status: str,
|
||||
data: Optional[Any] = None,
|
||||
message: Optional[str] = "ERROR",
|
||||
errors: Optional[Any] = None,
|
||||
metadata: Optional[Any] = None,
|
||||
event_type: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Формирует envelope с произвольным статусом, переданным вызывающим кодом.
|
||||
|
||||
.. warning::
|
||||
Использовать эту функцию **не рекомендуется**. Применять её следует только
|
||||
в крайних случаях, когда стандартные ``success()`` и ``error()`` семантически
|
||||
не подходят (нестандартный протокол статусов). В подавляющем большинстве
|
||||
случаев предпочитайте ``success()`` или ``error()``.
|
||||
|
||||
:param status: Произвольная строка-статус, которая будет помещена в поле
|
||||
``status`` envelope. Контракт значения полностью на ответственности
|
||||
вызывающего кода.
|
||||
:param data: Полезная нагрузка ответа/события — объект, список или любое
|
||||
сериализуемое значение, которое получит клиент. ``None``, если ответ
|
||||
не предполагает тела.
|
||||
:param message: Человекочитаемое сообщение о результате операции.
|
||||
По умолчанию ``"ERROR"``.
|
||||
:param errors: Детали ошибки — строка с кодом из каталога ошибок, список
|
||||
валидационных нарушений или иное машиночитаемое описание причины сбоя.
|
||||
``None``, если дополнительная детализация не требуется.
|
||||
:param metadata: Дополнительные метаданные, не входящие в основную нагрузку.
|
||||
Кладутся в поле ``meta``.
|
||||
:param event_type: Имя типа события для RabbitMQ-сообщений. Оборачивается
|
||||
в структуру ``{"id": <ULID>, "name": event_type}``. ``None`` для
|
||||
обычных HTTP-ответов.
|
||||
:return: Словарь-envelope с полями ``status``, ``data``, ``message``,
|
||||
``trace_id``, ``errors``, ``meta``, ``event_type``, ``processed_at``.
|
||||
"""
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"data": data,
|
||||
"message": message,
|
||||
"trace_id": trace_id_context.get(),
|
||||
"errors": errors,
|
||||
"meta": metadata,
|
||||
"event_type": {"id": str(ULID()), "name": event_type},
|
||||
"processed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
54
src/elexam_core/logging.py
Normal file
54
src/elexam_core/logging.py
Normal file
@ -0,0 +1,54 @@
|
||||
from typing import MutableMapping, Any
|
||||
import structlog
|
||||
|
||||
from elexam_core.context import trace_id_context
|
||||
|
||||
|
||||
def add_trace_id(event_dict: MutableMapping[str, Any]):
|
||||
"""
|
||||
Structlog-процессор: добавляет поле ``trace_id`` в каждую лог-запись.
|
||||
|
||||
``trace_id`` читается из ``trace_id_context`` — это ``contextvars.ContextVar``,
|
||||
которая устанавливается middleware при входе каждого HTTP-запроса. Таким образом
|
||||
все лог-строки одного запроса объединяются одним сквозным идентификатором —
|
||||
это называется distributed tracing / сквозная трассировка.
|
||||
|
||||
.. note::
|
||||
Стандартная сигнатура structlog-процессора — три аргумента:
|
||||
``(logger, method_name, event_dict)``. Здесь сигнатура упрощена до одного
|
||||
параметра: structlog передаёт ``event_dict`` напрямую, потому что первые два
|
||||
аргумента нигде в теле функции не нужны.
|
||||
|
||||
:param event_dict: ``MutableMapping[str, Any]`` (в терминах structlog — ``EventDict``):
|
||||
словарь с накопленными к данному моменту данными лог-записи (поля, добавленные
|
||||
предыдущими процессорами и вызывающим кодом). Процессор мутирует его,
|
||||
добавляя ключ ``"trace_id"``, и обязан вернуть его дальше по цепочке.
|
||||
:return: Тот же объект ``event_dict`` с добавленным полем ``"trace_id"``.
|
||||
Возврат обязателен — structlog передаёт его следующему процессору в цепочке.
|
||||
"""
|
||||
event_dict["trace_id"] = trace_id_context.get()
|
||||
return event_dict
|
||||
|
||||
|
||||
# Глобальная конфигурация structlog.
|
||||
# `processors` — это цепочка (pipeline): каждая лог-запись проходит через все
|
||||
# процессоры строго по порядку. Каждый процессор получает `event_dict` от предыдущего,
|
||||
# дополняет или преобразует его и передаёт дальше. Последний процессор должен
|
||||
# вернуть финальную строку или байты — это и есть то, что уйдёт в stdout/файл.
|
||||
structlog.configure(
|
||||
processors=[
|
||||
# 1. Добавляет поле "level" (например, "info", "error") в event_dict.
|
||||
structlog.processors.add_log_level,
|
||||
# 2. Добавляет поле "timestamp" в формате ISO 8601, время в UTC.
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
# 3. Наш процессор: добавляет поле "trace_id" для сквозной трассировки запроса.
|
||||
add_trace_id,
|
||||
# 4. Финальный рендер: сериализует весь event_dict в JSON-строку.
|
||||
# Стоит последним — к этому моменту все поля уже добавлены.
|
||||
structlog.processors.JSONRenderer(),
|
||||
]
|
||||
)
|
||||
|
||||
# Готовый логгер для импорта в любом сервисе проекта.
|
||||
# Использование: `from elexam_core.logging import log`, затем `log.info("msg", key=val)`.
|
||||
log = structlog.get_logger()
|
||||
11
uv.lock
generated
11
uv.lock
generated
@ -21,6 +21,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "python-ulid" },
|
||||
{ name = "starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
@ -28,6 +29,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "python-ulid", specifier = ">=3.1.0" },
|
||||
{ name = "starlette", specifier = ">=1.3.1" },
|
||||
{ name = "structlog", specifier = ">=26.1.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.15.0" },
|
||||
]
|
||||
|
||||
@ -61,6 +63,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user