feat(backend): integrate PostHog analytics for enhanced observability

- 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.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-22 22:16:28 -07:00
parent ca4f231577
commit dbedf0cfa5
47 changed files with 1618 additions and 513 deletions

View file

@ -392,6 +392,15 @@ STT_SERVICE=local/base
# OTEL_HTTP_PORT=4318 # OTEL_HTTP_PORT=4318
# OTEL_HEALTH_PORT=13133 # OTEL_HEALTH_PORT=13133
# PostHog product analytics (server-side). Opt-in like OTel: leave
# POSTHOG_API_KEY unset for zero telemetry. Passed to backend/worker/beat via
# env_file, so no compose changes are needed. Use the SAME project key as the
# frontend's NEXT_PUBLIC_POSTHOG_KEY so server events merge onto the persons the
# web app already identifies by user id.
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# POSTHOG_HOST=https://us.i.posthog.com
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Advanced (optional) # Advanced (optional)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes # OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
# OTEL_SDK_DISABLED=true # emergency kill-switch # OTEL_SDK_DISABLED=true # emergency kill-switch
# Observability - PostHog product analytics (server-side)
# Opt-in like OTel: leave POSTHOG_API_KEY unset for zero telemetry. Use the
# SAME project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
# merge onto the persons the web app already identifies by user id.
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# POSTHOG_HOST=https://us.i.posthog.com
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
# Skills + subagents # Skills + subagents
# SURFSENSE_ENABLE_SKILLS=false # SURFSENSE_ENABLE_SKILLS=false
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false # SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false

View file

@ -50,6 +50,7 @@ from app.gateway.inbox_worker import (
start_gateway_inbox_worker, start_gateway_inbox_worker,
stop_gateway_inbox_worker, stop_gateway_inbox_worker,
) )
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics
from app.observability.bootstrap import init_otel, shutdown_otel from app.observability.bootstrap import init_otel, shutdown_otel
from app.rate_limiter import get_real_client_ip, limiter from app.rate_limiter import get_real_client_ip, limiter
@ -690,6 +691,7 @@ async def lifespan(app: FastAPI):
await stop_gateway_inbox_worker() await stop_gateway_inbox_worker()
_stop_openrouter_background_refresh() _stop_openrouter_background_refresh()
await close_checkpointer() await close_checkpointer()
ph_analytics.shutdown()
shutdown_otel() shutdown_otel()
@ -796,6 +798,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
app.add_middleware(RequestPerfMiddleware) app.add_middleware(RequestPerfMiddleware)
# ---------------------------------------------------------------------------
# PAT / MCP API attribution middleware
# ---------------------------------------------------------------------------
# Emits a PostHog ``pat_api_request`` event for any request authenticated by a
# Personal Access Token, so "documents added via MCP", "searches via MCP" etc.
# are queryable without instrumenting each route. Relies on ``get_auth_context``
# stashing the resolved principal on ``request.state.auth_context``; requests
# that never resolve a PAT principal are silently skipped. No-op when PostHog
# is unconfigured.
class PatApiAnalyticsMiddleware(BaseHTTPMiddleware):
"""Capture PAT-authenticated API usage (incl. MCP) after each response."""
async def dispatch(
self, request: StarletteRequest, call_next: RequestResponseEndpoint
) -> StarletteResponse:
response = await call_next(request)
with contextlib.suppress(Exception):
ctx = getattr(request.state, "auth_context", None)
if (
ctx is not None
and ctx.method == "pat"
and ph_analytics.is_enabled()
):
# Use the route *template* (e.g. /documents/{id}) to keep the
# ``route`` property low-cardinality; fall back to the raw path.
route = request.scope.get("route")
route_path = getattr(route, "path", None) or request.url.path
client = (
"mcp"
if request.headers.get("X-SurfSense-Client") == "mcp"
else "pat_script"
)
ph_analytics.capture_for(
ctx,
"pat_api_request",
{
"route": route_path,
"method": request.method,
"status_code": response.status_code,
"client": client,
},
)
return response
app.add_middleware(PatApiAnalyticsMiddleware)
# Add SlowAPI middleware for automatic rate limiting # Add SlowAPI middleware for automatic rate limiting
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid # Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends # corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends

View file

@ -15,11 +15,38 @@ from app.automations.schemas.definition.envelope import (
) )
from app.automations.schemas.definition.plan_step import PlanStep from app.automations.schemas.definition.plan_step import PlanStep
from app.automations.templating import build_run_context from app.automations.templating import build_run_context
from app.observability import analytics as ph_analytics
from . import repository from . import repository
from .step import execute_step from .step import execute_step
def _capture_run_outcome(run: AutomationRun, status: str) -> None:
"""Emit ``automation_run_completed`` — headless runs the frontend never sees.
No-op when PostHog is unconfigured or the owning user is unknown.
"""
automation = run.automation
creator_id = getattr(automation, "created_by_user_id", None)
if not creator_id:
return
workspace_id = getattr(automation, "workspace_id", None)
ph_analytics.capture(
"automation_run_completed",
distinct_id=str(creator_id),
properties={
"automation_id": run.automation_id,
"run_id": run.id,
"workspace_id": workspace_id,
"status": status,
"trigger_type": run.trigger.type.value if run.trigger else None,
},
groups={"workspace": str(workspace_id)}
if workspace_id is not None
else None,
)
async def execute_run(session: AsyncSession, run_id: int) -> None: async def execute_run(session: AsyncSession, run_id: int) -> None:
"""Load run ``run_id`` and execute its snapshot plan to a terminal state.""" """Load run ``run_id`` and execute its snapshot plan to a terminal state."""
run = await repository.load_run(session, run_id) run = await repository.load_run(session, run_id)
@ -41,6 +68,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
}, },
) )
await session.commit() await session.commit()
_capture_run_outcome(run, "failed")
return return
await repository.mark_running(session, run) await repository.mark_running(session, run)
@ -66,6 +94,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await _run_on_failure(session, run, definition) await _run_on_failure(session, run, definition)
await repository.mark_failed(session, run, result.get("error")) await repository.mark_failed(session, run, result.get("error"))
await session.commit() await session.commit()
_capture_run_outcome(run, "failed")
return return
if result["status"] == "succeeded": if result["status"] == "succeeded":
@ -73,6 +102,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
await repository.mark_succeeded(session, run) await repository.mark_succeeded(session, run)
await session.commit() await session.commit()
_capture_run_outcome(run, "succeeded")
async def _run_on_failure( async def _run_on_failure(

View file

@ -29,6 +29,7 @@ from app.automations.services.model_policy import (
from app.automations.triggers import get_trigger from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, Workspace, get_async_session from app.db import Permission, Workspace, get_async_session
from app.observability import analytics as ph_analytics
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -75,6 +76,18 @@ class AutomationService:
self.session.add(automation) self.session.add(automation)
await self.session.commit() await self.session.commit()
# Authoritative creation (migrated from automations-mutation.atoms.ts).
ph_analytics.capture_for(
self.auth,
"automation_created",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"trigger_count": len(payload.triggers),
},
groups={"workspace": str(automation.workspace_id)},
)
return await self._get_with_triggers_or_raise(automation.id) return await self._get_with_triggers_or_raise(automation.id)
async def list( async def list(
@ -150,6 +163,31 @@ class AutomationService:
automation.version += 1 automation.version += 1
await self.session.commit() await self.session.commit()
# Migrated from automations-mutation.atoms.ts: a status-only change is a
# distinct event; other field edits are ``automation_updated``.
if "status" in data:
ph_analytics.capture_for(
self.auth,
"automation_status_changed",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"next_status": str(data["status"]),
},
groups={"workspace": str(automation.workspace_id)},
)
if any(k in data for k in ("name", "description", "definition")):
ph_analytics.capture_for(
self.auth,
"automation_updated",
{
"automation_id": automation.id,
"workspace_id": automation.workspace_id,
"has_definition_change": "definition" in data,
},
groups={"workspace": str(automation.workspace_id)},
)
return await self._get_with_triggers_or_raise(automation_id) return await self._get_with_triggers_or_raise(automation_id)
async def delete(self, automation_id: int) -> None: async def delete(self, automation_id: int) -> None:
@ -158,9 +196,18 @@ class AutomationService:
await self._authorize( await self._authorize(
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
) )
workspace_id = automation.workspace_id
await self.session.delete(automation) await self.session.delete(automation)
await self.session.commit() await self.session.commit()
# Authoritative deletion (migrated from automations-mutation.atoms.ts).
ph_analytics.capture_for(
self.auth,
"automation_deleted",
{"automation_id": automation_id, "workspace_id": workspace_id},
groups={"workspace": str(workspace_id)},
)
async def _get_or_raise(self, automation_id: int) -> Automation: async def _get_or_raise(self, automation_id: int) -> Automation:
automation = await self.session.get(Automation, automation_id) automation = await self.session.get(Automation, automation_id)
if automation is None: if automation is None:

View file

@ -16,6 +16,7 @@ from app.automations.schemas.api import TriggerCreate, TriggerUpdate
from app.automations.triggers import get_trigger from app.automations.triggers import get_trigger
from app.automations.triggers.builtin.schedule import compute_next_fire_at from app.automations.triggers.builtin.schedule import compute_next_fire_at
from app.db import Permission, get_async_session from app.db import Permission, get_async_session
from app.observability import analytics as ph_analytics
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
@ -48,6 +49,18 @@ class TriggerService:
self.session.add(trigger) self.session.add(trigger)
await self.session.commit() await self.session.commit()
await self.session.refresh(trigger) await self.session.refresh(trigger)
# Migrated from automations-mutation.atoms.ts.
ph_analytics.capture_for(
self.auth,
"automation_trigger_added",
{
"automation_id": automation_id,
"trigger_id": trigger.id,
"trigger_type": getattr(trigger.type, "value", str(trigger.type)),
"enabled": trigger.enabled,
},
)
return trigger return trigger
async def update( async def update(
@ -82,6 +95,26 @@ class TriggerService:
await self.session.commit() await self.session.commit()
await self.session.refresh(trigger) await self.session.refresh(trigger)
# Migrated from automations-mutation.atoms.ts. ``change`` mirrors the
# frontend's coarse categorisation.
_change = (
"enabled"
if "enabled" in data and "params" not in data
else "params"
if "params" in data
else "other"
)
ph_analytics.capture_for(
self.auth,
"automation_trigger_updated",
{
"automation_id": automation_id,
"trigger_id": trigger_id,
"change": _change,
"enabled": trigger.enabled,
},
)
return trigger return trigger
async def remove(self, *, automation_id: int, trigger_id: int) -> None: async def remove(self, *, automation_id: int, trigger_id: int) -> None:
@ -92,6 +125,13 @@ class TriggerService:
await self.session.delete(trigger) await self.session.delete(trigger)
await self.session.commit() await self.session.commit()
# Migrated from automations-mutation.atoms.ts.
ph_analytics.capture_for(
self.auth,
"automation_trigger_removed",
{"automation_id": automation_id, "trigger_id": trigger_id},
)
async def _authorize_automation( async def _authorize_automation(
self, automation_id: int, permission: str self, automation_id: int, permission: str
) -> Automation: ) -> Automation:

View file

@ -45,6 +45,7 @@ from app.capabilities.core.store import all_capabilities
from app.capabilities.core.types import Capability, CapabilityContext from app.capabilities.core.types import Capability, CapabilityContext
from app.db import Run, async_session_maker, get_async_session from app.db import Run, async_session_maker, get_async_session
from app.exceptions import ExternalServiceError, SurfSenseError from app.exceptions import ExternalServiceError, SurfSenseError
from app.observability import analytics as ph_analytics
from app.services.web_crawl_credit_service import InsufficientCreditsError from app.services.web_crawl_credit_service import InsufficientCreditsError
from app.users import get_auth_context from app.users import get_auth_context
from app.utils.rbac import check_workspace_access from app.utils.rbac import check_workspace_access
@ -107,6 +108,41 @@ def _origin_for(auth: AuthContext) -> str:
return "ui" if getattr(auth, "method", None) == "session" else "api" return "ui" if getattr(auth, "method", None) == "session" else "api"
def _capture_scraper_run(
*,
capability: str,
status: str,
user_id,
origin: str,
duration_ms: int | None = None,
item_count: int | None = None,
cost_micros: int | None = None,
) -> None:
"""Emit ``scraper_run_completed`` — scrapers are the highest-value MCP surface.
Covers both sync and async runs; the async background task never flows
through the PAT middleware, so this is the only place its outcome is
captured. No-op when PostHog is unconfigured.
"""
if not ph_analytics.is_enabled() or not user_id:
return
platform, _, verb = capability.partition(".")
ph_analytics.capture(
"scraper_run_completed",
distinct_id=str(user_id),
properties={
"platform": platform,
"verb": verb,
"capability": capability,
"status": status,
"origin": origin,
"duration_ms": duration_ms,
"item_count": item_count,
"cost_micros": cost_micros,
},
)
def _now_ms() -> int: def _now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
@ -185,6 +221,8 @@ async def _execute_async_run(
unit, unit,
executor, executor,
payload, payload,
user_id=None,
origin: str = "api",
) -> None: ) -> None:
"""Run a scrape in the background: stream progress, charge, finalize the row. """Run a scrape in the background: stream progress, charge, finalize the row.
@ -218,6 +256,13 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "error", error=str(exc)) _publish_finished(run_id, "error", error=str(exc))
_capture_scraper_run(
capability=capability,
status="error",
user_id=user_id,
origin=origin,
duration_ms=int((time.perf_counter() - started) * 1000),
)
return return
except Exception: except Exception:
logger.exception("async run %s failed with an upstream error", run_id) logger.exception("async run %s failed with an upstream error", run_id)
@ -229,6 +274,13 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "error", error="upstream error") _publish_finished(run_id, "error", error="upstream error")
_capture_scraper_run(
capability=capability,
status="error",
user_id=user_id,
origin=origin,
duration_ms=int((time.perf_counter() - started) * 1000),
)
return return
duration_ms = int((time.perf_counter() - started) * 1000) duration_ms = int((time.perf_counter() - started) * 1000)
@ -252,6 +304,15 @@ async def _execute_async_run(
progress=reporter.coarse, progress=reporter.coarse,
) )
_publish_finished(run_id, "success", item_count=serialized.item_count) _publish_finished(run_id, "success", item_count=serialized.item_count)
_capture_scraper_run(
capability=capability,
status="success",
user_id=user_id,
origin=origin,
duration_ms=duration_ms,
item_count=serialized.item_count,
cost_micros=cost_micros,
)
async def _finalize_async( async def _finalize_async(
@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
unit=unit, unit=unit,
executor=executor, executor=executor,
payload=payload, payload=payload,
user_id=user_id,
origin=origin,
) )
) )
run_event_bus.register_task(run_id, task) run_event_bus.register_task(run_id, task)
@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
try: try:
output = await executor(payload) output = await executor(payload)
except (SurfSenseError, HTTPException) as exc: except (SurfSenseError, HTTPException) as exc:
_sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run( await _record_rest_run(
workspace_id=workspace_id, workspace_id=workspace_id,
capability=name, capability=name,
@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump, input=input_dump,
user_id=user_id, user_id=user_id,
error=str(exc), error=str(exc),
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=_sync_err_duration,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="error",
user_id=user_id,
origin=origin,
duration_ms=_sync_err_duration,
)
raise raise
except Exception as exc: except Exception as exc:
_sync_err_duration = int((time.perf_counter() - started) * 1000)
await _record_rest_run( await _record_rest_run(
workspace_id=workspace_id, workspace_id=workspace_id,
capability=name, capability=name,
@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
input=input_dump, input=input_dump,
user_id=user_id, user_id=user_id,
error=str(exc), error=str(exc),
duration_ms=int((time.perf_counter() - started) * 1000), duration_ms=_sync_err_duration,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="error",
user_id=user_id,
origin=origin,
duration_ms=_sync_err_duration,
)
raise ExternalServiceError( raise ExternalServiceError(
f"The '{name}' capability failed due to an upstream error.", f"The '{name}' capability failed due to an upstream error.",
code="CAPABILITY_UPSTREAM_ERROR", code="CAPABILITY_UPSTREAM_ERROR",
@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
cost_micros=cost_micros, cost_micros=cost_micros,
progress=reporter.coarse, progress=reporter.coarse,
) )
_capture_scraper_run(
capability=name,
status="success",
user_id=user_id,
origin=origin,
duration_ms=duration_ms,
item_count=serialized.item_count,
cost_micros=cost_micros,
)
if run_id is not None: if run_id is not None:
response.headers["X-Run-Id"] = f"run_{run_id}" response.headers["X-Run-Id"] = f"run_{run_id}"
return output return output

View file

@ -10,6 +10,7 @@ from celery.signals import (
task_postrun, task_postrun,
task_prerun, task_prerun,
worker_process_init, worker_process_init,
worker_process_shutdown,
) )
from dotenv import load_dotenv from dotenv import load_dotenv
@ -123,6 +124,18 @@ def init_worker(**kwargs):
initialize_image_gen_router() initialize_image_gen_router()
@worker_process_shutdown.connect
def shutdown_worker(**kwargs):
"""Flush queued PostHog events before a Celery worker process exits.
The analytics client init is lazy (fork-safe), so there is nothing to
start here only a flush to avoid dropping events captured by tasks.
"""
from app.observability import analytics as ph_analytics
ph_analytics.shutdown()
# Celery configuration, sourced from the central Config singleton # Celery configuration, sourced from the central Config singleton
CELERY_BROKER_URL = config.CELERY_BROKER_URL CELERY_BROKER_URL = config.CELERY_BROKER_URL
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND

View file

@ -1179,6 +1179,19 @@ class Config:
os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE" os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE"
) )
# PostHog server-side product analytics (opt-in, mirrors the OTel pattern:
# no key set => the analytics wrapper is a silent no-op). Use the SAME
# project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
# merge onto the persons the web app already identifies by user id.
POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY")
POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com")
# When true (default), the LLM-analytics LangChain handler suppresses
# prompt/completion bodies ($ai_input / $ai_output_choices) and captures
# only metrics — chat content includes users' private documents.
POSTHOG_AI_PRIVACY_MODE = (
os.getenv("POSTHOG_AI_PRIVACY_MODE", "TRUE").upper() == "TRUE"
)
# Litellm TTS Configuration # Litellm TTS Configuration
TTS_SERVICE = os.getenv("TTS_SERVICE") TTS_SERVICE = os.getenv("TTS_SERVICE")
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE") TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")

