mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
- Added PostHog configuration options to .env.example files for both Docker and Surfsense backend. - Introduced PostHog dependency in pyproject.toml. - Implemented analytics middleware to capture various events across the application, including user authentication, automation runs, and API requests. - Enhanced existing routes and services to emit analytics events, providing insights into user interactions and system performance. - Ensured graceful shutdown of analytics clients in worker processes and application lifecycles.
202 lines
5.9 KiB
Python
202 lines
5.9 KiB
Python
"""Server-side PostHog product analytics for SurfSense.
|
|
|
|
Opt-in, mirroring the OpenTelemetry bootstrap contract: when
|
|
``POSTHOG_API_KEY`` is unset every function here is a silent no-op, so it is
|
|
safe to call from hot paths (including async request handlers) and from
|
|
self-hosted installs that never configure telemetry.
|
|
|
|
Design notes:
|
|
- The underlying ``posthog`` client enqueues events onto a background
|
|
consumer thread, so ``capture()`` is a non-blocking queue append; the only
|
|
network I/O happens off-thread. ``shutdown()`` flushes and joins that thread
|
|
and MUST run before a process exits or queued events are lost.
|
|
- The client is created lazily on first use, never at import time. This keeps
|
|
it fork-safe under Celery's prefork pool: a client (and its consumer thread)
|
|
created in the parent would not survive ``fork()``, so each worker process
|
|
builds its own on first capture.
|
|
- ``distinct_id`` is always ``str(user.id)`` so server events merge onto the
|
|
same PostHog persons the web frontend identifies (see
|
|
``surfsense_web/components/providers/PostHogIdentify.tsx``).
|
|
- Every event passes ``disable_geoip=True``; without it PostHog would resolve
|
|
the *server's* IP and overwrite each person's real (client-derived) location.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from app.config import config
|
|
|
|
if TYPE_CHECKING:
|
|
from app.auth.context import AuthContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_client: Any | None = None
|
|
_init_attempted = False
|
|
_lock = threading.Lock()
|
|
|
|
# Stamped on every backend event so client-observed (frontend) and
|
|
# server-truth events are always distinguishable in PostHog.
|
|
_SOURCE = "backend"
|
|
|
|
|
|
def _get_client() -> Any | None:
|
|
"""Return the process-local PostHog client, or ``None`` when disabled.
|
|
|
|
Lazy + fork-safe: built on first use inside whichever process (web worker
|
|
or Celery worker) calls it, never at import time.
|
|
"""
|
|
global _client, _init_attempted
|
|
|
|
if _init_attempted:
|
|
return _client
|
|
|
|
with _lock:
|
|
if _init_attempted:
|
|
return _client
|
|
_init_attempted = True
|
|
|
|
api_key = config.POSTHOG_API_KEY
|
|
if not api_key:
|
|
# ponytail: opt-in like OTel — no key means telemetry is off, not
|
|
# a misconfiguration. Stay silent so self-hosters see no noise.
|
|
return None
|
|
|
|
try:
|
|
from posthog import Posthog
|
|
|
|
_client = Posthog(
|
|
project_api_key=api_key,
|
|
host=config.POSTHOG_HOST,
|
|
)
|
|
except Exception:
|
|
logger.warning("PostHog analytics init failed; disabling", exc_info=True)
|
|
_client = None
|
|
|
|
return _client
|
|
|
|
|
|
def is_enabled() -> bool:
|
|
"""True when a PostHog client is configured and available."""
|
|
return _get_client() is not None
|
|
|
|
|
|
def get_client() -> Any | None:
|
|
"""Raw PostHog client for integrations that need it (e.g. the LLM handler)."""
|
|
return _get_client()
|
|
|
|
|
|
def _client_label(auth: AuthContext) -> str:
|
|
"""Best-effort ``client`` property derived from the auth principal.
|
|
|
|
``session`` can't be split into web vs desktop from auth alone, so callers
|
|
that know better may override ``client`` in ``properties``.
|
|
"""
|
|
if auth.method == "system":
|
|
return auth.source or "system"
|
|
if auth.method == "pat":
|
|
return "pat"
|
|
return "web"
|
|
|
|
|
|
def capture(
|
|
event: str,
|
|
*,
|
|
distinct_id: str,
|
|
properties: dict[str, Any] | None = None,
|
|
groups: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""Capture a product event. No-op (and never raises) when disabled.
|
|
|
|
Wrapped in try/except like the frontend ``safeCapture`` — analytics must
|
|
never break a request. ``posthog`` v6 signature is ``capture(event,
|
|
distinct_id=..., properties=...)`` (event first, distinct_id a kwarg).
|
|
"""
|
|
client = _get_client()
|
|
if client is None:
|
|
return
|
|
|
|
try:
|
|
props = {"source": _SOURCE, **(properties or {})}
|
|
client.capture(
|
|
event,
|
|
distinct_id=distinct_id,
|
|
properties=props,
|
|
groups=groups,
|
|
disable_geoip=True,
|
|
)
|
|
except Exception:
|
|
logger.debug("PostHog capture failed for %s", event, exc_info=True)
|
|
|
|
|
|
def capture_for(
|
|
auth: AuthContext,
|
|
event: str,
|
|
properties: dict[str, Any] | None = None,
|
|
groups: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""Capture an event attributed to an ``AuthContext`` principal.
|
|
|
|
Derives ``distinct_id`` from the user id and stamps ``auth_method`` and a
|
|
best-effort ``client`` so events are attributable to their surface
|
|
(web/desktop/pat/gateway/automation).
|
|
"""
|
|
if _get_client() is None:
|
|
return
|
|
|
|
props = {
|
|
"auth_method": auth.method,
|
|
"client": _client_label(auth),
|
|
**(properties or {}),
|
|
}
|
|
capture(
|
|
event,
|
|
distinct_id=str(auth.user.id),
|
|
properties=props,
|
|
groups=groups,
|
|
)
|
|
|
|
|
|
def group_identify(
|
|
group_type: str,
|
|
group_key: str,
|
|
properties: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Upsert group properties (e.g. per-workspace metadata). No-op when disabled."""
|
|
client = _get_client()
|
|
if client is None:
|
|
return
|
|
|
|
try:
|
|
client.group_identify(
|
|
group_type=group_type,
|
|
group_key=group_key,
|
|
properties=properties or {},
|
|
)
|
|
except Exception:
|
|
logger.debug("PostHog group_identify failed for %s", group_type, exc_info=True)
|
|
|
|
|
|
def shutdown() -> None:
|
|
"""Flush queued events and stop the consumer thread. Safe to call always."""
|
|
global _client
|
|
client = _client
|
|
if client is None:
|
|
return
|
|
try:
|
|
client.shutdown()
|
|
except Exception:
|
|
logger.debug("PostHog shutdown failed", exc_info=True)
|
|
|
|
|
|
__all__ = [
|
|
"capture",
|
|
"capture_for",
|
|
"get_client",
|
|
"group_identify",
|
|
"is_enabled",
|
|
"shutdown",
|
|
]
|