375 lines
10 KiB
Python
375 lines
10 KiB
Python
"""Anonymous telemetry for Reflex."""
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import importlib.metadata
|
|
import json
|
|
import multiprocessing
|
|
import platform
|
|
import warnings
|
|
from contextlib import suppress
|
|
from datetime import datetime, timezone
|
|
from typing import TypedDict
|
|
|
|
from reflex_base import constants
|
|
from reflex_base.environment import environment
|
|
from reflex_base.utils.decorator import once, once_unless_none
|
|
from reflex_base.utils.exceptions import ReflexError
|
|
|
|
from reflex.utils import console, processes
|
|
from reflex.utils.js_runtimes import get_bun_version, get_node_version
|
|
from reflex.utils.prerequisites import ensure_reflex_installation_id, get_project_hash
|
|
|
|
UTC = timezone.utc
|
|
POSTHOG_API_URL: str = "https://app.posthog.com/capture/"
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class CpuInfo:
|
|
"""Model to save cpu info."""
|
|
|
|
manufacturer_id: str | None
|
|
model_name: str | None
|
|
address_width: int | None
|
|
|
|
|
|
def format_address_width(address_width: str | None) -> int | None:
|
|
"""Cast address width to an int.
|
|
|
|
Args:
|
|
address_width: The address width.
|
|
|
|
Returns:
|
|
Address width int
|
|
"""
|
|
try:
|
|
return int(address_width) if address_width else None
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _retrieve_cpu_info() -> CpuInfo | None:
|
|
"""Retrieve the CPU info of the host.
|
|
|
|
Returns:
|
|
The CPU info.
|
|
"""
|
|
platform_os = platform.system()
|
|
cpuinfo = {}
|
|
try:
|
|
if platform_os == "Windows":
|
|
cmd = 'powershell -Command "Get-CimInstance Win32_Processor | Select-Object -First 1 | Select-Object AddressWidth,Manufacturer,Name | ConvertTo-Json"'
|
|
output = processes.execute_command_and_return_output(cmd)
|
|
if output:
|
|
cpu_data = json.loads(output)
|
|
cpuinfo["address_width"] = cpu_data["AddressWidth"]
|
|
cpuinfo["manufacturer_id"] = cpu_data["Manufacturer"]
|
|
cpuinfo["model_name"] = cpu_data["Name"]
|
|
elif platform_os == "Linux":
|
|
output = processes.execute_command_and_return_output("lscpu")
|
|
if output:
|
|
lines = output.split("\n")
|
|
for line in lines:
|
|
if "Architecture" in line:
|
|
cpuinfo["address_width"] = (
|
|
64 if line.split(":")[1].strip() == "x86_64" else 32
|
|
)
|
|
if "Vendor ID:" in line:
|
|
cpuinfo["manufacturer_id"] = line.split(":")[1].strip()
|
|
if "Model name" in line:
|
|
cpuinfo["model_name"] = line.split(":")[1].strip()
|
|
elif platform_os == "Darwin":
|
|
cpuinfo["address_width"] = format_address_width(
|
|
processes.execute_command_and_return_output("getconf LONG_BIT")
|
|
)
|
|
cpuinfo["manufacturer_id"] = processes.execute_command_and_return_output(
|
|
"sysctl -n machdep.cpu.brand_string"
|
|
)
|
|
cpuinfo["model_name"] = processes.execute_command_and_return_output(
|
|
"uname -m"
|
|
)
|
|
except Exception as err:
|
|
console.error(f"Failed to retrieve CPU info. {err}")
|
|
return None
|
|
|
|
return (
|
|
CpuInfo(
|
|
manufacturer_id=cpuinfo.get("manufacturer_id"),
|
|
model_name=cpuinfo.get("model_name"),
|
|
address_width=cpuinfo.get("address_width"),
|
|
)
|
|
if cpuinfo
|
|
else None
|
|
)
|
|
|
|
|
|
@once
|
|
def get_cpu_info() -> CpuInfo | None:
|
|
"""Get the CPU info of the underlining host.
|
|
|
|
Returns:
|
|
The CPU info.
|
|
"""
|
|
cpu_info_file = environment.REFLEX_DIR.get() / "cpu_info.json"
|
|
if cpu_info_file.exists() and (cpu_info := json.loads(cpu_info_file.read_text())):
|
|
return CpuInfo(**cpu_info)
|
|
cpu_info = _retrieve_cpu_info()
|
|
if cpu_info:
|
|
cpu_info_file.parent.mkdir(parents=True, exist_ok=True)
|
|
cpu_info_file.write_text(json.dumps(dataclasses.asdict(cpu_info)))
|
|
return cpu_info
|
|
|
|
|
|
def get_os() -> str:
|
|
"""Get the operating system.
|
|
|
|
Returns:
|
|
The operating system.
|
|
"""
|
|
return platform.system()
|
|
|
|
|
|
def get_detailed_platform_str() -> str:
|
|
"""Get the detailed os/platform string.
|
|
|
|
Returns:
|
|
The platform string
|
|
"""
|
|
return platform.platform()
|
|
|
|
|
|
def get_python_version() -> str:
|
|
"""Get the Python version.
|
|
|
|
Returns:
|
|
The Python version.
|
|
"""
|
|
# Remove the "+" from the version string in case user is using a pre-release version.
|
|
return platform.python_version().rstrip("+")
|
|
|
|
|
|
def get_reflex_version() -> str:
|
|
"""Get the Reflex version.
|
|
|
|
Returns:
|
|
The Reflex version.
|
|
"""
|
|
return constants.Reflex.VERSION
|
|
|
|
|
|
def get_cpu_count() -> int:
|
|
"""Get the number of CPUs.
|
|
|
|
Returns:
|
|
The number of CPUs.
|
|
"""
|
|
return multiprocessing.cpu_count()
|
|
|
|
|
|
def get_reflex_enterprise_version() -> str | None:
|
|
"""Get the version of reflex-enterprise if installed.
|
|
|
|
Returns:
|
|
The version string if installed, None if not installed.
|
|
"""
|
|
try:
|
|
return importlib.metadata.version("reflex-enterprise")
|
|
except importlib.metadata.PackageNotFoundError:
|
|
return None
|
|
|
|
|
|
def _raise_on_missing_project_hash() -> bool:
|
|
"""Check if an error should be raised when project hash is missing.
|
|
|
|
When running reflex with --backend-only, or doing database migration
|
|
operations, there is no requirement for a .web directory, so the reflex.json
|
|
file may not exist, and this should not be considered an error.
|
|
|
|
Returns:
|
|
False when compilation should be skipped (i.e. no .web directory is required).
|
|
Otherwise return True.
|
|
"""
|
|
return not environment.REFLEX_SKIP_COMPILE.get()
|
|
|
|
|
|
class _Properties(TypedDict):
|
|
"""Properties type for telemetry."""
|
|
|
|
distinct_id: int
|
|
distinct_app_id: int
|
|
user_os: str
|
|
user_os_detail: str
|
|
reflex_version: str
|
|
python_version: str
|
|
node_version: str | None
|
|
bun_version: str | None
|
|
reflex_enterprise_version: str | None
|
|
cpu_count: int
|
|
cpu_info: dict
|
|
|
|
|
|
class _DefaultEvent(TypedDict):
|
|
"""Default event type for telemetry."""
|
|
|
|
api_key: str
|
|
properties: _Properties
|
|
|
|
|
|
class _Event(_DefaultEvent):
|
|
"""Event type for telemetry."""
|
|
|
|
event: str
|
|
timestamp: str
|
|
|
|
|
|
def _get_event_defaults() -> _DefaultEvent | None:
|
|
"""Get the default event data.
|
|
|
|
Returns:
|
|
The default event data.
|
|
"""
|
|
installation_id = ensure_reflex_installation_id()
|
|
project_hash = get_project_hash(raise_on_fail=_raise_on_missing_project_hash())
|
|
|
|
if installation_id is None or project_hash is None:
|
|
console.debug(
|
|
f"Could not get installation_id or project_hash: {installation_id}, {project_hash}"
|
|
)
|
|
return None
|
|
|
|
cpuinfo = get_cpu_info()
|
|
|
|
return {
|
|
"api_key": "phc_JoMo0fOyi0GQAooY3UyO9k0hebGkMyFJrrCw1Gt5SGb",
|
|
"properties": {
|
|
"distinct_id": installation_id,
|
|
"distinct_app_id": project_hash,
|
|
"user_os": get_os(),
|
|
"user_os_detail": get_detailed_platform_str(),
|
|
"reflex_version": get_reflex_version(),
|
|
"python_version": get_python_version(),
|
|
"node_version": (
|
|
str(node_version) if (node_version := get_node_version()) else None
|
|
),
|
|
"bun_version": (
|
|
str(bun_version) if (bun_version := get_bun_version()) else None
|
|
),
|
|
"reflex_enterprise_version": get_reflex_enterprise_version(),
|
|
"cpu_count": get_cpu_count(),
|
|
"cpu_info": dataclasses.asdict(cpuinfo) if cpuinfo else {},
|
|
},
|
|
}
|
|
|
|
|
|
@once_unless_none
|
|
def get_event_defaults() -> _DefaultEvent | None:
|
|
"""Get the default event data.
|
|
|
|
Returns:
|
|
The default event data.
|
|
"""
|
|
return _get_event_defaults()
|
|
|
|
|
|
def _prepare_event(event: str, **kwargs) -> _Event | None:
|
|
"""Prepare the event to be sent to the PostHog server.
|
|
|
|
Args:
|
|
event: The event name.
|
|
kwargs: Additional data to send with the event.
|
|
|
|
Returns:
|
|
The event data.
|
|
"""
|
|
event_data = get_event_defaults()
|
|
if not event_data:
|
|
return None
|
|
|
|
additional_keys = ["template", "context", "detail", "user_uuid"]
|
|
|
|
properties = event_data["properties"]
|
|
|
|
for key in additional_keys:
|
|
if key in properties or key not in kwargs:
|
|
continue
|
|
|
|
properties[key] = kwargs[key]
|
|
|
|
stamp = datetime.now(UTC).isoformat()
|
|
|
|
return {
|
|
"api_key": event_data["api_key"],
|
|
"event": event,
|
|
"properties": properties,
|
|
"timestamp": stamp,
|
|
}
|
|
|
|
|
|
def _send_event(event_data: _Event) -> bool:
|
|
import httpx
|
|
|
|
try:
|
|
httpx.post(POSTHOG_API_URL, json=event_data)
|
|
except Exception:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def _send(event: str, telemetry_enabled: bool | None, **kwargs) -> bool:
|
|
from reflex_base.config import get_config
|
|
|
|
# Get the telemetry_enabled from the config if it is not specified.
|
|
if telemetry_enabled is None:
|
|
telemetry_enabled = get_config().telemetry_enabled
|
|
|
|
# Return if telemetry is disabled.
|
|
if not telemetry_enabled:
|
|
return False
|
|
|
|
with suppress(Exception):
|
|
event_data = _prepare_event(event, **kwargs)
|
|
if not event_data:
|
|
return False
|
|
return _send_event(event_data)
|
|
return False
|
|
|
|
|
|
background_tasks = set()
|
|
|
|
|
|
def send(event: str, telemetry_enabled: bool | None = None, **kwargs):
|
|
"""Send anonymous telemetry for Reflex.
|
|
|
|
Args:
|
|
event: The event name.
|
|
telemetry_enabled: Whether to send the telemetry (If None, get from config).
|
|
kwargs: Additional data to send with the event.
|
|
"""
|
|
|
|
async def async_send(event: str, telemetry_enabled: bool | None, **kwargs): # noqa: RUF029
|
|
return _send(event, telemetry_enabled, **kwargs)
|
|
|
|
try:
|
|
# Within an event loop context, send the event asynchronously.
|
|
task = asyncio.create_task(
|
|
async_send(event, telemetry_enabled, **kwargs),
|
|
name=f"reflex_send_telemetry_event|{event}",
|
|
)
|
|
background_tasks.add(task)
|
|
task.add_done_callback(background_tasks.discard)
|
|
except RuntimeError:
|
|
# If there is no event loop, send the event synchronously.
|
|
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
_send(event, telemetry_enabled, **kwargs)
|
|
|
|
|
|
def send_error(error: Exception, context: str):
|
|
"""Send an error event.
|
|
|
|
Args:
|
|
error: The error to send.
|
|
context: The context of the error (e.g. "frontend" or "backend")
|
|
"""
|
|
if isinstance(error, ReflexError):
|
|
send("error", detail=type(error).__name__, context=context)
|