View file

@ -6,4 +6,4 @@ wrapper is a no-op when OTEL is not configured, so importing it from
performance-critical paths is safe. performance-critical paths is safe.
""" """
__all__ = ["bootstrap", "metrics", "otel"] __all__ = ["analytics", "bootstrap", "metrics", "otel"]

View file

@ -0,0 +1,202 @@
"""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",
]

View file

@ -11,7 +11,10 @@ import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from sqlalchemy import select
from app.celery_app import celery_app from app.celery_app import celery_app
from app.observability import analytics as ph_analytics
from app.podcasts.persistence import PodcastRepository from app.podcasts.persistence import PodcastRepository
from app.podcasts.rendering import PodcastRenderer from app.podcasts.rendering import PodcastRenderer
from app.podcasts.service import ( from app.podcasts.service import (
@ -76,6 +79,30 @@ async def _render_audio(podcast_id: int) -> dict:
podcast, storage_backend=backend_name, storage_key=key podcast, storage_backend=backend_name, storage_key=key
) )
await session.commit() await session.commit()
# Credit-consuming deliverable; the frontend never confirms the
# render finished. Owner (workspace.user_id) resolved lazily so
# disabled installs pay nothing for the extra query.
if ph_analytics.is_enabled():
# Local import: app.db <-> app.podcasts.persistence have a
# module-init cycle; deferring keeps this task importable.
from app.db import Workspace
owner_id = await session.scalar(
select(Workspace.user_id).where(
Workspace.id == podcast.workspace_id
)
)
if owner_id:
ph_analytics.capture(
"podcast_generated",
distinct_id=str(owner_id),
properties={
"workspace_id": podcast.workspace_id,
"podcast_id": podcast_id,
},
groups={"workspace": str(podcast.workspace_id)},
)
except InvalidTransitionError: except InvalidTransitionError:
# A user back-out won the race (e.g. the regeneration was # A user back-out won the race (e.g. the regeneration was
# reverted): drop the stale render and leave the row alone. # reverted): drop the stale render and leave the row alone.

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
import secrets import secrets
import uuid import uuid
@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import config from app.config import config
from app.observability import analytics as ph_analytics
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
)
from app.etl_pipeline.file_classifier import ( from app.etl_pipeline.file_classifier import (
DIRECT_CONVERT_EXTENSIONS, DIRECT_CONVERT_EXTENSIONS,
PLAINTEXT_EXTENSIONS, PLAINTEXT_EXTENSIONS,
@ -354,6 +359,7 @@ async def stream_anonymous_chat(
accumulator = start_turn() accumulator = start_turn()
streaming_service = VercelStreamingService() streaming_service = VercelStreamingService()
anon_outcome = "success"
try: try:
async with shielded_async_session(): async with shielded_async_session():
@ -394,6 +400,21 @@ async def stream_anonymous_chat(
"recursion_limit": 40, "recursion_limit": 40,
} }
# PostHog LLM analytics for the free tier — model spend per
# model is the highest-value cost insight. distinct_id is the
# anon session id (not joined to any registered person).
_anon_llm_handler = build_llm_callback_handler(
distinct_id=session_id,
trace_id=anon_thread_id,
properties={
"client": "anonymous",
"model_slug": body.model_slug,
"$ai_session_id": session_id,
},
)
if _anon_llm_handler is not None:
langgraph_config["callbacks"] = [_anon_llm_handler]
yield streaming_service.format_message_start() yield streaming_service.format_message_start()
yield streaming_service.format_start_step() yield streaming_service.format_start_step()
@ -465,6 +486,7 @@ async def stream_anonymous_chat(
except Exception as e: except Exception as e:
logger.exception("Anonymous chat stream error") logger.exception("Anonymous chat stream error")
anon_outcome = "error"
await TokenQuotaService.anon_release(session_key, ip_key, request_id) await TokenQuotaService.anon_release(session_key, ip_key, request_id)
_, error_code, _, _, user_message, extra = classify_stream_exception( _, error_code, _, _, user_message, extra = classify_stream_exception(
e, e,
@ -479,6 +501,26 @@ async def stream_anonymous_chat(
finally: finally:
await TokenQuotaService.anon_release_stream_slot(client_ip) await TokenQuotaService.anon_release_stream_slot(client_ip)
# Server-truth free-tier volume/model/outcome/token-burn. distinct_id
# is the anon session id — deliberately NOT joined to the frontend's
# PostHog anonymous id (the point is server truth, not funnel merge).
with contextlib.suppress(Exception):
if ph_analytics.is_enabled():
ph_analytics.capture(
"anon_chat_turn_completed",
distinct_id=session_id,
properties={
"client": "anonymous",
"outcome": anon_outcome,
"model_slug": body.model_slug,
"model_name": model_cfg.get("model_name"),
"total_tokens": accumulator.grand_total,
"prompt_tokens": accumulator.total_prompt_tokens,
"completion_tokens": accumulator.total_completion_tokens,
"cost_micros": accumulator.total_cost_micros,
},
)
return StreamingResponse( return StreamingResponse(
_generate(), _generate(),
media_type="text/event-stream", media_type="text/event-stream",

View file

@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.observability import analytics as ph_analytics
from app.config import config from app.config import config
from app.db import ( from app.db import (
ImageGeneration, ImageGeneration,
@ -327,6 +328,20 @@ async def create_image_generation(
await session.commit() await session.commit()
await session.refresh(db_image_gen) await session.refresh(db_image_gen)
# Credit-consuming deliverable; the frontend never confirms
# completion. ``error_message`` set => provider call failed.
ph_analytics.capture_for(
auth,
"image_generated",
{
"workspace_id": data.workspace_id,
"model": data.model,
"n": data.n,
"status": "error" if db_image_gen.error_message else "success",
},
groups={"workspace": str(data.workspace_id)},
)
return db_image_gen return db_image_gen
except HTTPException: except HTTPException:

View file

@ -15,6 +15,7 @@ from app.db import (
UserIncentiveTask, UserIncentiveTask,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.incentive_tasks import ( from app.schemas.incentive_tasks import (
CompleteTaskResponse, CompleteTaskResponse,
IncentiveTaskInfo, IncentiveTaskInfo,
@ -125,6 +126,20 @@ async def complete_task(
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)
# Authoritative reward grant (migrated from earn-credits-content.tsx).
# Placed after the already-completed early-return so retries never
# double-count.
ph_analytics.capture_for(
auth,
"incentive_task_completed",
{
"task_type": task_type.value
if hasattr(task_type, "value")
else str(task_type),
"credit_micros_rewarded": credit_micros_reward,
},
)
return CompleteTaskResponse( return CompleteTaskResponse(
success=True, success=True,
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.", message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",

View file

@ -51,6 +51,7 @@ from app.db import (
get_async_session, get_async_session,
shielded_async_session, shielded_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.new_chat import ( from app.schemas.new_chat import (
AgentToolInfo, AgentToolInfo,
CancelActiveTurnResponse, CancelActiveTurnResponse,
@ -823,6 +824,15 @@ async def create_thread(
session.add(db_thread) session.add(db_thread)
await session.commit() await session.commit()
await session.refresh(db_thread) await session.refresh(db_thread)
# Authoritative thread creation (migrated from stream-engine/engine.ts).
# get_auth_context => covers PAT/MCP callers the frontend never sees.
ph_analytics.capture_for(
auth,
"chat_created",
{"workspace_id": db_thread.workspace_id, "chat_id": db_thread.id},
groups={"workspace": str(db_thread.workspace_id)},
)
return db_thread return db_thread
except HTTPException: except HTTPException:

View file

@ -8,6 +8,7 @@ from sqlalchemy.future import select
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.config import config from app.config import config
from app.db import PersonalAccessToken, get_async_session from app.db import PersonalAccessToken, get_async_session
from app.observability import analytics as ph_analytics
from app.schemas.pat import PATCreate, PATCreated, PATRead from app.schemas.pat import PATCreate, PATCreated, PATRead
from app.users import require_session_context from app.users import require_session_context
from app.utils.pat import generate_pat, hash_pat, token_prefix from app.utils.pat import generate_pat, hash_pat, token_prefix
@ -57,6 +58,13 @@ async def create_personal_access_token(
await session.commit() await session.commit()
await session.refresh(pat) await session.refresh(pat)
# Leading indicator of MCP / programmatic-API adoption.
ph_analytics.capture_for(
auth,
"pat_created",
{"pat_id": pat.id, "has_expiry": pat.expires_at is not None},
)
return PATCreated( return PATCreated(
id=pat.id, id=pat.id,
label=pat.label, label=pat.label,
@ -102,3 +110,5 @@ async def delete_personal_access_token(
) )
) )
await session.commit() await session.commit()
ph_analytics.capture_for(auth, "pat_revoked", {"pat_id": pat_id})

View file

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.db import get_async_session from app.db import get_async_session
from app.observability import analytics as ph_analytics
from app.schemas.new_chat import ( from app.schemas.new_chat import (
CloneResponse, CloneResponse,
PublicChatResponse, PublicChatResponse,
@ -56,7 +57,20 @@ async def clone_public_chat(
Creates thread and copies messages. Creates thread and copies messages.
Requires authentication. Requires authentication.
""" """
return await clone_from_snapshot(session, share_token, user) result = await clone_from_snapshot(session, share_token, user)
# Share-link conversion — only observable server-side.
ph_analytics.capture_for(
auth,
"public_chat_cloned",
{
"workspace_id": result.workspace_id,
"chat_id": result.thread_id,
},
groups={"workspace": str(result.workspace_id)},
)
return result
@router.get("/{share_token}/podcasts/{podcast_id}") @router.get("/{share_token}/podcasts/{podcast_id}")

View file

@ -28,6 +28,7 @@ from app.db import (
WorkspaceRole, WorkspaceRole,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas import ( from app.schemas import (
InviteAcceptRequest, InviteAcceptRequest,
InviteAcceptResponse, InviteAcceptResponse,
@ -782,6 +783,19 @@ async def create_invite(
) )
db_invite = result.scalars().first() db_invite = result.scalars().first()
# Authoritative invite creation (migrated from team-content.tsx).
ph_analytics.capture_for(
auth,
"workspace_invite_sent",
{
"workspace_id": workspace_id,
"role_name": db_invite.role.name if db_invite.role else None,
"has_expiry": db_invite.expires_at is not None,
"has_max_uses": db_invite.max_uses is not None,
},
groups={"workspace": str(workspace_id)},
)
return db_invite return db_invite
except HTTPException: except HTTPException:
@ -1091,6 +1105,16 @@ async def accept_invite(
role_name = invite.role.name if invite.role else "Default" role_name = invite.role.name if invite.role else "Default"
workspace_name = invite.workspace.name if invite.workspace else "" workspace_name = invite.workspace.name if invite.workspace else ""
# Authoritative join (migrated from app/invite/[invite_code]/page.tsx,
# which fired both events). workspace_name dropped — user content.
for _evt in ("workspace_invite_accepted", "workspace_user_added"):
ph_analytics.capture_for(
auth,
_evt,
{"workspace_id": invite.workspace_id, "role_name": role_name},
groups={"workspace": str(invite.workspace_id)},
)
return InviteAcceptResponse( return InviteAcceptResponse(
message="Successfully joined the workspace", message="Successfully joined the workspace",
workspace_id=invite.workspace_id, workspace_id=invite.workspace_id,

View file

@ -29,6 +29,7 @@ import redis
from dateutil.parser import isoparse from dateutil.parser import isoparse
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import event as sa_event
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -44,6 +45,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot from app.observability import metrics as ot_metrics, otel as ot
from app.schemas import ( from app.schemas import (
GoogleDriveIndexRequest, GoogleDriveIndexRequest,
@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
router = APIRouter() router = APIRouter()
def _connector_type_value(connector_type) -> str:
"""Low-cardinality connector_type string for analytics (enum -> its value)."""
return getattr(connector_type, "value", None) or str(connector_type)
def _emit_connector_connected(_mapper, _connection, target) -> None:
"""SQLAlchemy after_insert hook: one guard for every connector-creation path.
Fires ``connector_connected`` for the form route here AND all 15 OAuth
callback routes (each builds ``SearchSourceConnector(...)`` inline with no
shared helper), so we don't instrument 16 call sites. ``distinct_id`` comes
off the row itself (``user_id``) the same person the web app identifies.
ponytail: fires during flush, so a post-flush rollback would over-count
(rare; connector creation commits immediately after add). No-op without a
PostHog key. Upgrade path: move to an after_commit collector if over-count
ever shows up in the data.
"""
if not ph_analytics.is_enabled():
return
user_id = getattr(target, "user_id", None)
if not user_id:
return
workspace_id = getattr(target, "workspace_id", None)
ph_analytics.capture(
"connector_connected",
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
"connector_id": target.id,
"connector_type": _connector_type_value(target.connector_type),
},
groups={"workspace": str(workspace_id)} if workspace_id is not None else None,
)
sa_event.listen(SearchSourceConnector, "after_insert", _emit_connector_connected)
# Use Pydantic's BaseModel here # Use Pydantic's BaseModel here
class GitHubPATRequest(BaseModel): class GitHubPATRequest(BaseModel):
github_pat: str = Field(..., description="GitHub Personal Access Token") github_pat: str = Field(..., description="GitHub Personal Access Token")
@ -244,6 +285,10 @@ async def create_search_source_connector(
await session.commit() await session.commit()
await session.refresh(db_connector) await session.refresh(db_connector)
# ``connector_connected`` is emitted by the after_insert listener below,
# so it fires once for this form route AND all 15 OAuth callback routes
# without instrumenting each — see _emit_connector_connected.
# Create periodic schedule if periodic indexing is enabled # Create periodic schedule if periodic indexing is enabled
if ( if (
db_connector.periodic_indexing_enabled db_connector.periodic_indexing_enabled
@ -679,10 +724,23 @@ async def delete_search_source_connector(
# Delete the connector record # Delete the connector record
workspace_id = db_connector.workspace_id workspace_id = db_connector.workspace_id
deleted_connector_type = db_connector.connector_type
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
await session.delete(db_connector) await session.delete(db_connector)
await session.commit() await session.commit()
# Authoritative deletion (migrated from use-connector-dialog.ts).
ph_analytics.capture_for(
auth,
"connector_deleted",
{
"workspace_id": workspace_id,
"connector_id": connector_id,
"connector_type": _connector_type_value(deleted_connector_type),
},
groups={"workspace": str(workspace_id)},
)
if is_mcp: if is_mcp:
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import ( from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
invalidate_mcp_tools_cache, invalidate_mcp_tools_cache,

View file

@ -27,6 +27,7 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.observability import analytics as ph_analytics
from app.schemas.stripe import ( from app.schemas.stripe import (
AutoReloadSettingsResponse, AutoReloadSettingsResponse,
CreateAutoReloadSetupSessionRequest, CreateAutoReloadSetupSessionRequest,
@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/stripe", tags=["stripe"]) router = APIRouter(prefix="/stripe", tags=["stripe"])
def _capture_credits_purchased(user: User, purchase: CreditPurchase) -> None:
"""Emit ``credits_purchased`` — revenue events only exist server-side.
Call only from the idempotent grant paths (which early-return on already
COMPLETED / non-PENDING rows) so Stripe retries never double-count. No-op
when PostHog is unconfigured.
"""
ph_analytics.capture(
"credits_purchased",
distinct_id=str(user.id),
properties={
"credit_micros_granted": purchase.credit_micros_granted,
"quantity": purchase.quantity,
"amount_total": purchase.amount_total,
"currency": purchase.currency,
"source": purchase.source,
},
)
def get_stripe_client() -> StripeClient: def get_stripe_client() -> StripeClient:
"""Return a configured Stripe client or raise if Stripe is disabled.""" """Return a configured Stripe client or raise if Stripe is disabled."""
if not config.STRIPE_SECRET_KEY: if not config.STRIPE_SECRET_KEY:
@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
) )
await db_session.commit() await db_session.commit()
_capture_credits_purchased(user, purchase)
return StripeWebhookResponse() return StripeWebhookResponse()
@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
purchase.status = CreditPurchaseStatus.FAILED purchase.status = CreditPurchaseStatus.FAILED
await db_session.commit() await db_session.commit()
if succeeded:
_capture_credits_purchased(user, purchase)
return StripeWebhookResponse() return StripeWebhookResponse()

View file

@ -15,6 +15,7 @@ from app.db import (
get_async_session, get_async_session,
get_default_roles_config, get_default_roles_config,
) )
from app.observability import analytics as ph_analytics
from app.routes.model_connections_routes import compute_llm_setup_status from app.routes.model_connections_routes import compute_llm_setup_status
from app.schemas import ( from app.schemas import (
WorkspaceApiAccessUpdate, WorkspaceApiAccessUpdate,
@ -112,6 +113,16 @@ async def create_workspace(
await session.commit() await session.commit()
await session.refresh(db_workspace) await session.refresh(db_workspace)
# Authoritative creation event (migrated from the frontend
# CreateWorkspaceDialog). Workspace name is intentionally NOT sent —
# it's user content with no aggregation value.
ph_analytics.capture_for(
auth,
"workspace_created",
{"workspace_id": db_workspace.id},
groups={"workspace": str(db_workspace.id)},
)
response = WorkspaceRead.model_validate(db_workspace) response = WorkspaceRead.model_validate(db_workspace)
response.llm_setup = await compute_llm_setup_status( response.llm_setup = await compute_llm_setup_status(
session, auth, db_workspace.id session, auth, db_workspace.id

View file

@ -1,5 +1,6 @@
"""Celery tasks for connector indexing.""" """Celery tasks for connector indexing."""
import contextlib
import logging import logging
import time import time
import traceback import traceback
@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
from celery import current_task from celery import current_task
from app.celery_app import celery_app from app.celery_app import celery_app
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics, otel as ot from app.observability import metrics as ot_metrics, otel as ot
from app.tasks.celery_tasks import ( from app.tasks.celery_tasks import (
get_celery_session_maker, get_celery_session_maker,
@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T: def run_async_celery_task[T](
"""Run connector sync work and record aggregate connector metrics.""" coro_factory: Callable[[], Awaitable[T]],
*,
workspace_id: int | None = None,
user_id: str | None = None,
) -> T:
"""Run connector sync work and record aggregate connector metrics.
When ``workspace_id``/``user_id`` are provided, also emits the PostHog
``connector_indexing_completed``/``_failed`` outcome (the frontend only
knows indexing *started*, never whether it worked). No-op without a key.
"""
task_name = getattr(current_task, "name", None) or "unknown" task_name = getattr(current_task, "name", None) or "unknown"
t0 = time.perf_counter() t0 = time.perf_counter()
status = "failed" status = "failed"
@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
status=status, status=status,
error_category=error_category, error_category=error_category,
) )
if user_id and ph_analytics.is_enabled():
event = (
"connector_indexing_completed"
if status == "success"
else "connector_indexing_failed"
)
with contextlib.suppress(Exception):
ph_analytics.capture(
event,
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
# ``connector_type`` is the Celery task name, e.g.
# index_notion_pages / index_github_repos.
"connector_type": task_name,
"status": status,
"error_category": error_category,
"duration_ms": int(elapsed_s * 1000),
},
groups={"workspace": str(workspace_id)}
if workspace_id is not None
else None,
)
def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None: def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None:
@ -91,7 +126,9 @@ def index_notion_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_notion_pages( lambda: _index_notion_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
except Exception as e: except Exception as e:
_handle_greenlet_error(e, "index_notion_pages", connector_id) _handle_greenlet_error(e, "index_notion_pages", connector_id)
@ -129,7 +166,9 @@ def index_github_repos_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_github_repos( lambda: _index_github_repos(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -164,7 +203,9 @@ def index_confluence_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_confluence_pages( lambda: _index_confluence_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -200,7 +241,9 @@ def index_google_calendar_events_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_google_calendar_events( lambda: _index_google_calendar_events(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
except Exception as e: except Exception as e:
_handle_greenlet_error(e, "index_google_calendar_events", connector_id) _handle_greenlet_error(e, "index_google_calendar_events", connector_id)
@ -238,7 +281,9 @@ def index_google_gmail_messages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_google_gmail_messages( lambda: _index_google_gmail_messages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -275,7 +320,9 @@ def index_google_drive_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -315,7 +362,9 @@ def index_onedrive_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -355,7 +404,9 @@ def index_dropbox_files_task(
workspace_id, workspace_id,
user_id, user_id,
items_dict, items_dict,
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_elasticsearch_documents( lambda: _index_elasticsearch_documents(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -428,7 +481,9 @@ def index_bookstack_pages_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_bookstack_pages( lambda: _index_bookstack_pages(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )
@ -463,7 +518,9 @@ def index_composio_connector_task(
return run_async_celery_task( return run_async_celery_task(
lambda: _index_composio_connector( lambda: _index_composio_connector(
connector_id, workspace_id, user_id, start_date, end_date connector_id, workspace_id, user_id, start_date, end_date
) ),
workspace_id=workspace_id,
user_id=user_id,
) )

View file

@ -4,11 +4,13 @@ import asyncio
import contextlib import contextlib
import logging import logging
import os import os
import time
from uuid import UUID from uuid import UUID
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config from app.config import config
from app.notifications.service import NotificationService from app.notifications.service import NotificationService
from app.observability import analytics as ph_analytics
from app.observability import metrics as ot_metrics from app.observability import metrics as ot_metrics
from app.services.task_logging_service import TaskLoggingService from app.services.task_logging_service import TaskLoggingService
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
@ -22,6 +24,44 @@ from app.tasks.document_processors import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _capture_doc_processing(
status: str,
*,
user_id: str | None,
workspace_id: int,
doc_type: str,
file_size: int | None = None,
duration_ms: int | None = None,
) -> None:
"""Emit ``document_processing_completed``/``_failed`` from a Celery task.
The frontend only knows the upload POST succeeded, never whether ingestion
actually worked this is the authoritative outcome. No-op when PostHog is
unconfigured. ``distinct_id`` is the owning user's id so it joins the same
person the web app identifies.
"""
if not ph_analytics.is_enabled() or not user_id:
return
event = (
"document_processing_completed"
if status == "success"
else "document_processing_failed"
)
ph_analytics.capture(
event,
distinct_id=str(user_id),
properties={
"workspace_id": workspace_id,
"doc_type": doc_type,
"file_size": file_size,
"duration_ms": duration_ms,
"status": status,
},
groups={"workspace": str(workspace_id)},
)
# ===== Redis heartbeat for document processing tasks ===== # ===== Redis heartbeat for document processing tasks =====
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py). # Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
# A background coroutine refreshes a Redis key every 60s with a 2-min TTL. # A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
@ -268,11 +308,30 @@ def process_extension_document_task(
workspace_id: ID of the workspace workspace_id: ID of the workspace
user_id: ID of the user user_id: ID of the user
""" """
return run_async_celery_task( _t0 = time.perf_counter()
lambda: _process_extension_document( try:
individual_document_dict, workspace_id, user_id result = run_async_celery_task(
lambda: _process_extension_document(
individual_document_dict, workspace_id, user_id
)
) )
except Exception:
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="extension",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="extension",
duration_ms=int((time.perf_counter() - _t0) * 1000),
) )
return result
async def _process_extension_document( async def _process_extension_document(
@ -430,12 +489,14 @@ def process_file_upload_task(
) )
return return
file_size: int | None = None
try: try:
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
logger.info(f"[process_file_upload] File size: {file_size} bytes") logger.info(f"[process_file_upload] File size: {file_size} bytes")
except Exception as e: except Exception as e:
logger.warning(f"[process_file_upload] Could not get file size: {e}") logger.warning(f"[process_file_upload] Could not get file size: {e}")
_t0 = time.perf_counter()
try: try:
run_async_celery_task( run_async_celery_task(
lambda: _process_file_upload(file_path, filename, workspace_id, user_id) lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
@ -448,7 +509,23 @@ def process_file_upload_task(
f"[process_file_upload] Task failed for {filename}: {e}\n" f"[process_file_upload] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}" f"Traceback:\n{traceback.format_exc()}"
) )
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload",
file_size=file_size,
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload",
file_size=file_size,
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
async def _process_file_upload( async def _process_file_upload(
@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
) )
return return
_t0 = time.perf_counter()
try: try:
run_async_celery_task( run_async_celery_task(
lambda: _process_file_with_document( lambda: _process_file_with_document(
@ -702,7 +780,21 @@ def process_file_upload_with_document_task(
f"[process_file_upload_with_document] Task failed for {filename}: {e}\n" f"[process_file_upload_with_document] Task failed for {filename}: {e}\n"
f"Traceback:\n{traceback.format_exc()}" f"Traceback:\n{traceback.format_exc()}"
) )
_capture_doc_processing(
"failed",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload_2phase",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
raise raise
_capture_doc_processing(
"success",
user_id=user_id,
workspace_id=workspace_id,
doc_type="file_upload_2phase",
duration_ms=int((time.perf_counter() - _t0) * 1000),
)
async def _mark_document_failed(document_id: int, reason: str): async def _mark_document_failed(document_id: int, reason: str):

View file

@ -12,6 +12,7 @@ from app.agents.video_presentation.state import State as VideoPresentationState
from app.celery_app import celery_app from app.celery_app import celery_app
from app.config import config as app_config from app.config import config as app_config
from app.db import VideoPresentation, VideoPresentationStatus from app.db import VideoPresentation, VideoPresentationStatus
from app.observability import analytics as ph_analytics
from app.services.billable_calls import ( from app.services.billable_calls import (
BillingSettlementError, BillingSettlementError,
QuotaInsufficientError, QuotaInsufficientError,
@ -239,6 +240,20 @@ async def _generate_video_presentation(
logger.info(f"Successfully generated video presentation: {video_pres.id}") logger.info(f"Successfully generated video presentation: {video_pres.id}")
# Credit-consuming deliverable — the frontend never confirms
# completion. Attributed to the workspace owner resolved above.
if owner_user_id:
ph_analytics.capture(
"video_presentation_generated",
distinct_id=str(owner_user_id),
properties={
"workspace_id": workspace_id,
"video_presentation_id": video_pres.id,
"slide_count": len(serializable_slides),
},
groups={"workspace": str(workspace_id)},
)
return { return {
"status": "ready", "status": "ready",
"video_presentation_id": video_pres.id, "video_presentation_id": video_pres.id,

View file

@ -97,6 +97,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered, log_rate_limit_recovered,
reroute_to_next_auto_pin, reroute_to_next_auto_pin,
) )
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
capture_chat_turn_completed,
)
from app.tasks.chat.streaming.flows.shared.span import ( from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span, close_chat_request_span,
open_chat_request_span, open_chat_request_span,
@ -487,6 +491,23 @@ async def stream_new_chat(
"recursion_limit": 10_000, "recursion_limit": 10_000,
} }
# PostHog LLM analytics: attach a callback so the full agent trace
# tree (LLM calls, tools, subagents) is captured per turn. No-op when
# PostHog is unconfigured.
_llm_handler = build_llm_callback_handler(
distinct_id=user_id,
trace_id=stream_result.turn_id,
properties={
"workspace_id": workspace_id,
"chat_id": chat_id,
"$ai_session_id": str(chat_id),
"flow": flow,
},
groups={"workspace": str(workspace_id)},
)
if _llm_handler is not None:
config["callbacks"] = [_llm_handler]
# --- Block 4: First SSE frames --- # --- Block 4: First SSE frames ---
for sse in iter_initial_frames( for sse in iter_initial_frames(
@ -830,6 +851,27 @@ async def stream_new_chat(
log_prefix="stream_new_chat", log_prefix="stream_new_chat",
) )
# Authoritative server-side product event. Inside the shield so it
# survives client-disconnect cancellation (abandoned tabs still
# produce a turn event), and while ``stream_result`` is still live
# (it's dropped to None below for GC).
capture_chat_turn_completed(
flow=flow,
outcome=chat_outcome,
error_category=chat_error_category,
workspace_id=workspace_id,
chat_id=chat_id,
user_id=user_id,
auth_context=auth_context,
agent_mode=chat_agent_mode,
client_platform=fs_platform,
filesystem_mode=fs_mode,
turn_id=getattr(stream_result, "turn_id", None),
request_id=request_id,
duration_ms=int((time.perf_counter() - _t_total) * 1000),
accumulator=accumulator,
)
# Persist any sandbox-produced files to local storage so they remain # Persist any sandbox-produced files to local storage so they remain
# downloadable after the Daytona sandbox auto-deletes. # downloadable after the Daytona sandbox auto-deletes.
if stream_result and stream_result.sandbox_files: if stream_result and stream_result.sandbox_files:

View file

@ -76,6 +76,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
log_rate_limit_recovered, log_rate_limit_recovered,
reroute_to_next_auto_pin, reroute_to_next_auto_pin,
) )
from app.tasks.chat.streaming.flows.shared.analytics import (
build_llm_callback_handler,
capture_chat_turn_completed,
)
from app.tasks.chat.streaming.flows.shared.span import ( from app.tasks.chat.streaming.flows.shared.span import (
close_chat_request_span, close_chat_request_span,
open_chat_request_span, open_chat_request_span,
@ -387,6 +391,22 @@ async def stream_resume_chat(
"recursion_limit": 10_000, "recursion_limit": 10_000,
} }
# PostHog LLM analytics — same trace as the original turn's
# conversation via ``$ai_session_id``. No-op when unconfigured.
_llm_handler = build_llm_callback_handler(
distinct_id=user_id,
trace_id=stream_result.turn_id,
properties={
"workspace_id": workspace_id,
"chat_id": chat_id,
"$ai_session_id": str(chat_id),
"flow": "resume",
},
groups={"workspace": str(workspace_id)},
)
if _llm_handler is not None:
config["callbacks"] = [_llm_handler]
# --- First SSE frames --- # --- First SSE frames ---
for sse in iter_initial_frames( for sse in iter_initial_frames(
@ -599,6 +619,25 @@ async def stream_resume_chat(
log_prefix="stream_resume", log_prefix="stream_resume",
) )
# Authoritative server-side product event (see new_chat
# orchestrator for rationale). ``flow="resume"``.
capture_chat_turn_completed(
flow="resume",
outcome=chat_outcome,
error_category=chat_error_category,
workspace_id=workspace_id,
chat_id=chat_id,
user_id=user_id,
auth_context=auth_context,
agent_mode=chat_agent_mode,
client_platform=fs_platform,
filesystem_mode=fs_mode,
turn_id=getattr(stream_result, "turn_id", None),
request_id=request_id,
duration_ms=int((time.perf_counter() - _t_total) * 1000),
accumulator=accumulator,
)
# Release the lock from the original interrupted turn or any # Release the lock from the original interrupted turn or any
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here). # re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
if not busy_error_raised: if not busy_error_raised:

View file

@ -0,0 +1,117 @@
"""PostHog chat-turn analytics for streaming flows.
Emits a single authoritative ``chat_turn_completed`` product event per turn,
shared by the new-chat and resume orchestrators so every chat source (web,
desktop, PAT scripts, gateway, automations) is tracked identically
including sources the frontend can never observe. No-op when PostHog is
unconfigured.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from app.config import config
from app.observability import analytics
if TYPE_CHECKING:
from app.auth.context import AuthContext
from app.services.token_tracking_service import TurnTokenAccumulator
logger = logging.getLogger(__name__)
def build_llm_callback_handler(
*,
distinct_id: str | None,
trace_id: str | None,
properties: dict[str, Any] | None = None,
groups: dict[str, str] | None = None,
) -> Any | None:
"""Build a PostHog LangChain ``CallbackHandler`` for a chat turn.
Attaching this to the LangGraph ``config["callbacks"]`` captures the full
agent trace tree ($ai_trace / $ai_span / $ai_generation) every LLM call,
tool, subagent, and retriever joined to the ``chat_turn_completed`` event
via ``trace_id`` (the turn id) and grouped per conversation via
``$ai_session_id`` (in ``properties``).
Returns ``None`` when PostHog is disabled or the package/handler is
unavailable, so callers can simply skip attaching callbacks. ``privacy_mode``
(default on) suppresses prompt/completion bodies chat content includes
users' private documents.
"""
client_obj = analytics.get_client()
if client_obj is None or not distinct_id:
return None
try:
from posthog.ai.langchain import CallbackHandler
return CallbackHandler(
client=client_obj,
distinct_id=distinct_id,
trace_id=trace_id,
properties=properties or {},
privacy_mode=config.POSTHOG_AI_PRIVACY_MODE,
groups=groups,
)
except Exception:
logger.debug("PostHog LLM callback handler unavailable", exc_info=True)
return None
def capture_chat_turn_completed(
*,
flow: str,
outcome: str,
error_category: str | None,
workspace_id: int,
chat_id: int,
user_id: str | None,
auth_context: AuthContext | None,
agent_mode: str,
client_platform: str,
filesystem_mode: str,
turn_id: str | None,
request_id: str | None,
duration_ms: int,
accumulator: TurnTokenAccumulator,
) -> None:
"""Capture ``chat_turn_completed``. Best-effort; never raises."""
if not analytics.is_enabled() or not user_id:
return
props: dict[str, Any] = {
"flow": flow,
"outcome": outcome,
"error_category": error_category,
"workspace_id": workspace_id,
"chat_id": chat_id,
"agent_mode": agent_mode,
"client_platform": client_platform,
"filesystem_mode": filesystem_mode,
"turn_id": turn_id,
"request_id": request_id,
"duration_ms": duration_ms,
# Cost is micro-USD (integer), matching TurnTokenAccumulator; do not
# convert to float dollars.
"total_tokens": accumulator.grand_total,
"prompt_tokens": accumulator.total_prompt_tokens,
"completion_tokens": accumulator.total_completion_tokens,
"cost_micros": accumulator.total_cost_micros,
}
groups = {"workspace": str(workspace_id)}
if auth_context is not None:
analytics.capture_for(
auth_context, "chat_turn_completed", props, groups=groups
)
else:
analytics.capture(
"chat_turn_completed",
distinct_id=user_id,
properties=props,
groups=groups,
)

