Files
Fringg 1068c1387a 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
2026-04-22 05:57:46 +03:00

353 lines
11 KiB
Python

"""Yandex.Metrika offline conversions service.
Sends events (registration, trial-add, purchase) to mc.yandex.ru/collect
using the Measurement Protocol. No pageview needed — user has active
Metrika session from the site. yclid is passed via landing page URL,
Metrika matches it automatically.
"""
from __future__ import annotations
import asyncio
import re
import time
import httpx
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.yandex_client_id import (
get_cid,
mark_registration_sent,
mark_trial_sent,
upsert_cid,
)
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
COLLECT_URL = 'https://mc.yandex.ru/collect'
TIMEOUT = 10.0
MAX_RETRIES = 3
RETRY_DELAY = 1.0
_CID_RE = re.compile(r'^[A-Za-z0-9._:-]{4,128}$')
_http_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=TIMEOUT)
return _http_client
def _is_enabled() -> bool:
return bool(
settings.YANDEX_OFFLINE_CONV_ENABLED
and settings.YANDEX_OFFLINE_CONV_COUNTER_ID
and settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET
)
def _normalize_cid(cid: str | None) -> str | None:
if not isinstance(cid, str):
return None
cid = cid.strip()
if not cid or not _CID_RE.match(cid):
return None
return cid
def _mask_cid(cid: str) -> str:
if len(cid) <= 4:
return '****'
return '*' * (len(cid) - 4) + cid[-4:]
def _base_payload(cid: str) -> dict[str, str]:
return {
'tid': settings.YANDEX_OFFLINE_CONV_COUNTER_ID,
'cid': cid,
'ms': settings.YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET,
}
def _pageview_payload(cid: str) -> dict[str, str]:
payload = _base_payload(cid)
payload.update(
{
't': 'pageview',
'dl': settings.YANDEX_OFFLINE_CONV_DL or 'https://web.mtrxvps.ru',
'dt': settings.YANDEX_OFFLINE_CONV_DT or 'Matrixxx VPN',
}
)
return payload
def _event_payload(cid: str, event_action: str) -> dict[str, str]:
payload = _base_payload(cid)
payload.update(
{
't': 'event',
'ea': event_action,
}
)
return payload
def _ecommerce_purchase_payload(
cid: str,
amount_rubles: float,
order_id: str = '',
product_name: str = '',
product_category: str = '',
) -> dict[str, str]:
"""Build ecommerce:purchase payload for Metrika Measurement Protocol."""
service_name = (
getattr(settings, 'YANDEX_OFFLINE_CONV_DT', '')
or getattr(settings, 'PAYMENT_SERVICE_NAME', '')
or 'Subscription'
)
currency = getattr(settings, 'YANDEX_OFFLINE_CONV_CURRENCY', '') or 'RUB'
payload = _base_payload(cid)
payload.update(
{
't': 'event',
'ea': 'purchase',
'pa': 'purchase',
'ti': order_id or str(int(time.time())),
'tr': str(amount_rubles),
'cu': currency,
'ev': str(amount_rubles),
'pr1id': 'subscription',
'pr1nm': product_name or service_name,
'pr1ca': product_category or 'subscription',
'pr1pr': str(amount_rubles),
'pr1qt': '1',
}
)
return payload
async def _post_collect(payload: dict[str, str], kind: str, cid: str) -> bool:
"""POST to mc.yandex.ru/collect with retries. Returns True on success."""
masked = _mask_cid(cid)
for attempt in range(1, MAX_RETRIES + 1):
try:
client = _get_client()
resp = await client.post(COLLECT_URL, data=payload)
if 200 <= resp.status_code < 300:
logger.info('collect sent', kind=kind, cid=masked, status=resp.status_code)
return True
if 500 <= resp.status_code < 600 and attempt < MAX_RETRIES:
logger.warning(
'collect server error',
kind=kind,
attempt=attempt,
max=MAX_RETRIES,
cid=masked,
status=resp.status_code,
)
await asyncio.sleep(RETRY_DELAY)
continue
logger.error('collect rejected', kind=kind, cid=masked, status=resp.status_code, body=resp.text[:200])
return False
except Exception as exc:
logger.warning(
'collect request error', kind=kind, attempt=attempt, max=MAX_RETRIES, cid=masked, error=str(exc)
)
if attempt < MAX_RETRIES:
await asyncio.sleep(RETRY_DELAY)
continue
return False
return False
async def _send_event(cid: str, event_action: str) -> bool:
"""Send event directly — no pageview needed, user has active Metrika session."""
return await _post_collect(_event_payload(cid, event_action), event_action, cid)
# --- Background task helpers ---
_background_tasks: set[asyncio.Task] = set()
def _task_done(task):
"""Log errors from background conversion tasks."""
_background_tasks.discard(task)
if task.cancelled():
return
exc = task.exception()
if exc:
logger.error('YandexOfflineConv background task failed', error=str(exc))
def spawn_bg(coro) -> None:
"""Spawn a background Yandex conversion task with proper reference tracking.
Checks _is_enabled() early so callers don't need to.
"""
if not _is_enabled():
# Close the coroutine to avoid RuntimeWarning
coro.close()
return
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_task_done)
async def _fire_bg(event_name: str, event_fn, user_id: int, **kwargs) -> None:
"""Generic background wrapper: opens a session, calls event_fn, logs errors."""
try:
async with AsyncSessionLocal() as db:
await event_fn(db, user_id, **kwargs)
except Exception as exc:
logger.warning('YandexOfflineConv background event failed', event=event_name, user_id=user_id, error=str(exc))
async def fire_registration_bg(user_id: int) -> None:
"""Fire registration event in background with its own DB session."""
await _fire_bg('registration', on_registration, user_id)
async def fire_trial_bg(user_id: int) -> None:
"""Fire trial event in background with its own DB session."""
await _fire_bg('trial', on_trial, user_id)
async def fire_purchase_bg(user_id: int, amount_kopeks: int) -> None:
"""Fire purchase event in background with its own DB session."""
await _fire_bg('purchase', on_purchase, user_id, amount_kopeks=amount_kopeks)
# --- Public API ---
async def store_cid(
db: AsyncSession,
user_id: int,
cid: str | None,
source: str = 'web',
) -> bool:
"""Store Yandex ClientID for a user. Returns True if stored."""
normalized = _normalize_cid(cid)
if not normalized:
return False
try:
await upsert_cid(db, user_id, normalized, source=source, counter_id=settings.YANDEX_OFFLINE_CONV_COUNTER_ID)
logger.info('stored CID', user_id=user_id, source=source)
return True
except Exception as exc:
logger.error('failed to store CID', user_id=user_id, error=str(exc))
return False
async def store_cid_and_fire_registration(
user_id: int,
cid: str | None,
*,
source: str = 'web',
) -> None:
"""Store Yandex CID and fire registration conversion in background (best-effort).
Opens its own DB session so it never interferes with the caller's transaction.
"""
if not cid:
return
try:
async with AsyncSessionLocal() as db:
stored = await store_cid(db, user_id, cid, source=source)
if stored:
await db.commit()
spawn_bg(fire_registration_bg(user_id))
except Exception as exc:
logger.warning('Failed to store CID and fire registration', user_id=user_id, error=str(exc))
async def on_registration(db: AsyncSession, user_id: int) -> None:
"""Fire registration event (once per user)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row or row.registration_sent:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
success = await _send_event(row.yandex_cid, 'registration')
if success:
await mark_registration_sent(db, user_id)
await db.commit()
logger.info('registration event sent', user_id=user_id)
except Exception as exc:
logger.error('registration event failed', user_id=user_id, error=str(exc))
async def on_trial(db: AsyncSession, user_id: int) -> None:
"""Fire trial-add event (once per user)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row or row.trial_sent:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
success = await _send_event(row.yandex_cid, 'trial-add')
if success:
await mark_trial_sent(db, user_id)
await db.commit()
logger.info('trial-add event sent', user_id=user_id)
except Exception as exc:
logger.error('trial-add event failed', user_id=user_id, error=str(exc))
async def on_purchase(db: AsyncSession, user_id: int, amount_kopeks: int) -> None:
"""Fire ecommerce purchase event (every payment)."""
if not _is_enabled():
return
try:
row = await get_cid(db, user_id)
if not row:
return
if not row.yandex_cid or row.yandex_cid.startswith('_'):
return # placeholder row — real CID not yet received
amount_rubles = amount_kopeks / 100
payload = _ecommerce_purchase_payload(row.yandex_cid, amount_rubles)
success = await _post_collect(payload, 'purchase', row.yandex_cid)
if success:
logger.info('purchase event sent', user_id=user_id, amount=amount_rubles)
except Exception as exc:
logger.error('purchase event failed', user_id=user_id, error=str(exc))
def parse_cid_from_start_param(param: str) -> tuple[str | None, str]:
"""Extract Yandex CID from bot start parameter.
If param starts with the configured prefix (e.g. 'utm_ya_'),
returns (cid, original_param). Otherwise returns (None, original_param).
Original param is always preserved for UTM tracking.
"""
prefix = settings.YANDEX_OFFLINE_CONV_START_PREFIX
if not prefix or not param.startswith(prefix):
return None, param
cid = param[len(prefix) :]
normalized = _normalize_cid(cid)
return normalized, param # Keep original param for UTM tracking