seitime-frappe/frappe/utils/telemetry/pulse/client.py
Aarol D'Souza 08793c57f7
fix: force type check in whitelisted methods 2 (#37086)
* fix(diff): add type hints to whitelisted methods

* fix(global_search): add type hints to whitelisted methods

* fix(custom_html_block): add type hints to whitelisted methods

* fix(deleted_document): add type hints to whitelisted methods

* fix(log_settings): add type hints to whitelisted methods

* fix(role): add type hints to whitelisted methods

* fix(user_type): add type hints to whitelisted methods

* fix(rq_job): add type hints to whitelisted methods

* fix(link_preview): add type hints to whitelisted methods

* fix(email_account): add type hints to whitelisted methods

* fix(web_form): add type hints to whitelisted methods

* fix(web_page_view): add type hints to whitelisted methods

* fix(csvutils): add type hints to whitelisted methods

* fix(file_manager): add type hints to whitelisted methods

* fix(email_body): add type hints to whitelisted methods

* fix(email_queue): add type hints to whitelisted methods

* fix(email_template): add type hints to whitelisted methods

* fix(notification): add type hints to whitelisted methods

* fix(email_group): add type hints to whitelisted methods

* fix(inbox): add type hints to whitelisted methods

* fix(recorder): add type hints to whitelisted methods

* fix(sms_settings): add type hints to whitelisted methods

* fix: tighten type hints

* fix(data_import): add type hints to whitelisted methods

* fix(user_permission): add type hints to whitelisted methods

* fix(gantt): add type hints to whitelisted methods

* fix(like): add type hints to whitelisted methods

* fix(search): add type hints to whitelisted methods

* fix(onboarding_step): add type hints to whitelisted methods

* fix(system_console): add type hints to whitelisted methods

* fix(workspace_sidebar): add type hints to whitelisted methods

* fix(todo): add type hints to whitelisted methods

* fix: correct type hints

* fix(print_format): add type hints to whitelisted methods

* fix(client): add type hints to whitelisted methods
2026-02-19 14:58:16 +05:30

258 lines
6.5 KiB
Python

import time
from contextlib import suppress
from typing import Any
from orjson import JSONDecodeError
import frappe
from frappe.utils import get_request_session
from frappe.utils.caching import site_cache
from frappe.utils.frappecloud import on_frappecloud
from .utils import anonymize_user, ensure_http, parse_interval, utc_iso
@frappe.whitelist()
@site_cache(ttl=60 * 60)
def is_enabled() -> bool:
return bool(
not frappe.conf.get("developer_mode", 0)
and frappe.conf.get("pulse_api_key")
and on_frappecloud()
and frappe.get_system_settings("enable_telemetry")
)
@frappe.whitelist()
def capture(
event_name: str,
site: str | None = None,
app: str | None = None,
user: str | None = None,
captured_at: str | None = None,
properties: dict[str, Any] | None = None,
interval: int | str | None = None,
):
if not is_enabled():
return
try:
eq = EventQueue()
eq.add(
{
"event_name": event_name,
"captured_at": captured_at or utc_iso(),
"app": app,
"user": anonymize_user(user),
"site": site or frappe.local.site,
"properties": properties,
},
interval=interval,
)
except Exception as e:
frappe.logger("pulse").error(f"pulse-client - capture failed: {e!s}")
@frappe.whitelist()
def bulk_capture(events: str | list[dict[str, Any]]):
if not is_enabled():
return
if isinstance(events, str):
events = frappe.parse_json(events)
for event in events:
capture(
event.get("event_name"),
site=event.get("site"),
app=event.get("app"),
user=event.get("user") or frappe.session.user,
captured_at=event.get("captured_at"),
properties=event.get("properties"),
interval=event.get("interval"),
)
def send_queued_events():
if not is_enabled():
return
eq = EventQueue()
eq.batch_process(post, batch_size=100, max_batches=10)
def post(events):
session = _create_session()
url = _get_ingest_url()
data = frappe.as_json({"events": events})
resp = session.post(url, data=data, timeout=15)
if not (200 <= resp.status_code < 300):
msg = f"pulse-client - post failed: {resp.status_code} {resp.text}"
frappe.logger("pulse").error(msg)
raise Exception(msg)
return resp
def _create_session():
api_key = frappe.conf.get("pulse_api_key")
session = get_request_session()
session.headers.update(
{
"Content-Type": "application/json",
"X-Pulse-API-Key": api_key,
}
)
return session
def _get_ingest_url():
host = frappe.conf.get("pulse_host") or "https://pulse.m.frappe.cloud"
host = ensure_http(host)
host = host.rstrip("/")
endpoint = frappe.conf.get("pulse_ingest_endpoint") or "/api/method/pulse.api.bulk_ingest"
endpoint = endpoint.lstrip("/")
return f"{host}/{endpoint}"
class EventQueue:
def __init__(self):
self.queue = "pulse-client:events"
self.queue_size = 10000
self.ratelimit_prefix = "pulse-client:last_sent:"
@property
def length(self):
return frappe.cache.llen(self.queue)
def add(self, event, interval=None):
if self._is_ratelimited(event, interval):
return
self._queue_event(event)
self._update_ratelimit(event, interval)
def _is_ratelimited(self, event, interval):
if not interval:
return False
interval_seconds = parse_interval(interval)
event_key = self._get_event_key(event)
last_sent_key = f"{self.ratelimit_prefix}{event_key}"
last_sent = frappe.cache.get_value(last_sent_key)
if last_sent and time.monotonic() - float(last_sent) < interval_seconds:
return True
return False
def _get_event_key(self, event):
return f"{event.get('event_name')}:{event.get('site')}:{event.get('app')}:{event.get('user')}"
def _update_ratelimit(self, event, interval):
if not interval:
return
event_key = self._get_event_key(event)
last_sent_key = f"{self.ratelimit_prefix}{event_key}"
frappe.cache.set_value(last_sent_key, time.monotonic())
def _queue_event(self, event):
frappe.cache.lpush(self.queue, frappe.as_json(event))
frappe.cache.ltrim(self.queue, 0, self.queue_size - 1)
def batch_process(self, fn, batch_size=100, max_batches=10, max_retries=3, backoff_seconds=1):
pending_events = None
retry_attempts = 0
for _ in range(max_batches):
events = pending_events or self.collect(batch_size)
if not events:
break
try:
fn(events)
pending_events = None
retry_attempts = 0
except Exception as e:
retry_attempts += 1
if retry_attempts > max_retries:
# Tried enough times, re-queue pending events and exit.
frappe.logger("pulse").error(f"pulse-client - max retries reached: {e!s}")
self._requeue_events(events)
break
pending_events = events
time.sleep(backoff_seconds * (2 ** (retry_attempts - 1)))
frappe.logger("pulse").error(f"pulse-client - retrying batch due to error: {e!s}")
def collect(self, batch_size=100):
events = []
for _ in range(batch_size):
event_json = frappe.cache.rpop(self.queue)
if not event_json:
break
data = self._decode_event(event_json)
if data:
events.append(data)
return events
def _requeue_events(self, events):
# Preserve original processing order (FIFO): we pop from right, so re-add in reverse.
for event in reversed(events):
frappe.cache.rpush(self.queue, frappe.as_json(event))
frappe.cache.ltrim(self.queue, 0, self.queue_size - 1)
def _decode_event(self, event_json):
event_json = event_json.decode()
with suppress(JSONDecodeError):
return frappe.parse_json(event_json)
def get_events(self, limit=20):
events = []
for _ in range(limit):
event_json = frappe.cache.lindex(self.queue, _)
if not event_json:
break
data = self._decode_event(event_json)
if data:
events.append(data)
return events
def get_last_sent_events(self, limit=20):
events = []
keys = frappe.cache.get_keys(f"{self.ratelimit_prefix}*")[:limit]
for key in keys:
last_sent = frappe.cache.get_value(key)
event_key = key.replace(self.ratelimit_prefix, "")
events.append(
{
"event_key": event_key,
"last_sent": last_sent,
}
)
return events
@frappe.whitelist()
def get_debug_info(
fetch_events: int | str | bool | None = None, fetch_rate_limited_events: int | str | bool | None = None
):
frappe.only_for("System Manager")
info = frappe._dict()
info.is_enabled = is_enabled()
if info.is_enabled:
eq = EventQueue()
info.queued_event_count = eq.length
if fetch_events:
limit = int(fetch_events) if str(fetch_events).isdigit() else 20
info.queued_events = eq.get_events(limit)
if fetch_rate_limited_events:
limit = int(fetch_rate_limited_events) if str(fetch_rate_limited_events).isdigit() else 20
info.rate_limited_events = eq.get_last_sent_events(limit)
return info