View file

@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext from app.auth.context import AuthContext
from app.auth.session_cookies import access_expires_at, write_session from app.auth.session_cookies import access_expires_at, write_session
from app.config import config from app.config import config
from app.observability import analytics as ph_analytics
from app.db import ( from app.db import (
Prompt, Prompt,
User, User,
@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
except Exception as e: except Exception as e:
logger.warning(f"Failed to update last_login for user {user.id}: {e}") logger.warning(f"Failed to update last_login for user {user.id}: {e}")
# Authoritative login event (vs. the frontend's optimistic capture).
ph_analytics.capture("auth_login_success", distinct_id=str(user.id))
async def on_after_register(self, user: User, request: Request | None = None): async def on_after_register(self, user: User, request: Request | None = None):
""" """
Called after a user registers. Creates a default workspace for the user Called after a user registers. Creates a default workspace for the user
@ -206,6 +210,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
logger.info( logger.info(
f"Created default workspace (ID: {default_workspace.id}) for user {user.id}" f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
) )
# Authoritative registration + auto-created default workspace.
ph_analytics.capture(
"auth_registration_success", distinct_id=str(user.id)
)
ph_analytics.capture(
"workspace_created",
distinct_id=str(user.id),
properties={"client": "auto_register"},
groups={"workspace": str(default_workspace.id)},
)
except Exception as e: except Exception as e:
logger.error(f"Failed to create default workspace for user {user.id}: {e}") logger.error(f"Failed to create default workspace for user {user.id}: {e}")
@ -338,6 +353,13 @@ async def get_auth_context(
FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC
receives the full SurfSense principal instead of a bare User. receives the full SurfSense principal instead of a bare User.
""" """
def _stash(ctx: AuthContext) -> AuthContext:
# Expose the resolved principal on request.state so downstream
# middleware (e.g. PostHog pat_api_request attribution) can read it
# without re-resolving auth.
request.state.auth_context = ctx
return ctx
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if auth_header: if auth_header:
scheme, _, credential = auth_header.partition(" ") scheme, _, credential = auth_header.partition(" ")
@ -348,7 +370,7 @@ async def get_auth_context(
pat = await resolve_pat(session, token) pat = await resolve_pat(session, token)
if pat and pat.user and pat.user.is_active: if pat and pat.user and pat.user.is_active:
maybe_touch_last_used(pat) maybe_touch_last_used(pat)
return AuthContext.pat_auth(pat.user, pat) return _stash(AuthContext.pat_auth(pat.user, pat))
if is_bearer and _token_meets_epoch(token): if is_bearer and _token_meets_epoch(token):
try: try:
@ -358,7 +380,7 @@ async def get_auth_context(
user = None user = None
if user and user.is_active: if user and user.is_active:
return AuthContext.session(user) return _stash(AuthContext.session(user))
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME) cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
if cookie_token and _token_meets_epoch(cookie_token): if cookie_token and _token_meets_epoch(cookie_token):
@ -369,7 +391,7 @@ async def get_auth_context(
user = None user = None
if user and user.is_active: if user and user.is_active:
return AuthContext.session(user) return _stash(AuthContext.session(user))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -86,6 +86,7 @@ dependencies = [
"python-telegram-bot>=22.7", "python-telegram-bot>=22.7",
"croniter>=2.0.0", "croniter>=2.0.0",
"scrapling[fetchers]>=0.4.11", "scrapling[fetchers]>=0.4.11",
"posthog>=6.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -0,0 +1,149 @@
"""Unit tests for the PostHog analytics wrapper.
Covers the two properties the rest of the codebase relies on:
1. Opt-in no-op: with no client configured, every entry point is silent and
never raises (analytics must never break a request).
2. Correct stamping when a client IS present: ``source="backend"`` and
``disable_geoip=True`` on every capture, plus ``auth_method`` / ``client``
derived from the ``AuthContext`` principal by ``capture_for``.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.observability import analytics
pytestmark = pytest.mark.unit
class _FakeClient:
"""Records capture()/group_identify() calls instead of hitting the network."""
def __init__(self) -> None:
self.captures: list[dict] = []
self.groups: list[dict] = []
def capture(self, event, *, distinct_id, properties, groups, disable_geoip):
self.captures.append(
{
"event": event,
"distinct_id": distinct_id,
"properties": properties,
"groups": groups,
"disable_geoip": disable_geoip,
}
)
def group_identify(self, *, group_type, group_key, properties):
self.groups.append(
{"group_type": group_type, "group_key": group_key, "properties": properties}
)
@pytest.fixture(autouse=True)
def _reset_module_state():
"""Isolate the module-level lazy-client singleton between tests."""
orig_client = analytics._client
orig_attempted = analytics._init_attempted
yield
analytics._client = orig_client
analytics._init_attempted = orig_attempted
def _use_fake_client() -> _FakeClient:
"""Inject a fake client, bypassing lazy init (no posthog import, no key)."""
fake = _FakeClient()
analytics._client = fake
analytics._init_attempted = True
return fake
def _disable_client() -> None:
analytics._client = None
analytics._init_attempted = True
# ---- No-op behaviour when disabled -------------------------------------------------
def test_capture_is_noop_without_client():
_disable_client()
assert analytics.is_enabled() is False
# Must not raise even though there is no client.
analytics.capture("some_event", distinct_id="u1", properties={"a": 1})
def test_capture_for_is_noop_without_client():
_disable_client()
auth = SimpleNamespace(method="session", source=None, user=SimpleNamespace(id="u1"))
analytics.capture_for(auth, "some_event", {"a": 1}) # no raise
def test_shutdown_is_noop_without_client():
_disable_client()
analytics.shutdown() # no raise
# ---- Stamping when a client is present ---------------------------------------------
def test_capture_stamps_source_and_disables_geoip():
fake = _use_fake_client()
analytics.capture(
"chat_turn_completed",
distinct_id="user-123",
properties={"workspace_id": 7},
groups={"workspace": "7"},
)
assert len(fake.captures) == 1
call = fake.captures[0]
assert call["event"] == "chat_turn_completed"
assert call["distinct_id"] == "user-123"
# source is always stamped so backend vs frontend events are separable.
assert call["properties"]["source"] == "backend"
assert call["properties"]["workspace_id"] == 7
assert call["groups"] == {"workspace": "7"}
# Without this the server IP would overwrite each person's real location.
assert call["disable_geoip"] is True
def test_capture_never_raises_when_client_errors():
class _Boom(_FakeClient):
def capture(self, *a, **k):
raise RuntimeError("network down")
boom = _Boom()
analytics._client = boom
analytics._init_attempted = True
# Swallowed like the frontend safeCapture — analytics never breaks a request.
analytics.capture("evt", distinct_id="u1")
@pytest.mark.parametrize(
("method", "source", "expected_client"),
[
("session", None, "web"),
("pat", None, "pat"),
("system", "gateway", "gateway"),
("system", None, "system"),
],
)
def test_capture_for_stamps_auth_method_and_client(method, source, expected_client):
fake = _use_fake_client()
auth = SimpleNamespace(
method=method, source=source, user=SimpleNamespace(id="user-abc")
)
analytics.capture_for(auth, "workspace_created", {"workspace_id": 1})
assert len(fake.captures) == 1
props = fake.captures[0]["properties"]
assert fake.captures[0]["distinct_id"] == "user-abc"
assert props["auth_method"] == method
assert props["client"] == expected_client
assert props["source"] == "backend" # capture_for delegates to capture
assert props["workspace_id"] == 1

View file

@ -48,6 +48,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -94,6 +97,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -140,6 +146,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -186,6 +195,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -232,6 +244,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -278,6 +293,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -324,6 +342,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -370,6 +391,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -416,6 +440,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -462,6 +489,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -523,6 +553,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -569,6 +603,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -615,6 +652,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -661,6 +701,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -707,6 +750,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -753,6 +799,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -799,6 +848,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -845,6 +897,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -891,6 +946,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -937,6 +995,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -983,6 +1044,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1044,6 +1108,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1090,6 +1158,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1136,6 +1207,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1182,6 +1256,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1228,6 +1305,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1274,6 +1354,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1320,6 +1403,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1366,6 +1452,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1412,6 +1501,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1458,6 +1550,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1504,6 +1599,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1565,6 +1663,10 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1611,6 +1713,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1702,6 +1807,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1748,6 +1859,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1794,6 +1908,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1885,6 +2002,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -1976,6 +2099,12 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
@ -2022,6 +2151,9 @@ resolution-markers = [
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'", "python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_version < '0'",
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'", "python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
] ]
conflicts = [[ conflicts = [[
@ -2557,6 +2689,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 },
] ]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
]
[[package]] [[package]]
name = "banks" name = "banks"
version = "2.4.1" version = "2.4.1"
@ -8650,6 +8791,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 }, { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 },
] ]
[[package]]
name = "posthog"
version = "7.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/f0/3af875ac3fd5863ed4874c9618d85eab3f7fd24b395827d99da0ef90aca6/posthog-7.28.0.tar.gz", hash = "sha256:9e048dee58f27373db622c0744be30c1a2b7f1df31049956d6341c9646fb3833", size = 356360 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/8b/d41d98e64bd6ce650d45690cffc718274b1eda65e3bfd79575a1cf95b45c/posthog-7.28.0-py3-none-any.whl", hash = "sha256:4cff10062807bbd8ae6ec2804a71584831a75b390ce7ba3e736bf4cc0fb25d28", size = 425147 },
]
[[package]] [[package]]
name = "preshed" name = "preshed"
version = "3.0.13" version = "3.0.13"
@ -10926,6 +11082,7 @@ dependencies = [
{ name = "opentelemetry-sdk" }, { name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions" },
{ name = "pgvector" }, { name = "pgvector" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary", "pool"] }, { name = "psycopg", extra = ["binary", "pool"] },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pyjwt" }, { name = "pyjwt" },
@ -11041,6 +11198,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" }, { name = "opentelemetry-sdk", specifier = ">=1.40.0" },
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" }, { name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
{ name = "pgvector", specifier = ">=0.3.6" }, { name = "pgvector", specifier = ">=0.3.6" },
{ name = "posthog", specifier = ">=6.0.0" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" }, { name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
{ name = "pyjwt", specifier = ">=2.12.0" }, { name = "pyjwt", specifier = ">=2.12.0" },

View file

@ -37,7 +37,11 @@ class SurfSenseClient:
self._fallback_api_key = fallback_api_key self._fallback_api_key = fallback_api_key
self._http = httpx.AsyncClient( self._http = httpx.AsyncClient(
base_url=api_base, base_url=api_base,
headers={"Accept": "application/json"}, # ``X-SurfSense-Client`` lets the backend distinguish PAT traffic
# originating from this MCP server vs. raw PAT scripts, so
# "documents added via MCP" / "searches via MCP" are queryable.
# Server-to-server, so no CORS implications.
headers={"Accept": "application/json", "X-SurfSense-Client": "mcp"},
timeout=timeout, timeout=timeout,
) )

View file

@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils"; import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() { export function LocalLoginForm() {
const t = useTranslations("auth"); const t = useTranslations("auth");
@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password", grant_type: "password",
}); });
// Track successful login // auth_login_success is now emitted server-side
trackLoginSuccess("local"); // (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message // Small delay to show success message
setTimeout(() => { setTimeout(() => {

View file

@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session"; import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
import { import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground"; import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() { export default function RegisterPage() {
@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false, is_verified: false,
}); });
// Track successful registration // auth_registration_success is now emitted server-side
trackRegistrationSuccess(); // (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast // Success toast
toast.success(t("register_success"), { toast.success(t("register_success"), {

View file

@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
const canPrev = pageIndex > 0; const canPrev = pageIndex > 0;
const canNext = displayEnd < totalItems; const canNext = displayEnd < totalItems;
useEffect(() => { // workspace_users_viewed removed — redundant with $pageview.
if (members.length > 0 && !membersLoading) {
const ownerCount = members.filter((m) => m.is_owner).length;
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
if (accessLoading || membersLoading) { if (accessLoading || membersLoading) {
return ( return (
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
Invite members Invite members
</Button> </Button>
) : ( ) : (
<CreateInviteDialog <CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
roles={roles}
onCreateInvite={handleCreateInvite}
workspaceId={workspaceId}
/>
)} )}
{invitesLoading ? ( {invitesLoading ? (
<Button <Button
@ -617,11 +607,9 @@ function MemberRow({
function CreateInviteDialog({ function CreateInviteDialog({
roles, roles,
onCreateInvite, onCreateInvite,
workspaceId,
}: { }: {
roles: Role[]; roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>; onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
workspaceId: number;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -653,12 +641,7 @@ function CreateInviteDialog({
const invite = await onCreateInvite(data); const invite = await onCreateInvite(data);
setCreatedInvite(invite); setCreatedInvite(invite);
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined; // workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
trackWorkspaceInviteSent(workspaceId, {
roleName,
hasExpiry: !!expiresAt,
hasMaxUses: !!maxUses,
});
} catch (error) { } catch (error) {
console.error("Failed to create invite:", error); console.error("Failed to create invite:", error);
toast.error("Failed to create invite. Please try again."); toast.error("Failed to create invite. Please try again.");

View file

@ -16,7 +16,7 @@ import { motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { use, useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms"; import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { useSession } from "@/hooks/use-session"; import { useSession } from "@/hooks/use-session";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { setRedirectPath } from "@/lib/auth-utils"; import { setRedirectPath } from "@/lib/auth-utils";
import { import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
trackWorkspaceInviteAccepted,
trackWorkspaceInviteDeclined,
trackWorkspaceUserAdded,
} from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
export default function InviteAcceptPage() { export default function InviteAcceptPage() {
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
setAccepted(true); setAccepted(true);
setAcceptedData(result); setAcceptedData(result);
// Track invite accepted and user added events // workspace_invite_accepted + workspace_user_added are now emitted
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name); // server-side (rbac_routes.accept_invite) — the server redirect is
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name); // the authoritative join point.
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to accept invite"); setError(err.message || "Failed to accept invite");

View file

@ -8,18 +8,11 @@ import type {
} from "@/contracts/types/automation.types"; } from "@/contracts/types/automation.types";
import { automationsApiService } from "@/lib/apis/automations-api.service"; import { automationsApiService } from "@/lib/apis/automations-api.service";
import { import {
trackAutomationCreated,
trackAutomationCreateFailed, trackAutomationCreateFailed,
trackAutomationDeleted,
trackAutomationDeleteFailed, trackAutomationDeleteFailed,
trackAutomationStatusChanged,
trackAutomationTriggerAdded,
trackAutomationTriggerAddFailed, trackAutomationTriggerAddFailed,
trackAutomationTriggerRemoved,
trackAutomationTriggerRemoveFailed, trackAutomationTriggerRemoveFailed,
trackAutomationTriggerUpdated,
trackAutomationTriggerUpdateFailed, trackAutomationTriggerUpdateFailed,
trackAutomationUpdated,
trackAutomationUpdateFailed, trackAutomationUpdateFailed,
} from "@/lib/posthog/events"; } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: AutomationCreateRequest) => { mutationFn: async (request: AutomationCreateRequest) => {
return automationsApiService.createAutomation(request); return automationsApiService.createAutomation(request);
}, },
onSuccess: (automation, variables) => { onSuccess: (_automation, variables) => {
invalidateList(variables.workspace_id); invalidateList(variables.workspace_id);
toast.success("Automation created"); toast.success("Automation created");
trackAutomationCreated({ // automation_created is now emitted server-side (AutomationService.create).
workspace_id: variables.workspace_id,
automation_id: automation.id,
task_count: variables.definition.plan.length,
trigger_type: variables.triggers?.[0]?.type ?? "none",
has_schedule: (variables.triggers?.length ?? 0) > 0,
chat_model_id: variables.definition.models?.chat_model_id,
image_gen_model_id: variables.definition.models?.image_gen_model_id,
vision_model_id: variables.definition.models?.vision_model_id,
tags_count: variables.definition.metadata?.tags?.length,
});
}, },
onError: (error: Error, variables) => { onError: (error: Error, variables) => {
console.error("Error creating automation:", error); console.error("Error creating automation:", error);
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
invalidateList(automation.workspace_id); invalidateList(automation.workspace_id);
toast.success("Automation updated"); toast.success("Automation updated");
// A status-only patch (pause/resume/archive) is a distinct action from a // automation_updated / automation_status_changed are now emitted
// definition/name edit, so split it into its own event. // server-side (AutomationService.update).
if (vars.patch.status && !vars.patch.definition) {
trackAutomationStatusChanged({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
next_status: vars.patch.status,
});
} else {
trackAutomationUpdated({
automation_id: vars.automationId,
workspace_id: automation.workspace_id,
has_definition_change: !!vars.patch.definition,
has_name_change: vars.patch.name != null,
has_description_change: vars.patch.description !== undefined,
task_count: vars.patch.definition?.plan?.length,
});
}
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error updating automation:", error); console.error("Error updating automation:", error);
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
invalidateList(vars.workspaceId); invalidateList(vars.workspaceId);
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Automation deleted"); toast.success("Automation deleted");
trackAutomationDeleted({ // automation_deleted is now emitted server-side (AutomationService.delete).
automation_id: vars.automationId,
workspace_id: vars.workspaceId,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error deleting automation:", error); console.error("Error deleting automation:", error);
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => { mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
return automationsApiService.addTrigger(vars.automationId, vars.payload); return automationsApiService.addTrigger(vars.automationId, vars.payload);
}, },
onSuccess: (trigger, vars) => { onSuccess: (_trigger, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger added"); toast.success("Trigger added");
trackAutomationTriggerAdded({ // automation_trigger_added is now emitted server-side (TriggerService.add).
automation_id: vars.automationId,
trigger_id: trigger.id,
trigger_type: trigger.type,
enabled: trigger.enabled,
has_cron: !!trigger.params?.cron,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error adding trigger:", error); console.error("Error adding trigger:", error);
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (_, vars) => { onSuccess: (_, vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger updated"); toast.success("Trigger updated");
const change: "enabled" | "params" | "other" = vars.patch.params // automation_trigger_updated is now emitted server-side (TriggerService.update).
? "params"
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
? "enabled"
: "other";
trackAutomationTriggerUpdated({
automation_id: vars.automationId,
trigger_id: vars.triggerId,
change,
enabled: vars.patch.enabled ?? undefined,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error updating trigger:", error); console.error("Error updating trigger:", error);
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
onSuccess: (vars) => { onSuccess: (vars) => {
invalidateDetail(vars.automationId); invalidateDetail(vars.automationId);
toast.success("Trigger removed"); toast.success("Trigger removed");
trackAutomationTriggerRemoved({ // automation_trigger_removed is now emitted server-side (TriggerService.remove).
automation_id: vars.automationId,
trigger_id: vars.triggerId,
});
}, },
onError: (error: Error, vars) => { onError: (error: Error, vars) => {
console.error("Error removing trigger:", error); console.error("Error removing trigger:", error);

View file

@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
import { authenticatedFetch } from "@/lib/auth-fetch"; import { authenticatedFetch } from "@/lib/auth-fetch";
import { buildBackendUrl } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { import {
trackConnectorConnected,
trackConnectorDeleted,
trackConnectorSetupFailure, trackConnectorSetupFailure,
trackConnectorSetupStarted, trackConnectorSetupStarted,
trackIndexWithDateRangeOpened, trackIndexWithDateRangeOpened,
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
if (newConnector && oauthConnector) { if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector); const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) { if (connectorValidation.success) {
trackConnectorConnected( // connector_connected is now emitted server-side (after_insert
Number(workspaceId), // listener in search_source_connectors_routes.py) — covers the
oauthConnector.connectorType, // OAuth callback redirect the frontend often never observes.
newConnector.id
);
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType); const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
if (connector) { if (connector) {
const connectorValidation = searchSourceConnector.safeParse(connector); const connectorValidation = searchSourceConnector.safeParse(connector);
if (connectorValidation.success) { if (connectorValidation.success) {
// Track webcrawler connector connected // connector_connected is now emitted server-side (after_insert).
trackConnectorConnected(
Number(workspaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
connector.id
);
const config = validateIndexingConfigState({ const config = validateIndexingConfigState({
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
// Store connectingConnectorType before clearing it // Store connectingConnectorType before clearing it
const currentConnectorType = connectingConnectorType; const currentConnectorType = connectingConnectorType;
// Track connector connected event for non-OAuth connectors // connector_connected is now emitted server-side (after_insert).
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
// Find connector title from constants // Find connector title from constants
const connectorInfo = OTHER_CONNECTORS.find( const connectorInfo = OTHER_CONNECTORS.find(
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
id: editingConnector.id, id: editingConnector.id,
}); });
// Track connector deleted event // connector_deleted is now emitted server-side
trackConnectorDeleted( // (search_source_connectors_routes.delete_search_source_connector).
Number(workspaceId),
editingConnector.connector_type,
editingConnector.id
);
toast.success( toast.success(
editingConnector.connector_type === "MCP_CONNECTOR" editingConnector.connector_type === "MCP_CONNECTOR"

View file

@ -28,7 +28,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { trackWorkspaceCreated } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client"; import { queryClient } from "@/lib/query-client/client";
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
description: values.description || "", description: values.description || "",
}); });
trackWorkspaceCreated(result.id, values.name); // workspace_created is now emitted server-side (workspaces_routes.py)
// so PAT/MCP-created workspaces are also counted.
// Seed the gate's query so it resolves without a loader flash, and // Seed the gate's query so it resolves without a loader flash, and
// route straight to onboarding vs. new-chat on the first hop. // route straight to onboarding vs. new-chat on the first hop.

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink } from "lucide-react"; import { Check, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { getWorkspaceIdParam } from "@/lib/route-params"; import { getWorkspaceIdParam } from "@/lib/route-params";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const workspaceId = getWorkspaceIdParam(params) ?? ""; const workspaceId = getWorkspaceIdParam(params) ?? "";
useEffect(() => { // incentive_page_viewed removed — redundant with $pageview.
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"], queryKey: ["incentive-tasks"],
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
const completeMutation = useMutation({ const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask, mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => { onSuccess: (response) => {
if (response.success) { if (response.success) {
toast.success(response.message); toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType); // incentive_task_completed is now emitted server-side
if (task) { // (incentive_tasks_routes.complete_task) where credit is granted.
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
} }

View file

@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
import type { ProcessingMode } from "@/contracts/types/document.types"; import type { ProcessingMode } from "@/contracts/types/document.types";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { import { trackDocumentUploadStarted } from "@/lib/posthog/events";
trackDocumentUploadFailure,
trackDocumentUploadStarted,
trackDocumentUploadSuccess,
} from "@/lib/posthog/events";
import { import {
getAcceptedFileTypes, getAcceptedFileTypes,
getSupportedExtensions, getSupportedExtensions,
@ -380,13 +376,14 @@ export function DocumentUploadTab({
setUploadProgress(Math.round((uploaded / total) * 100)); setUploadProgress(Math.round((uploaded / total) * 100));
} }
trackDocumentUploadSuccess(Number(workspaceId), total); // Ingestion outcome is now emitted server-side
// (document_processing_completed/_failed in document_tasks.py); the
// upload POST succeeding only means the file was accepted, not processed.
toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
setFolderUpload(null); setFolderUpload(null);
onSuccess?.(); onSuccess?.();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Upload failed"; const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), { toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`, description: `${t("upload_error_desc")}: ${message}`,
}); });
@ -421,7 +418,7 @@ export function DocumentUploadTab({
onSuccess: () => { onSuccess: () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(100); setUploadProgress(100);
trackDocumentUploadSuccess(Number(workspaceId), files.length); // Ingestion outcome now server-side (document_processing_*).
toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
onSuccess?.(); onSuccess?.();
}, },
@ -429,7 +426,6 @@ export function DocumentUploadTab({
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
setUploadProgress(0); setUploadProgress(0);
const message = error instanceof Error ? error.message : "Upload failed"; const message = error instanceof Error ? error.message : "Upload failed";
trackDocumentUploadFailure(Number(workspaceId), message);
toast(t("upload_error"), { toast(t("upload_error"), {
description: `${t("upload_error_desc")}: ${message}`, description: `${t("upload_error_desc")}: ${message}`,
}); });

View file

@ -156,6 +156,40 @@ transport.
Celery runtime, and runtime gauges appear within one export interval. Celery runtime, and runtime gauges appear within one export interval.
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs. 6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
## Product Analytics (PostHog)
Separate from OpenTelemetry, the backend can emit server-side product events to
PostHog. This is the authoritative source for outcome events (chats, document
ingestion, connector indexing, billing, automations) because it captures traffic
the browser never sees — MCP clients, personal-access-token scripts, and Celery
background jobs. It is fully opt-in and mirrors the OTel contract: with
`POSTHOG_API_KEY` unset, every capture is a silent no-op.
Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
server events merge onto the same PostHog persons the web app identifies by user
id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
they reach the API, Celery worker, and beat services via `env_file`, so no
compose changes are needed:
```dotenv
POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_AI_PRIVACY_MODE=true
```
`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
LLM prompt and completion bodies shipped to PostHog's AI observability views.
Every backend event is stamped `source=backend` (so it is distinguishable from
frontend captures), carries `auth_method` / `client` for surface attribution, and
sends `disable_geoip=true` so the server IP never overwrites a person's real
location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
traces via the PostHog LangChain callback handler, keyed by turn and chat id.
Keep event properties low-cardinality. Never attach user content — workspace
names, connector titles, document titles, prompts, or raw queries — as event
properties; they carry no aggregation value and are a privacy risk.
## Out Of Scope ## Out Of Scope
- Frontend/browser OpenTelemetry. - Frontend/browser OpenTelemetry.

View file

@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types"; import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service"; import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { trackWeeklyUser } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled"; export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
try { try {
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload); const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
runIdRef.current = started.run_id; runIdRef.current = started.run_id;
trackWeeklyUser("api_run", workspaceId); // weekly_users removed — WAU is derived server-side from
// scraper_run_completed / chat_turn_completed.
setState((s) => ({ ...s, runId: started.run_id })); setState((s) => ({ ...s, runId: started.run_id }));
void consume(started.run_id, controller.signal); void consume(started.run_id, controller.signal);
} catch (e) { } catch (e) {

View file

@ -39,6 +39,30 @@ function blockRefreshRetry(key: string): void {
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS); refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
} }
/**
* Send an API failure to PostHog error tracking. Scoped by the caller to only
* 5xx server faults + network outages 4xx responses are expected behavior.
* Lazy-imports posthog-js so an ad-blocker can never break the request path.
*/
function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
import("posthog-js")
.then(({ default: posthog }) => {
posthog.captureException(error, {
api_url: url,
api_method: method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
}
export type RequestOptions = { export type RequestOptions = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>; headers?: Record<string, string>;
@ -281,29 +305,20 @@ class BaseApiService {
throw new AbortedError(); throw new AbortedError();
} }
if (error instanceof TypeError && !(error instanceof AppError)) { if (error instanceof TypeError && !(error instanceof AppError)) {
throw new NetworkError( const networkError = new NetworkError(
"Unable to connect to the server. Check your internet connection and try again." "Unable to connect to the server. Check your internet connection and try again."
); );
// Network failures are genuine outages worth tracking.
captureApiException(networkError, url, options?.method);
throw networkError;
} }
console.error("Request failed:", JSON.stringify(error)); console.error("Request failed:", JSON.stringify(error));
if (!(error instanceof AuthenticationError)) { // Only 5xx server faults are unexpected. 4xx (validation, authz, 404)
import("posthog-js") // are expected behavior — capturing them was billable error-tracking
.then(({ default: posthog }) => { // noise. AuthenticationError (401) is a 4xx and stays excluded.
posthog.captureException(error, { if (error instanceof AppError && error.status >= 500) {
api_url: url, captureApiException(error, url, options?.method);
api_method: options?.method ?? "GET",
...(error instanceof AppError && {
status_code: error.status,
status_text: error.statusText,
error_code: error.code,
request_id: error.requestId,
}),
});
})
.catch(() => {
console.error("Failed to capture exception in PostHog");
});
} }
throw error; throw error;
} }

View file

@ -58,7 +58,6 @@ import {
import { buildBackendUrl } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { import {
trackChatBlocked, trackChatBlocked,
trackChatCreated,
trackChatErrorDetailed, trackChatErrorDetailed,
trackChatMessageSent, trackChatMessageSent,
trackChatResponseReceived, trackChatResponseReceived,
@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread); queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] }); queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
trackChatCreated(workspaceId, currentThreadId); // chat_created is now emitted server-side (new_chat_routes.create_thread)
// so PAT/MCP-created threads are also counted.
isNewThread = true; isNewThread = true;
// Update URL silently using browser API (not router.replace) to avoid // Update URL silently using browser API (not router.replace) to avoid

View file

@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry"; import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
/** /**
* PostHog Analytics Event Definitions * PostHog Analytics Event Definitions (frontend)
* *
* All capture/identify/reset calls are wrapped in try-catch so that * All capture/identify/reset calls are wrapped in try-catch so that
* ad-blockers that interfere with posthog-js can never break app * ad-blockers that interfere with posthog-js can never break app
* functionality (e.g. the chat flow). * functionality (e.g. the chat flow).
* *
* Events follow a consistent naming convention: category_action * SCOPE: this file now holds only *intent* and client-perceived *UX* events.
* Authoritative *outcome* events (resource creation, task/ingestion/indexing
* completion, auth success, billing) are emitted server-side in
* surfsense_backend/app/observability/analytics.py they are reliable
* regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
* clients. Do NOT re-add optimistic outcome captures here; they double-count.
* *
* Categories: * Events follow a consistent naming convention: category_action
* - auth: Authentication events
* - workspace: Search space management
* - document: Document management
* - chat: Chat and messaging (authenticated + anonymous)
* - connector: External connector events (all lifecycle stages)
* - contact: Contact form events
* - settings: Settings changes
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
* - marketing: Marketing/referral tracking
*/ */
function safeCapture(event: string, properties?: Record<string, unknown>) { function safeCapture(event: string, properties?: Record<string, unknown>) {
@ -43,17 +39,13 @@ function compact<T extends object>(obj: T): Record<string, unknown> {
} }
// ============================================ // ============================================
// AUTH EVENTS // AUTH EVENTS (attempts + failures only; successes are server-side)
// ============================================ // ============================================
export function trackLoginAttempt(method: "local" | "google") { export function trackLoginAttempt(method: "local" | "google") {
safeCapture("auth_login_attempt", { method }); safeCapture("auth_login_attempt", { method });
} }
export function trackLoginSuccess(method: "local" | "google") {
safeCapture("auth_login_success", { method });
}
export function trackLoginFailure(method: "local" | "google", error?: string) { export function trackLoginFailure(method: "local" | "google", error?: string) {
safeCapture("auth_login_failure", { method, error }); safeCapture("auth_login_failure", { method, error });
} }
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
safeCapture("auth_registration_attempt"); safeCapture("auth_registration_attempt");
} }
export function trackRegistrationSuccess() {
safeCapture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) { export function trackRegistrationFailure(error?: string) {
safeCapture("auth_registration_failure", { error }); safeCapture("auth_registration_failure", { error });
} }
@ -75,56 +63,9 @@ export function trackLogout() {
} }
// ============================================ // ============================================
// SEARCH SPACE EVENTS // CHAT EVENTS (client-perceived UX)
// ============================================ // ============================================
export function trackWorkspaceCreated(workspaceId: number, name: string) {
safeCapture("workspace_created", {
workspace_id: workspaceId,
name,
});
}
export function trackWorkspaceDeleted(workspaceId: number) {
safeCapture("workspace_deleted", {
workspace_id: workspaceId,
});
}
export function trackWorkspaceViewed(workspaceId: number) {
safeCapture("workspace_viewed", {
workspace_id: workspaceId,
});
}
// ============================================
// ACTIVE-USER (WAU) EVENT
// ============================================
/**
* Single signal for active-user counting. Fired whenever a user sends a
* chat message or starts an API run, so a "weekly unique users on
* weekly_users" insight in PostHog is our WAU number.
*
* ponytail: frontend-only capture API runs made directly against the
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
* server-side capture in the backend if that ever matters.
*/
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
}
// ============================================
// CHAT EVENTS
// ============================================
export function trackChatCreated(workspaceId: number, chatId: number) {
safeCapture("chat_created", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackChatMessageSent( export function trackChatMessageSent(
workspaceId: number, workspaceId: number,
chatId: number, chatId: number,
@ -141,7 +82,6 @@ export function trackChatMessageSent(
has_mentioned_documents: options?.hasMentionedDocuments ?? false, has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength, message_length: options?.messageLength,
}); });
trackWeeklyUser("chat_message", workspaceId);
} }
export function trackChatResponseReceived(workspaceId: number, chatId: number) { export function trackChatResponseReceived(workspaceId: number, chatId: number) {
@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
* flow. This is intentionally a separate event from `chat_message_sent` * flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while * so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*. * still giving us visibility into top-of-funnel usage on /free/*.
*
* Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
* frontend anon distinct id is what merges into the person at signup,
* powering the anonymous-to-registered conversion funnel.
*/ */
export function trackAnonymousChatMessageSent(options: { export function trackAnonymousChatMessageSent(options: {
modelSlug: string; modelSlug: string;
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
} }
// ============================================ // ============================================
// DOCUMENT EVENTS // DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
// ============================================ // ============================================
export function trackDocumentUploadStarted( export function trackDocumentUploadStarted(
@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
}); });
} }
export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
safeCapture("document_upload_success", {
workspace_id: workspaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
safeCapture("document_upload_failure", {
workspace_id: workspaceId,
error,
});
}
export function trackDocumentDeleted(workspaceId: number, documentId: number) {
safeCapture("document_deleted", {
workspace_id: workspaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
safeCapture("document_bulk_deleted", {
workspace_id: workspaceId,
count,
});
}
export function trackYouTubeImport(workspaceId: number, url: string) {
safeCapture("youtube_import_started", {
workspace_id: workspaceId,
url,
});
}
// ============================================ // ============================================
// CONNECTOR EVENTS (generic lifecycle dispatcher) // CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
// ============================================ // ============================================
// //
// All connector events go through `trackConnectorEvent`. The connector's // All connector events go through `trackConnectorEvent`. The connector's
// human-readable title and its group (oauth/composio/crawler/other) are // group (oauth/composio/crawler/other) is auto-attached from the shared
// auto-attached from the shared registry in `connector-constants.ts`, so // registry, so adding a new connector to that list is the only change
// adding a new connector to that list is the only change required for it // required for it to show up correctly in PostHog dashboards.
// to show up correctly in PostHog dashboards.
export type ConnectorEventStage = export type ConnectorEventStage =
| "setup_started" | "setup_started"
| "setup_success" | "setup_success"
| "setup_failure" | "setup_failure"
| "oauth_initiated" | "oauth_initiated";
| "connected"
| "deleted"
| "synced";
export interface ConnectorEventOptions { export interface ConnectorEventOptions {
workspaceId?: number | null; workspaceId?: number | null;
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
/** /**
* Generic connector lifecycle tracker. Every connector analytics event * Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent. * should funnel through here so the enrichment stays consistent.
*
* ``connector_title`` is intentionally NOT sent it's a display label with
* no aggregation value; segment on ``connector_type`` / ``connector_group``.
*/ */
export function trackConnectorEvent( export function trackConnectorEvent(
stage: ConnectorEventStage, stage: ConnectorEventStage,
@ -327,7 +235,6 @@ export function trackConnectorEvent(
error: options.error, error: options.error,
}), }),
connector_type: meta.connector_type, connector_type: meta.connector_type,
connector_title: meta.connector_title,
connector_group: meta.connector_group, connector_group: meta.connector_group,
is_oauth: meta.is_oauth, is_oauth: meta.is_oauth,
...(options.extra ?? {}), ...(options.extra ?? {}),
@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
}); });
} }
export function trackConnectorDeleted(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
}
export function trackConnectorSynced(
workspaceId: number,
connectorType: string,
connectorId: number
) {
trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
}
// ============================================
// SETTINGS EVENTS
// ============================================
export function trackSettingsViewed(workspaceId: number, section: string) {
safeCapture("settings_viewed", {
workspace_id: workspaceId,
section,
});
}
export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
safeCapture("settings_updated", {
workspace_id: workspaceId,
section,
setting,
});
}
// ============================================ // ============================================
// FEATURE USAGE EVENTS // FEATURE USAGE EVENTS
// ============================================ // ============================================
export function trackPodcastGenerated(workspaceId: number, chatId: number) {
safeCapture("podcast_generated", {
workspace_id: workspaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(workspaceId: number, tab: string) {
safeCapture("sources_tab_viewed", {
workspace_id: workspaceId,
tab,
});
}
export function trackDesktopDownloadClicked(options: { export function trackDesktopDownloadClicked(options: {
os: string; os: string;
placement: "sidebar_collapsed" | "sidebar_expanded"; placement: "sidebar_collapsed" | "sidebar_expanded";
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
} }
// ============================================ // ============================================
// SEARCH SPACE INVITE EVENTS // INDEXING EVENTS (intent/UX; indexing outcome is server-side)
// ============================================
export function trackWorkspaceInviteSent(
workspaceId: number,
options?: {
roleName?: string;
hasExpiry?: boolean;
hasMaxUses?: boolean;
}
) {
safeCapture("workspace_invite_sent", {
workspace_id: workspaceId,
role_name: options?.roleName,
has_expiry: options?.hasExpiry ?? false,
has_max_uses: options?.hasMaxUses ?? false,
});
}
export function trackWorkspaceInviteAccepted(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_invite_accepted", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("workspace_invite_declined", {
workspace_name: workspaceName,
});
}
export function trackWorkspaceUserAdded(
workspaceId: number,
workspaceName: string,
roleName?: string | null
) {
safeCapture("workspace_user_added", {
workspace_id: workspaceId,
workspace_name: workspaceName,
role_name: roleName,
});
}
export function trackWorkspaceUsersViewed(
workspaceId: number,
userCount: number,
ownerCount: number
) {
safeCapture("workspace_users_viewed", {
workspace_id: workspaceId,
user_count: userCount,
owner_count: ownerCount,
});
}
// ============================================
// CONNECTOR CONNECTION EVENTS
// ============================================
export function trackConnectorConnected(
workspaceId: number,
connectorType: string,
connectorId?: number
) {
trackConnectorEvent("connected", connectorType, {
workspaceId,
connectorId: connectorId ?? undefined,
});
}
// ============================================
// INDEXING EVENTS
// ============================================ // ============================================
export function trackIndexWithDateRangeOpened( export function trackIndexWithDateRangeOpened(
@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
} }
// ============================================ // ============================================
// INCENTIVE TASKS EVENTS // SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
// ============================================ // ============================================
export function trackIncentivePageViewed() { export function trackWorkspaceInviteDeclined(workspaceName?: string) {
safeCapture("incentive_page_viewed"); safeCapture("workspace_invite_declined", {
} workspace_name: workspaceName,
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
safeCapture("incentive_task_completed", {
task_type: taskType,
credit_micros_rewarded: creditMicrosRewarded,
}); });
} }
// ============================================
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
// ============================================
export function trackIncentiveTaskClicked(taskType: string) { export function trackIncentiveTaskClicked(taskType: string) {
safeCapture("incentive_task_clicked", { safeCapture("incentive_task_clicked", {
task_type: taskType, task_type: taskType,
@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
} }
// ============================================ // ============================================
// AUTOMATION EVENTS // AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
// ============================================ // ============================================
interface AutomationCreatedProps {
workspace_id: number;
automation_id: number;
task_count?: number;
trigger_type?: string;
has_schedule?: boolean;
chat_model_id?: number;
image_gen_model_id?: number;
vision_model_id?: number;
tags_count?: number;
}
export function trackAutomationCreated(props: AutomationCreatedProps) {
safeCapture("automation_created", compact(props));
}
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) { export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
safeCapture("automation_create_failed", compact(props)); safeCapture("automation_create_failed", compact(props));
} }
export function trackAutomationUpdated(props: {
automation_id: number;
workspace_id?: number;
has_definition_change?: boolean;
has_name_change?: boolean;
has_description_change?: boolean;
task_count?: number;
}) {
safeCapture("automation_updated", compact(props));
}
export function trackAutomationStatusChanged(props: {
automation_id: number;
workspace_id?: number;
next_status: string;
}) {
safeCapture("automation_status_changed", compact(props));
}
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) { export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_update_failed", compact(props)); safeCapture("automation_update_failed", compact(props));
} }
export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
safeCapture("automation_deleted", compact(props));
}
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) { export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_delete_failed", compact(props)); safeCapture("automation_delete_failed", compact(props));
} }
export function trackAutomationTriggerAdded(props: {
automation_id: number;
trigger_id?: number;
trigger_type?: string;
enabled?: boolean;
has_cron?: boolean;
}) {
safeCapture("automation_trigger_added", compact(props));
}
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) { export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
safeCapture("automation_trigger_add_failed", compact(props)); safeCapture("automation_trigger_add_failed", compact(props));
} }
export function trackAutomationTriggerUpdated(props: {
automation_id: number;
trigger_id: number;
change?: "enabled" | "params" | "other";
enabled?: boolean;
}) {
safeCapture("automation_trigger_updated", compact(props));
}
export function trackAutomationTriggerUpdateFailed(props: { export function trackAutomationTriggerUpdateFailed(props: {
automation_id: number; automation_id: number;
trigger_id: number; trigger_id: number;
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
safeCapture("automation_trigger_update_failed", compact(props)); safeCapture("automation_trigger_update_failed", compact(props));
} }
export function trackAutomationTriggerRemoved(props: {
automation_id: number;
trigger_id: number;
}) {
safeCapture("automation_trigger_removed", compact(props));
}
export function trackAutomationTriggerRemoveFailed(props: { export function trackAutomationTriggerRemoveFailed(props: {
automation_id: number; automation_id: number;
trigger_id: number; trigger_id: number;