830e64afe0
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах - subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs - monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч - logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове - logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв * fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id Трейс показал: subscription.user падает на lazy-load → pool._checkout → do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не поддерживает sync-lazy-load для relationships. Причина рассинхрона: lock_user_for_pricing делает populate_existing=True + selectinload( User.subscriptions).selectinload(Subscription.tariff), что разгружает Subscription.user backref для сестринских подписок того же user. Последующее обращение sub.user у другой подписки падает. Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию делаем fresh refetch через async select с eager load user+tariff+ promo_group. Никаких lazy access в горячем пути. В except используем локально захваченные id вместо getattr(subscription, ...), чтобы логирование не падало каскадом на expired объекте. * fix: grant all available squads for unrestricted trials (#2897) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook * style: ruff format main.py --------- Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not update first_name/last_name from OIDC claims (#2892) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal - balance/platega: re-set FSM state after min/max validation errors, set state before pending_amount path, use balance_topup callback for back button - balance/main: set FSM state and payment_method in handle_topup_amount_callback for all providers before routing, use balance_topup callback in validation errors - payment/paypear: fix confirmation_url key (was 'url'), add fallback, store charged amount with commission for correct webhook amount comparison - tariff_purchase: redirect to active tariff list when current tariff is inactive (hidden trial after promo code activation) - cabinet/renewal: check tariff.is_active in both GET and POST endpoints to prevent hidden trial tariff periods from appearing * fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard - pricing_engine: use shortest period for daily rate comparison instead of period closest to remaining_days — fixes incorrect free/zero cost for upgrades when tariffs have different period sets - pricing_engine: remove unused target_days parameter from get_tariff_daily_rate_fraction - admin_users: add duplicate subscription check before create, change_tariff and activate actions to prevent UniqueViolationError on uq_subscriptions_user_tariff_active constraint - admin_users: add IntegrityError fallback on create as TOCTOU safety net * feat: tariff switch direction control, fix device pricing within tariff limit Tariff switch direction: - Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED settings to control allowed switch directions - Guard all 10 entry points: instant switch (list, preview, confirm), legacy switch (list, select, confirm, daily confirm), cabinet (preview, execute), purchase-options API - Filter tariff lists by allowed direction, show "unavailable" when both directions disabled - Expose settings in cabinet purchase-options response for frontend Device pricing fix: - Devices within tariff.device_limit are now free when restoring (was charging for all devices regardless of tariff inclusion) - Fix max(100, price) minimum enforcing 1 RUB even when chargeable_devices is 0 - Apply fix across all endpoints: bot handlers (confirm_change, execute_change, confirm_add), cabinet API (legacy purchase, modern purchase, get-price, save-cart), inline keyboard display * fix: classic mode renewal resets device_limit to 1 via cart key mismatch - Fix cart key mismatch: extend cart saved 'device_limit' but confirm_purchase read 'devices' key, falling back to DEFAULT=1. Now both keys are saved in both cart-save paths - Fix confirm_purchase device resolution: use explicit is None checks instead of or-chain to avoid falsy-zero trap - Fix return_to_saved_cart display: fall back to 'device_limit' and 'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent - Fix second cart-save path in _extend_existing_subscription with same dual-key pattern - Fix RemnaWaveService import path in renewal service - Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices (hwid) via RemnaWave API on each subscription renewal * fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports - Increase icon max_length from 10 to 100 in all three schemas (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest) to support Telegram Custom Emoji IDs - Add traffic_topup_enabled condition to ButtonConditions schema - Remove shadowing local imports of MenuLayoutService in routes/menu_layout.py (top-level import already provides access) * feat(tickets): multi-media message gallery (media_items JSONB) - Add media_items JSONB column to TicketMessage model for multi-media gallery support (photos/videos/documents in one bubble) - Add TicketMediaItem schema with type validation and shared _validate_media_bundle helper (max 10 items, legacy field compat) - Update admin and user ticket handlers to store media_items and back-fill legacy media_type/media_file_id/media_caption from first item for backward compatibility - Update _message_to_response in both admin and user routes to include media_items in API responses - Allow empty message text when media is attached (message field now defaults to empty string with model validator ensuring text or media) - Add migration 0061 with idempotent column check Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: ticket media_items review fixes - Add if has_media else None guards in user-side ticket handlers (create_ticket, add_message) matching admin handler pattern - Fix Telegram notification using resolved primary_file_id/primary_type instead of raw request fields for gallery messages - Narrow except Exception to (TypeError, KeyError, ValueError) in _message_to_response with warning log for debugging - Add media_items parameter to TicketCRUD.create_ticket and TicketCRUD.add_message for CRUD layer parity - Add TicketMediaItemResponse and media_items field to webapi TicketMessageResponse to prevent data loss on read * feat: landing page analytics goals and sticky pay button - Add sticky_pay_button, analytics_view_enabled, analytics_view_goal, analytics_click_enabled, analytics_click_goal columns to LandingPage - Add fields to CRUD updatable fields, admin create/update/detail schemas, create_landing() kwargs, _landing_to_detail() response - Expose sticky_pay_button and analytics fields in public landing config response for frontend Yandex Metrika integration - Add migration 0062 with idempotent column checks Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: validate analytics goal is set when analytics is enabled on landing Prevent enabling analytics_view/click without providing the corresponding goal identifier, which would result in empty Yandex Metrika calls on the frontend. * feat: Yandex Metrika offline conversions + S2S postbacks - Add YandexClientIdMap model for user → yandex_cid mapping with upsert-safe CRUD (ON CONFLICT DO UPDATE) - Add yandex_cid, subid, referrer columns to GuestPurchase - Add yandex_offline_conv_service: Measurement Protocol integration with mc.yandex.ru/collect (registration, trial, purchase events), background task management, CID parsing from /start params - Add s2s_postback_service: server-to-server affiliate postbacks with URL template placeholders and URL-safe encoding - Add analytics offline conversion info to branding API (masked secret) - Add POST /analytics/yandex-cid endpoint for cabinet CID capture - Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*) - Add migration 0063 (yandex_client_id_map table + guest_purchases cols) - Fix: mask measurement secret aggressively (show only last 4 chars) - Fix: always replace {user_id} placeholder in S2S postback URLs - Fix: use structlog kwargs instead of f-strings with LOG_PREFIX Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded --------- Co-authored-by: c0mrade <killmy666@gmail.com> Co-authored-by: Danila Yudin <danyayudin2012@gmail.com> Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com> Co-authored-by: Dmitry Lunin <br@slack.ru>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""Structlog processor for sending ERROR/CRITICAL logs to admin Telegram chat.
|
|
|
|
Intercepts all log events at ERROR level and above, deduplicates them,
|
|
and schedules async delivery to the admin Telegram chat via the existing
|
|
``send_error_to_admin_chat`` infrastructure.
|
|
|
|
Deduplication:
|
|
- Events already processed by GlobalErrorMiddleware / @error_handler
|
|
carry ``_admin_notified=True`` and are skipped.
|
|
- Recent message hashes are kept in a TTL cache to prevent duplicate
|
|
notifications for the same error within a short window.
|
|
|
|
Async bridge:
|
|
- structlog processors are synchronous. We use
|
|
``asyncio.get_running_loop().call_soon_threadsafe()`` to schedule an
|
|
asyncio.Task from any thread.
|
|
|
|
Deferred init:
|
|
- The Bot instance is created later in main.py. ``set_bot()`` injects
|
|
it after creation. Until then, events are silently passed through.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from typing import Any, Final
|
|
|
|
from aiogram import Bot
|
|
|
|
|
|
# Constants
|
|
RECENT_HASHES_MAX_SIZE: Final[int] = 256
|
|
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 min — matches cooldown in global_error
|
|
|
|
# Logger name prefixes we never want notifications from
|
|
# (noisy transport-level loggers).
|
|
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
|
|
'aiohttp.access',
|
|
'aiohttp.client',
|
|
'aiohttp.internal',
|
|
'uvicorn.access',
|
|
'uvicorn.error',
|
|
'uvicorn.protocols',
|
|
'websockets',
|
|
'asyncio',
|
|
# Payment modules — isolated to payments.log, must not leak to Telegram
|
|
'app.payments',
|
|
'app.services.payment',
|
|
'app.services.yookassa_service',
|
|
'app.services.tribute_service',
|
|
'app.services.mulenpay_service',
|
|
'app.services.cloudpayments_service',
|
|
'app.services.platega_service',
|
|
'app.services.pal24_service',
|
|
'app.services.wata_service',
|
|
'app.services.kassa_ai_service',
|
|
'app.services.freekassa_service',
|
|
'app.external.cryptobot',
|
|
'app.external.heleket',
|
|
'app.external.tribute',
|
|
'app.external.yookassa_webhook',
|
|
'app.external.wata_webhook',
|
|
'app.external.heleket_webhook',
|
|
'app.external.pal24_client',
|
|
'app.external.telegram_stars',
|
|
'app.webserver.payments',
|
|
)
|
|
|
|
|
|
class TelegramNotifierProcessor:
|
|
"""Structlog processor that sends ERROR/CRITICAL events to the admin Telegram chat.
|
|
|
|
Uses the existing throttling and buffering from
|
|
``app.middlewares.global_error.send_error_to_admin_chat``.
|
|
|
|
Usage::
|
|
|
|
notifier = TelegramNotifierProcessor()
|
|
# Add to shared_processors list in logging_config.py
|
|
# Later, when Bot is created:
|
|
notifier.set_bot(bot)
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._bot: Bot | None = None
|
|
# LRU-like cache of recent message hashes: hash -> timestamp
|
|
self._recent_hashes: dict[str, float] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public API
|
|
# ------------------------------------------------------------------
|
|
|
|
def set_bot(self, bot: Bot) -> None:
|
|
"""Inject the Bot instance for sending messages.
|
|
|
|
Called from main.py after the bot is created.
|
|
"""
|
|
self._bot = bot
|
|
|
|
# ------------------------------------------------------------------
|
|
# Processor interface
|
|
# ------------------------------------------------------------------
|
|
|
|
def __call__(
|
|
self,
|
|
logger: Any,
|
|
method_name: str,
|
|
event_dict: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Process a log event. Passthrough — always returns event_dict."""
|
|
|
|
# 1. Only handle error-level events
|
|
level = event_dict.get('level', '')
|
|
if level not in ('error', 'critical', 'exception'):
|
|
return event_dict
|
|
|
|
# 2. Already sent via GlobalErrorMiddleware / @error_handler
|
|
if event_dict.get('_admin_notified'):
|
|
return event_dict
|
|
|
|
# 3. Filter noisy loggers
|
|
logger_name = event_dict.get('logger', '')
|
|
if any(logger_name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
|
|
return event_dict
|
|
|
|
# 4. Resolve exc_info into actual tuple while still in except block.
|
|
# logger.exception() sets exc_info=True (bool); we need the tuple for
|
|
# traceback extraction. sys.exc_info() works because the processor runs
|
|
# synchronously inside the except clause.
|
|
#
|
|
# If exc_info is not passed at all, auto-capture traceback from:
|
|
# (a) sys.exc_info() — works when logger.error is called inside except
|
|
# (b) error/exc/exception kwargs if they carry __traceback__
|
|
# This avoids having to pass exc_info=True at every logger.error site.
|
|
exc_info = event_dict.get('exc_info')
|
|
if exc_info is True:
|
|
event_dict['exc_info'] = sys.exc_info()
|
|
elif not exc_info:
|
|
current = sys.exc_info()
|
|
if current[1] is not None:
|
|
event_dict['exc_info'] = current
|
|
else:
|
|
for key in ('error', 'exc', 'exception', 'e', 'err'):
|
|
candidate = event_dict.get(key)
|
|
if isinstance(candidate, BaseException) and candidate.__traceback__ is not None:
|
|
event_dict['exc_info'] = (type(candidate), candidate, candidate.__traceback__)
|
|
break
|
|
|
|
# 5. Bot not initialized yet — skip
|
|
bot = self._bot
|
|
if bot is None:
|
|
return event_dict
|
|
|
|
# 6. Deduplication via hash
|
|
msg_hash = self._compute_hash(event_dict)
|
|
now = time.monotonic()
|
|
|
|
with self._lock:
|
|
self._evict_stale(now)
|
|
if msg_hash in self._recent_hashes:
|
|
return event_dict
|
|
self._recent_hashes[msg_hash] = now
|
|
|
|
# 7. Schedule async send
|
|
self._schedule_send(bot, event_dict)
|
|
|
|
return event_dict
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _compute_hash(event_dict: dict[str, Any]) -> str:
|
|
"""Compute a short hash for deduplication.
|
|
|
|
Hashes logger name + event message + exception type (if present).
|
|
"""
|
|
logger_name = event_dict.get('logger', '')
|
|
event_msg = event_dict.get('event', '')
|
|
|
|
# Include exception type for better dedup granularity
|
|
exc_type = ''
|
|
exc_info = event_dict.get('exc_info')
|
|
if exc_info and isinstance(exc_info, tuple) and exc_info[0] is not None:
|
|
exc_type = exc_info[0].__name__
|
|
|
|
raw = f'{logger_name}:{event_msg}:{exc_type}'
|
|
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
|
|
|
|
def _evict_stale(self, now: float) -> None:
|
|
"""Remove expired entries from the hash cache. Must be called under self._lock."""
|
|
if not self._recent_hashes:
|
|
return
|
|
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
|
|
for k in stale_keys:
|
|
self._recent_hashes.pop(k, None)
|
|
# Force eviction on overflow — remove oldest entries
|
|
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
|
|
sorted_keys = sorted(self._recent_hashes, key=lambda k: self._recent_hashes[k])
|
|
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
|
|
self._recent_hashes.pop(k, None)
|
|
|
|
def _schedule_send(self, bot: Bot, event_dict: dict[str, Any]) -> None:
|
|
"""Schedule async delivery via event loop.
|
|
|
|
Works from any thread:
|
|
- From async context: creates Task directly.
|
|
- From other threads: uses call_soon_threadsafe.
|
|
"""
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
# No running loop — silently skip.
|
|
# The structlog processor runs in sync context; if there's no loop,
|
|
# we can't send anything.
|
|
return
|
|
else:
|
|
# We're in async context — create task directly
|
|
self._create_send_task(bot, event_dict, loop)
|
|
|
|
def _create_send_task(
|
|
self,
|
|
bot: Bot,
|
|
event_dict: dict[str, Any],
|
|
loop: asyncio.AbstractEventLoop,
|
|
) -> None:
|
|
"""Create an asyncio.Task for sending the notification."""
|
|
loop.create_task(self._send(bot, event_dict))
|
|
|
|
@staticmethod
|
|
async def _send(bot: Bot, event_dict: dict[str, Any]) -> None:
|
|
"""Send the log event to the admin chat via existing infrastructure."""
|
|
try:
|
|
# Lazy import to avoid circular dependencies at startup
|
|
from app.middlewares.global_error import send_error_to_admin_chat
|
|
|
|
# Build a pseudo-Exception from the event_dict
|
|
error = _make_event_dict_error(event_dict)
|
|
|
|
# Build rich context from event_dict
|
|
context_parts: list[str] = []
|
|
logger_name = event_dict.get('logger', '')
|
|
if logger_name:
|
|
context_parts.append(f'Logger: {logger_name}')
|
|
user_id = event_dict.get('user_id')
|
|
username = event_dict.get('username')
|
|
if user_id:
|
|
user_str = f'User: {user_id}'
|
|
if username:
|
|
user_str += f' (@{username})'
|
|
context_parts.append(user_str)
|
|
|
|
context = '\n'.join(context_parts)
|
|
|
|
# Extract traceback from exc_info if present
|
|
tb_override: str | None = None
|
|
exc_info = event_dict.get('exc_info')
|
|
if exc_info and isinstance(exc_info, tuple) and exc_info[2] is not None:
|
|
tb_override = ''.join(traceback.format_exception(*exc_info))
|
|
|
|
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
|
|
|
|
except Exception:
|
|
# Never let an exception leak — this is a logging processor,
|
|
# recursion would kill the application.
|
|
pass
|
|
|
|
|
|
def _make_event_dict_error(event_dict: dict[str, Any]) -> Exception:
|
|
"""Create an Exception wrapper for a structlog event_dict.
|
|
|
|
``send_error_to_admin_chat`` uses ``type(error).__name__`` as error_type.
|
|
If exc_info contains a real exception, use its type name.
|
|
Otherwise, create a descriptive class from the log level.
|
|
"""
|
|
# Prefer the real exception type from exc_info or error kwarg
|
|
exc_info = event_dict.get('exc_info')
|
|
if exc_info and isinstance(exc_info, tuple) and exc_info[1] is not None:
|
|
real_exc = exc_info[1]
|
|
class_name = type(real_exc).__name__
|
|
else:
|
|
error_kwarg = event_dict.get('error')
|
|
if error_kwarg and isinstance(error_kwarg, BaseException):
|
|
class_name = type(error_kwarg).__name__
|
|
else:
|
|
level = event_dict.get('level', 'error')
|
|
class_name = f'Log{level.capitalize()}'
|
|
|
|
error_cls = type(
|
|
class_name,
|
|
(Exception,),
|
|
{
|
|
'__str__': lambda self: self.args[0] if self.args else '',
|
|
},
|
|
)
|
|
message = str(event_dict.get('event', ''))
|
|
error = error_cls(message)
|
|
error.event_dict = event_dict # type: ignore[attr-defined]
|
|
return error
|