2026-09-07 19:43:48 +03:00

229 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery configuration and task lifecycle logging for EUSRR."""
import logging
import os
from inspect import signature
from threading import Lock
from time import monotonic
from types import TracebackType
from celery import Celery
from celery.signals import before_task_publish, task_failure, task_postrun, task_prerun
from .logging_utils import (
bind_log_context,
get_log_context,
new_request_id,
reset_log_context,
resolve_client_ip,
)
logger = logging.getLogger("eusrr.celery")
_task_state = {}
_task_state_lock = Lock()
_TASK_LOG_CONTEXT_HEADER = "eusrr_log_context"
_PROPAGATED_LOG_FIELDS = ("request_id", "user_id", "client_ip")
def _traceback_like(value) -> bool:
return isinstance(value, TracebackType) or all(
hasattr(value, attribute) for attribute in ("tb_frame", "tb_lineno", "tb_next")
)
def _normalize_failure_traceback(exception, traceback_value):
"""Return valid logging exc_info plus a serializable fallback traceback."""
if exception is None:
return None, None
candidate = traceback_value
fallback = None
if isinstance(candidate, tuple) and len(candidate) == 3:
candidate = candidate[2]
elif hasattr(candidate, "tb"):
fallback = getattr(candidate, "traceback", None)
candidate = getattr(candidate, "tb", None)
if _traceback_like(candidate):
return (type(exception), exception, candidate), fallback
if traceback_value:
fallback = fallback or str(traceback_value)
return None, fallback
# Устанавливаем настройки Django по умолчанию
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings")
app = Celery("eusrr_backend")
# Загружаем конфигурацию из Django settings
# namespace='CELERY' означает что все настройки Celery в settings.py
# должны начинаться с префикса CELERY_
app.config_from_object("django.conf:settings", namespace="CELERY")
# Автоматически находит tasks.py в каждом приложении
app.autodiscover_tasks()
@before_task_publish.connect
def inject_task_log_context(headers=None, **kwargs):
"""Copy safe request correlation fields into Celery message headers."""
if headers is None:
return
context = get_log_context()
propagated = {
key: context[key]
for key in _PROPAGATED_LOG_FIELDS
if context.get(key) is not None
}
if propagated:
headers[_TASK_LOG_CONTEXT_HEADER] = propagated
def _task_headers(sender=None, task=None) -> dict:
task_object = task or sender
request = getattr(task_object, "request", None)
headers = getattr(request, "headers", None)
return headers if isinstance(headers, dict) else {}
def _task_user_id(sender=None, task=None, task_args=None, task_kwargs=None):
if isinstance(task_kwargs, dict) and task_kwargs.get("user_id") is not None:
return task_kwargs["user_id"]
task_object = task or sender
run = getattr(task_object, "run", None)
if not callable(run):
return None
try:
arguments = (
signature(run)
.bind_partial(
*(task_args or ()),
**(task_kwargs or {}),
)
.arguments
)
except (TypeError, ValueError):
return None
return arguments.get("user_id")
def _propagated_task_context(
sender=None,
task=None,
task_args=None,
task_kwargs=None,
) -> dict:
headers = _task_headers(sender=sender, task=task)
raw_context = headers.get(_TASK_LOG_CONTEXT_HEADER, {})
context = raw_context if isinstance(raw_context, dict) else {}
propagated = {}
request_id = context.get("request_id")
if request_id is not None:
propagated["request_id"] = new_request_id(str(request_id))
client_ip = resolve_client_ip(context.get("client_ip"))
if client_ip:
propagated["client_ip"] = client_ip
user_id = context.get("user_id")
if user_id is None:
user_id = _task_user_id(
sender=sender,
task=task,
task_args=task_args,
task_kwargs=task_kwargs,
)
if isinstance(user_id, (int, str)) and not isinstance(user_id, bool):
propagated["user_id"] = user_id
return propagated
@task_prerun.connect
def log_task_started(
sender=None,
task_id=None,
task=None,
args=None,
kwargs=None,
**signal_kwargs,
):
task_name = getattr(sender, "name", None) or getattr(task, "name", None)
token = bind_log_context(
**_propagated_task_context(
sender=sender,
task=task,
task_args=args,
task_kwargs=kwargs,
),
task_id=task_id,
task_name=task_name,
)
with _task_state_lock:
_task_state[task_id] = (token, monotonic())
logger.info(
"Celery task started",
extra={
"event": "celery.task.started",
"task_id": task_id,
"task_name": task_name,
},
)
@task_failure.connect
def log_task_failed(
sender=None, task_id=None, exception=None, traceback=None, **kwargs
):
task_name = getattr(sender, "name", None)
exc_info, failure_traceback = _normalize_failure_traceback(exception, traceback)
logger.error(
"Celery task failed",
extra={
"event": "celery.task.failed",
"task_id": task_id,
"task_name": task_name,
"error_type": type(exception).__name__ if exception else None,
"failure_reason": str(exception) if exception else None,
"failure_traceback": failure_traceback,
},
exc_info=exc_info,
)
@task_postrun.connect
def log_task_completed(sender=None, task_id=None, state=None, **kwargs):
with _task_state_lock:
task_context = _task_state.pop(task_id, None)
token, started = task_context or (None, monotonic())
duration_ms = round((monotonic() - started) * 1000, 2)
task_name = getattr(sender, "name", None)
level = logging.INFO if state == "SUCCESS" else logging.WARNING
logger.log(
level,
"Celery task completed",
extra={
"event": "celery.task.completed",
"task_id": task_id,
"task_name": task_name,
"task_state": state,
"duration_ms": duration_ms,
},
)
if token is not None:
reset_log_context(token)
@app.task(bind=True, ignore_result=True)
def debug_task(self):
"""Тестовая задача для проверки работоспособности Celery"""
logger.debug(
"Celery debug task executed",
extra={
"event": "celery.debug_task.executed",
"task_id": self.request.id,
},
)