telemetry.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """Anonymous telemetry for Reflex."""
  2. from __future__ import annotations
  3. import asyncio
  4. import dataclasses
  5. import importlib.metadata
  6. import multiprocessing
  7. import platform
  8. import warnings
  9. from contextlib import suppress
  10. from datetime import datetime, timezone
  11. from typing import TypedDict
  12. import httpx
  13. import psutil
  14. from reflex import constants
  15. from reflex.environment import environment
  16. from reflex.utils import console
  17. from reflex.utils.decorator import once_unless_none
  18. from reflex.utils.exceptions import ReflexError
  19. from reflex.utils.prerequisites import (
  20. ensure_reflex_installation_id,
  21. get_bun_version,
  22. get_node_version,
  23. get_project_hash,
  24. )
  25. UTC = timezone.utc
  26. POSTHOG_API_URL: str = "https://app.posthog.com/capture/"
  27. def get_os() -> str:
  28. """Get the operating system.
  29. Returns:
  30. The operating system.
  31. """
  32. return platform.system()
  33. def get_detailed_platform_str() -> str:
  34. """Get the detailed os/platform string.
  35. Returns:
  36. The platform string
  37. """
  38. return platform.platform()
  39. def get_python_version() -> str:
  40. """Get the Python version.
  41. Returns:
  42. The Python version.
  43. """
  44. # Remove the "+" from the version string in case user is using a pre-release version.
  45. return platform.python_version().rstrip("+")
  46. def get_reflex_version() -> str:
  47. """Get the Reflex version.
  48. Returns:
  49. The Reflex version.
  50. """
  51. return constants.Reflex.VERSION
  52. def get_cpu_count() -> int:
  53. """Get the number of CPUs.
  54. Returns:
  55. The number of CPUs.
  56. """
  57. return multiprocessing.cpu_count()
  58. def get_reflex_enterprise_version() -> str | None:
  59. """Get the version of reflex-enterprise if installed.
  60. Returns:
  61. The version string if installed, None if not installed.
  62. """
  63. try:
  64. return importlib.metadata.version("reflex-enterprise")
  65. except importlib.metadata.PackageNotFoundError:
  66. return None
  67. def get_memory() -> int:
  68. """Get the total memory in MB.
  69. Returns:
  70. The total memory in MB.
  71. """
  72. try:
  73. return psutil.virtual_memory().total >> 20
  74. except ValueError: # needed to pass ubuntu test
  75. return 0
  76. def _raise_on_missing_project_hash() -> bool:
  77. """Check if an error should be raised when project hash is missing.
  78. When running reflex with --backend-only, or doing database migration
  79. operations, there is no requirement for a .web directory, so the reflex.json
  80. file may not exist, and this should not be considered an error.
  81. Returns:
  82. False when compilation should be skipped (i.e. no .web directory is required).
  83. Otherwise return True.
  84. """
  85. return not environment.REFLEX_SKIP_COMPILE.get()
  86. class _Properties(TypedDict):
  87. """Properties type for telemetry."""
  88. distinct_id: int
  89. distinct_app_id: int
  90. user_os: str
  91. user_os_detail: str
  92. reflex_version: str
  93. python_version: str
  94. node_version: str | None
  95. bun_version: str | None
  96. reflex_enterprise_version: str | None
  97. cpu_count: int
  98. memory: int
  99. cpu_info: dict
  100. class _DefaultEvent(TypedDict):
  101. """Default event type for telemetry."""
  102. api_key: str
  103. properties: _Properties
  104. class _Event(_DefaultEvent):
  105. """Event type for telemetry."""
  106. event: str
  107. timestamp: str
  108. def _get_event_defaults() -> _DefaultEvent | None:
  109. """Get the default event data.
  110. Returns:
  111. The default event data.
  112. """
  113. from reflex.utils.prerequisites import get_cpu_info
  114. installation_id = ensure_reflex_installation_id()
  115. project_hash = get_project_hash(raise_on_fail=_raise_on_missing_project_hash())
  116. if installation_id is None or project_hash is None:
  117. console.debug(
  118. f"Could not get installation_id or project_hash: {installation_id}, {project_hash}"
  119. )
  120. return None
  121. cpuinfo = get_cpu_info()
  122. return {
  123. "api_key": "phc_JoMo0fOyi0GQAooY3UyO9k0hebGkMyFJrrCw1Gt5SGb",
  124. "properties": {
  125. "distinct_id": installation_id,
  126. "distinct_app_id": project_hash,
  127. "user_os": get_os(),
  128. "user_os_detail": get_detailed_platform_str(),
  129. "reflex_version": get_reflex_version(),
  130. "python_version": get_python_version(),
  131. "node_version": (
  132. str(node_version) if (node_version := get_node_version()) else None
  133. ),
  134. "bun_version": (
  135. str(bun_version) if (bun_version := get_bun_version()) else None
  136. ),
  137. "reflex_enterprise_version": get_reflex_enterprise_version(),
  138. "cpu_count": get_cpu_count(),
  139. "memory": get_memory(),
  140. "cpu_info": dataclasses.asdict(cpuinfo) if cpuinfo else {},
  141. },
  142. }
  143. @once_unless_none
  144. def get_event_defaults() -> _DefaultEvent | None:
  145. """Get the default event data.
  146. Returns:
  147. The default event data.
  148. """
  149. return _get_event_defaults()
  150. def _prepare_event(event: str, **kwargs) -> _Event | None:
  151. """Prepare the event to be sent to the PostHog server.
  152. Args:
  153. event: The event name.
  154. kwargs: Additional data to send with the event.
  155. Returns:
  156. The event data.
  157. """
  158. event_data = get_event_defaults()
  159. if not event_data:
  160. return None
  161. additional_keys = ["template", "context", "detail", "user_uuid"]
  162. properties = event_data["properties"]
  163. for key in additional_keys:
  164. if key in properties or key not in kwargs:
  165. continue
  166. properties[key] = kwargs[key]
  167. stamp = datetime.now(UTC).isoformat()
  168. return {
  169. "api_key": event_data["api_key"],
  170. "event": event,
  171. "properties": properties,
  172. "timestamp": stamp,
  173. }
  174. def _send_event(event_data: _Event) -> bool:
  175. try:
  176. httpx.post(POSTHOG_API_URL, json=event_data)
  177. except Exception:
  178. return False
  179. else:
  180. return True
  181. def _send(event: str, telemetry_enabled: bool | None, **kwargs) -> bool:
  182. from reflex.config import get_config
  183. # Get the telemetry_enabled from the config if it is not specified.
  184. if telemetry_enabled is None:
  185. telemetry_enabled = get_config().telemetry_enabled
  186. # Return if telemetry is disabled.
  187. if not telemetry_enabled:
  188. return False
  189. with suppress(Exception):
  190. event_data = _prepare_event(event, **kwargs)
  191. if not event_data:
  192. return False
  193. return _send_event(event_data)
  194. return False
  195. background_tasks = set()
  196. def send(event: str, telemetry_enabled: bool | None = None, **kwargs):
  197. """Send anonymous telemetry for Reflex.
  198. Args:
  199. event: The event name.
  200. telemetry_enabled: Whether to send the telemetry (If None, get from config).
  201. kwargs: Additional data to send with the event.
  202. """
  203. async def async_send(event: str, telemetry_enabled: bool | None, **kwargs):
  204. return _send(event, telemetry_enabled, **kwargs)
  205. try:
  206. # Within an event loop context, send the event asynchronously.
  207. task = asyncio.create_task(async_send(event, telemetry_enabled, **kwargs))
  208. background_tasks.add(task)
  209. task.add_done_callback(background_tasks.discard)
  210. except RuntimeError:
  211. # If there is no event loop, send the event synchronously.
  212. warnings.filterwarnings("ignore", category=RuntimeWarning)
  213. _send(event, telemetry_enabled, **kwargs)
  214. def send_error(error: Exception, context: str):
  215. """Send an error event.
  216. Args:
  217. error: The error to send.
  218. context: The context of the error (e.g. "frontend" or "backend")
  219. """
  220. if isinstance(error, ReflexError):
  221. send("error", detail=type(error).__name__, context=context)