Files
2026-11-02 13:59:57 +01:00

99 lines
3.2 KiB
Python

"""
The only module route code touches.
@tracked("channel") wrap a stream handler: one event per call,
def _handle_channel_stream(provider, channel_id): ...
annotate(is_catchup=..., proxy=...) optional extra fields for the current request
Cost on the request thread when analytics is enabled: a handful of attribute
reads, one dict literal and one deque.append. When disabled: a single
`get_emitter() is None` check. Everything is wrapped so that no analytics
failure can change a response.
"""
from __future__ import annotations
import functools
import time
from typing import Any, Callable
from bottle import HTTPResponse, request, response
from .emitter import get_emitter
from .events import (
DELIVERY_ERROR,
DELIVERY_PROXIED,
DELIVERY_REDIRECT,
EVENT_MANIFEST_REQUEST,
)
_ENV_KEY = "analytics.extra"
_UA_MAX = 256
def annotate(**fields: Any) -> None:
"""Attach extra fields to the event of the current request. Never raises."""
try:
request.environ.setdefault(_ENV_KEY, {}).update(fields)
except Exception: # noqa: BLE001
pass
def _classify(status: int, redirected: bool) -> str:
if status >= 400:
return DELIVERY_ERROR
return DELIVERY_REDIRECT if redirected else DELIVERY_PROXIED
def _emit(emitter, content_type: str, provider: str, content_id: str,
status: int, delivery: str) -> None:
try:
ua = request.headers.get("User-Agent")
raw = {
"event_type": EVENT_MANIFEST_REQUEST,
"ts": time.time(),
"client_ip": request.remote_addr,
"provider": provider,
"channel_id": content_id,
"content_type": content_type,
"http_status": status,
"delivery": delivery,
"user_agent": ua[:_UA_MAX] if ua else None,
"country": request.query.get("country") or None,
}
extra = request.environ.get(_ENV_KEY)
if extra:
raw.update(extra)
emitter.emit(raw)
except Exception: # noqa: BLE001
pass
def tracked(content_type: str) -> Callable:
"""
Decorator for `handler(provider, content_id, ...)`. Emits exactly one event
per call, including for redirects (which Bottle raises as HTTPResponse),
handler-set error statuses and unexpected exceptions. Never alters the
handler's return value, raised exception, or response.
"""
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(provider, content_id, *args, **kwargs):
emitter = get_emitter()
if emitter is None:
return fn(provider, content_id, *args, **kwargs)
status, delivery = 500, DELIVERY_ERROR
try:
result = fn(provider, content_id, *args, **kwargs)
status = response.status_code
delivery = _classify(status, redirected=False)
return result
except HTTPResponse as exc:
status = exc.status_code
delivery = _classify(status, redirected=300 <= status < 400)
raise
finally:
_emit(emitter, content_type, provider, content_id, status, delivery)
return wrapper
return decorator