mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-24 23:41:10 +02:00
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:
parent
ca4f231577
commit
dbedf0cfa5
47 changed files with 1618 additions and 513 deletions
|
|
@ -392,6 +392,15 @@ STT_SERVICE=local/base
|
|||
# OTEL_HTTP_PORT=4318
|
||||
# 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)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
|
|||
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
|
||||
# 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
|
||||
# SURFSENSE_ENABLE_SKILLS=false
|
||||
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from app.gateway.inbox_worker import (
|
|||
start_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.bootstrap import init_otel, shutdown_otel
|
||||
from app.rate_limiter import get_real_client_ip, limiter
|
||||
|
|
@ -690,6 +691,7 @@ async def lifespan(app: FastAPI):
|
|||
await stop_gateway_inbox_worker()
|
||||
_stop_openrouter_background_refresh()
|
||||
await close_checkpointer()
|
||||
ph_analytics.shutdown()
|
||||
shutdown_otel()
|
||||
|
||||
|
||||
|
|
@ -796,6 +798,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
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
|
||||
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
|
||||
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
|
||||
|
|
|
|||
|
|
@ -15,11 +15,38 @@ from app.automations.schemas.definition.envelope import (
|
|||
)
|
||||
from app.automations.schemas.definition.plan_step import PlanStep
|
||||
from app.automations.templating import build_run_context
|
||||
from app.observability import analytics as ph_analytics
|
||||
|
||||
from . import repository
|
||||
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:
|
||||
"""Load run ``run_id`` and execute its snapshot plan to a terminal state."""
|
||||
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()
|
||||
_capture_run_outcome(run, "failed")
|
||||
return
|
||||
|
||||
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 repository.mark_failed(session, run, result.get("error"))
|
||||
await session.commit()
|
||||
_capture_run_outcome(run, "failed")
|
||||
return
|
||||
|
||||
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 session.commit()
|
||||
_capture_run_outcome(run, "succeeded")
|
||||
|
||||
|
||||
async def _run_on_failure(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.automations.services.model_policy import (
|
|||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||
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.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -75,6 +76,18 @@ class AutomationService:
|
|||
|
||||
self.session.add(automation)
|
||||
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)
|
||||
|
||||
async def list(
|
||||
|
|
@ -150,6 +163,31 @@ class AutomationService:
|
|||
automation.version += 1
|
||||
|
||||
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)
|
||||
|
||||
async def delete(self, automation_id: int) -> None:
|
||||
|
|
@ -158,9 +196,18 @@ class AutomationService:
|
|||
await self._authorize(
|
||||
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
|
||||
)
|
||||
workspace_id = automation.workspace_id
|
||||
await self.session.delete(automation)
|
||||
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:
|
||||
automation = await self.session.get(Automation, automation_id)
|
||||
if automation is None:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.automations.schemas.api import TriggerCreate, TriggerUpdate
|
|||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||
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.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -48,6 +49,18 @@ class TriggerService:
|
|||
self.session.add(trigger)
|
||||
await self.session.commit()
|
||||
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
|
||||
|
||||
async def update(
|
||||
|
|
@ -82,6 +95,26 @@ class TriggerService:
|
|||
|
||||
await self.session.commit()
|
||||
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
|
||||
|
||||
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.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(
|
||||
self, automation_id: int, permission: str
|
||||
) -> Automation:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from app.capabilities.core.store import all_capabilities
|
|||
from app.capabilities.core.types import Capability, CapabilityContext
|
||||
from app.db import Run, async_session_maker, get_async_session
|
||||
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.users import get_auth_context
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
|
@ -185,6 +221,8 @@ async def _execute_async_run(
|
|||
unit,
|
||||
executor,
|
||||
payload,
|
||||
user_id=None,
|
||||
origin: str = "api",
|
||||
) -> None:
|
||||
"""Run a scrape in the background: stream progress, charge, finalize the row.
|
||||
|
||||
|
|
@ -218,6 +256,13 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_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
|
||||
except Exception:
|
||||
logger.exception("async run %s failed with an upstream error", run_id)
|
||||
|
|
@ -229,6 +274,13 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_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
|
||||
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
|
@ -252,6 +304,15 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_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(
|
||||
|
|
@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
unit=unit,
|
||||
executor=executor,
|
||||
payload=payload,
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
run_event_bus.register_task(run_id, task)
|
||||
|
|
@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
try:
|
||||
output = await executor(payload)
|
||||
except (SurfSenseError, HTTPException) as exc:
|
||||
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||
await _record_rest_run(
|
||||
workspace_id=workspace_id,
|
||||
capability=name,
|
||||
|
|
@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
input=input_dump,
|
||||
user_id=user_id,
|
||||
error=str(exc),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
duration_ms=_sync_err_duration,
|
||||
progress=reporter.coarse,
|
||||
)
|
||||
_capture_scraper_run(
|
||||
capability=name,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=_sync_err_duration,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||
await _record_rest_run(
|
||||
workspace_id=workspace_id,
|
||||
capability=name,
|
||||
|
|
@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
input=input_dump,
|
||||
user_id=user_id,
|
||||
error=str(exc),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
duration_ms=_sync_err_duration,
|
||||
progress=reporter.coarse,
|
||||
)
|
||||
_capture_scraper_run(
|
||||
capability=name,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=_sync_err_duration,
|
||||
)
|
||||
raise ExternalServiceError(
|
||||
f"The '{name}' capability failed due to an upstream error.",
|
||||
code="CAPABILITY_UPSTREAM_ERROR",
|
||||
|
|
@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
cost_micros=cost_micros,
|
||||
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:
|
||||
response.headers["X-Run-Id"] = f"run_{run_id}"
|
||||
return output
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from celery.signals import (
|
|||
task_postrun,
|
||||
task_prerun,
|
||||
worker_process_init,
|
||||
worker_process_shutdown,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
|
@ -123,6 +124,18 @@ def init_worker(**kwargs):
|
|||
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_BROKER_URL = config.CELERY_BROKER_URL
|
||||
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,19 @@ class Config:
|
|||
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
|
||||
TTS_SERVICE = os.getenv("TTS_SERVICE")
|
||||
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ wrapper is a no-op when OTEL is not configured, so importing it from
|
|||
performance-critical paths is safe.
|
||||
"""
|
||||
|
||||
__all__ = ["bootstrap", "metrics", "otel"]
|
||||
__all__ = ["analytics", "bootstrap", "metrics", "otel"]
|
||||
|
|
|
|||
202
surfsense_backend/app/observability/analytics.py
Normal file
202
surfsense_backend/app/observability/analytics.py
Normal 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",
|
||||
]
|
||||
|
|
@ -11,7 +11,10 @@ import logging
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.podcasts.persistence import PodcastRepository
|
||||
from app.podcasts.rendering import PodcastRenderer
|
||||
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
|
||||
)
|
||||
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:
|
||||
# A user back-out won the race (e.g. the regeneration was
|
||||
# reverted): drop the stale render and leave the row alone.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
|
|
@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
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 (
|
||||
DIRECT_CONVERT_EXTENSIONS,
|
||||
PLAINTEXT_EXTENSIONS,
|
||||
|
|
@ -354,6 +359,7 @@ async def stream_anonymous_chat(
|
|||
|
||||
accumulator = start_turn()
|
||||
streaming_service = VercelStreamingService()
|
||||
anon_outcome = "success"
|
||||
|
||||
try:
|
||||
async with shielded_async_session():
|
||||
|
|
@ -394,6 +400,21 @@ async def stream_anonymous_chat(
|
|||
"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_start_step()
|
||||
|
||||
|
|
@ -465,6 +486,7 @@ async def stream_anonymous_chat(
|
|||
|
||||
except Exception as e:
|
||||
logger.exception("Anonymous chat stream error")
|
||||
anon_outcome = "error"
|
||||
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
|
||||
_, error_code, _, _, user_message, extra = classify_stream_exception(
|
||||
e,
|
||||
|
|
@ -479,6 +501,26 @@ async def stream_anonymous_chat(
|
|||
finally:
|
||||
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(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.auth.context import AuthContext
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ImageGeneration,
|
||||
|
|
@ -327,6 +328,20 @@ async def create_image_generation(
|
|||
|
||||
await session.commit()
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
|||
UserIncentiveTask,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.incentive_tasks import (
|
||||
CompleteTaskResponse,
|
||||
IncentiveTaskInfo,
|
||||
|
|
@ -125,6 +126,20 @@ async def complete_task(
|
|||
await session.commit()
|
||||
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(
|
||||
success=True,
|
||||
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
shielded_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.new_chat import (
|
||||
AgentToolInfo,
|
||||
CancelActiveTurnResponse,
|
||||
|
|
@ -823,6 +824,15 @@ async def create_thread(
|
|||
session.add(db_thread)
|
||||
await session.commit()
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlalchemy.future import select
|
|||
from app.auth.context import AuthContext
|
||||
from app.config import config
|
||||
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.users import require_session_context
|
||||
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.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(
|
||||
id=pat.id,
|
||||
label=pat.label,
|
||||
|
|
@ -102,3 +110,5 @@ async def delete_personal_access_token(
|
|||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
ph_analytics.capture_for(auth, "pat_revoked", {"pat_id": pat_id})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import get_async_session
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.new_chat import (
|
||||
CloneResponse,
|
||||
PublicChatResponse,
|
||||
|
|
@ -56,7 +57,20 @@ async def clone_public_chat(
|
|||
Creates thread and copies messages.
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from app.db import (
|
|||
WorkspaceRole,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas import (
|
||||
InviteAcceptRequest,
|
||||
InviteAcceptResponse,
|
||||
|
|
@ -782,6 +783,19 @@ async def create_invite(
|
|||
)
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -1091,6 +1105,16 @@ async def accept_invite(
|
|||
role_name = invite.role.name if invite.role else "Default"
|
||||
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(
|
||||
message="Successfully joined the workspace",
|
||||
workspace_id=invite.workspace_id,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import redis
|
|||
from dateutil.parser import isoparse
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import event as sa_event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
|
@ -44,6 +45,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
)
|
||||
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.schemas import (
|
||||
GoogleDriveIndexRequest,
|
||||
|
|
@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
|
|||
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
|
||||
class GitHubPATRequest(BaseModel):
|
||||
github_pat: str = Field(..., description="GitHub Personal Access Token")
|
||||
|
|
@ -244,6 +285,10 @@ async def create_search_source_connector(
|
|||
await session.commit()
|
||||
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
|
||||
if (
|
||||
db_connector.periodic_indexing_enabled
|
||||
|
|
@ -679,10 +724,23 @@ async def delete_search_source_connector(
|
|||
|
||||
# Delete the connector record
|
||||
workspace_id = db_connector.workspace_id
|
||||
deleted_connector_type = db_connector.connector_type
|
||||
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
|
||||
await session.delete(db_connector)
|
||||
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:
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
||||
invalidate_mcp_tools_cache,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from app.db import (
|
|||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.stripe import (
|
||||
AutoReloadSettingsResponse,
|
||||
CreateAutoReloadSetupSessionRequest,
|
||||
|
|
@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
|
|||
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:
|
||||
"""Return a configured Stripe client or raise if Stripe is disabled."""
|
||||
if not config.STRIPE_SECRET_KEY:
|
||||
|
|
@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
|
|||
)
|
||||
|
||||
await db_session.commit()
|
||||
_capture_credits_purchased(user, purchase)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
|
|
@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
|
|||
purchase.status = CreditPurchaseStatus.FAILED
|
||||
|
||||
await db_session.commit()
|
||||
if succeeded:
|
||||
_capture_credits_purchased(user, purchase)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
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.schemas import (
|
||||
WorkspaceApiAccessUpdate,
|
||||
|
|
@ -112,6 +113,16 @@ async def create_workspace(
|
|||
await session.commit()
|
||||
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.llm_setup = await compute_llm_setup_status(
|
||||
session, auth, db_workspace.id
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Celery tasks for connector indexing."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
|
|
@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
|
|||
from celery import current_task
|
||||
|
||||
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.tasks.celery_tasks import (
|
||||
get_celery_session_maker,
|
||||
|
|
@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
||||
"""Run connector sync work and record aggregate connector metrics."""
|
||||
def run_async_celery_task[T](
|
||||
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"
|
||||
t0 = time.perf_counter()
|
||||
status = "failed"
|
||||
|
|
@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
|||
status=status,
|
||||
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:
|
||||
|
|
@ -91,7 +126,9 @@ def index_notion_pages_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_notion_pages(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
_handle_greenlet_error(e, "index_notion_pages", connector_id)
|
||||
|
|
@ -129,7 +166,9 @@ def index_github_repos_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_github_repos(
|
||||
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(
|
||||
lambda: _index_confluence_pages(
|
||||
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(
|
||||
lambda: _index_google_calendar_events(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
_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(
|
||||
lambda: _index_google_gmail_messages(
|
||||
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,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -315,7 +362,9 @@ def index_onedrive_files_task(
|
|||
workspace_id,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -355,7 +404,9 @@ def index_dropbox_files_task(
|
|||
workspace_id,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_elasticsearch_documents(
|
||||
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(
|
||||
lambda: _index_bookstack_pages(
|
||||
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(
|
||||
lambda: _index_composio_connector(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import asyncio
|
|||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.config import config
|
||||
from app.notifications.service import NotificationService
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.observability import metrics as ot_metrics
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
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__)
|
||||
|
||||
|
||||
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 =====
|
||||
# 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.
|
||||
|
|
@ -268,11 +308,30 @@ def process_extension_document_task(
|
|||
workspace_id: ID of the workspace
|
||||
user_id: ID of the user
|
||||
"""
|
||||
return run_async_celery_task(
|
||||
lambda: _process_extension_document(
|
||||
individual_document_dict, workspace_id, user_id
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
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(
|
||||
|
|
@ -430,12 +489,14 @@ def process_file_upload_task(
|
|||
)
|
||||
return
|
||||
|
||||
file_size: int | None = None
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"[process_file_upload] File size: {file_size} bytes")
|
||||
except Exception as e:
|
||||
logger.warning(f"[process_file_upload] Could not get file size: {e}")
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
run_async_celery_task(
|
||||
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"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
|
||||
_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(
|
||||
|
|
@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
|
|||
)
|
||||
return
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
run_async_celery_task(
|
||||
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"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
|
||||
_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):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.agents.video_presentation.state import State as VideoPresentationState
|
|||
from app.celery_app import celery_app
|
||||
from app.config import config as app_config
|
||||
from app.db import VideoPresentation, VideoPresentationStatus
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.services.billable_calls import (
|
||||
BillingSettlementError,
|
||||
QuotaInsufficientError,
|
||||
|
|
@ -239,6 +240,20 @@ async def _generate_video_presentation(
|
|||
|
||||
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 {
|
||||
"status": "ready",
|
||||
"video_presentation_id": video_pres.id,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
|
|||
log_rate_limit_recovered,
|
||||
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 (
|
||||
close_chat_request_span,
|
||||
open_chat_request_span,
|
||||
|
|
@ -487,6 +491,23 @@ async def stream_new_chat(
|
|||
"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 ---
|
||||
|
||||
for sse in iter_initial_frames(
|
||||
|
|
@ -830,6 +851,27 @@ async def 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
|
||||
# downloadable after the Daytona sandbox auto-deletes.
|
||||
if stream_result and stream_result.sandbox_files:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
|
|||
log_rate_limit_recovered,
|
||||
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 (
|
||||
close_chat_request_span,
|
||||
open_chat_request_span,
|
||||
|
|
@ -387,6 +391,22 @@ async def stream_resume_chat(
|
|||
"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 ---
|
||||
|
||||
for sse in iter_initial_frames(
|
||||
|
|
@ -599,6 +619,25 @@ async def stream_resume_chat(
|
|||
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
|
||||
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
|
||||
if not busy_error_raised:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.auth.context import AuthContext
|
||||
from app.auth.session_cookies import access_expires_at, write_session
|
||||
from app.config import config
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.db import (
|
||||
Prompt,
|
||||
User,
|
||||
|
|
@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||
except Exception as 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):
|
||||
"""
|
||||
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(
|
||||
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:
|
||||
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
|
||||
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")
|
||||
if auth_header:
|
||||
scheme, _, credential = auth_header.partition(" ")
|
||||
|
|
@ -348,7 +370,7 @@ async def get_auth_context(
|
|||
pat = await resolve_pat(session, token)
|
||||
if pat and pat.user and pat.user.is_active:
|
||||
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):
|
||||
try:
|
||||
|
|
@ -358,7 +380,7 @@ async def get_auth_context(
|
|||
user = None
|
||||
|
||||
if user and user.is_active:
|
||||
return AuthContext.session(user)
|
||||
return _stash(AuthContext.session(user))
|
||||
|
||||
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
|
||||
if cookie_token and _token_meets_epoch(cookie_token):
|
||||
|
|
@ -369,7 +391,7 @@ async def get_auth_context(
|
|||
user = None
|
||||
|
||||
if user and user.is_active:
|
||||
return AuthContext.session(user)
|
||||
return _stash(AuthContext.session(user))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ dependencies = [
|
|||
"python-telegram-bot>=22.7",
|
||||
"croniter>=2.0.0",
|
||||
"scrapling[fetchers]>=0.4.11",
|
||||
"posthog>=6.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
149
surfsense_backend/tests/unit/observability/test_analytics.py
Normal file
149
surfsense_backend/tests/unit/observability/test_analytics.py
Normal 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
|
||||
158
surfsense_backend/uv.lock
generated
158
surfsense_backend/uv.lock
generated
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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'",
|
||||
|
|
@ -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_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 = [[
|
||||
|
|
@ -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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "banks"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "preshed"
|
||||
version = "3.0.13"
|
||||
|
|
@ -10926,6 +11082,7 @@ dependencies = [
|
|||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pyjwt" },
|
||||
|
|
@ -11041,6 +11198,7 @@ requires-dist = [
|
|||
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" },
|
||||
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
|
||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||
{ name = "posthog", specifier = ">=6.0.0" },
|
||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
|
||||
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ class SurfSenseClient:
|
|||
self._fallback_api_key = fallback_api_key
|
||||
self._http = httpx.AsyncClient(
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
||||
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
|
|
@ -45,8 +45,8 @@ export function LocalLoginForm() {
|
|||
grant_type: "password",
|
||||
});
|
||||
|
||||
// Track successful login
|
||||
trackLoginSuccess("local");
|
||||
// auth_login_success is now emitted server-side
|
||||
// (UserManager.on_after_login) — authoritative vs. optimistic.
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { useSession } from "@/hooks/use-session";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
import {
|
||||
trackRegistrationAttempt,
|
||||
trackRegistrationFailure,
|
||||
trackRegistrationSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
|
|
@ -81,8 +77,8 @@ export default function RegisterPage() {
|
|||
is_verified: false,
|
||||
});
|
||||
|
||||
// Track successful registration
|
||||
trackRegistrationSuccess();
|
||||
// auth_registration_success is now emitted server-side
|
||||
// (UserManager.on_after_register) — authoritative vs. optimistic.
|
||||
|
||||
// Success toast
|
||||
toast.success(t("register_success"), {
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
|
|||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
|||
const canPrev = pageIndex > 0;
|
||||
const canNext = displayEnd < totalItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (members.length > 0 && !membersLoading) {
|
||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||
}
|
||||
}, [members, membersLoading, workspaceId]);
|
||||
// workspace_users_viewed removed — redundant with $pageview.
|
||||
|
||||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
|
|
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
|||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
<Button
|
||||
|
|
@ -617,11 +607,9 @@ function MemberRow({
|
|||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
workspaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
workspaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -653,12 +641,7 @@ function CreateInviteDialog({
|
|||
const invite = await onCreateInvite(data);
|
||||
setCreatedInvite(invite);
|
||||
|
||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||
trackWorkspaceInviteSent(workspaceId, {
|
||||
roleName,
|
||||
hasExpiry: !!expiresAt,
|
||||
hasMaxUses: !!maxUses,
|
||||
});
|
||||
// workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
|
||||
} catch (error) {
|
||||
console.error("Failed to create invite:", error);
|
||||
toast.error("Failed to create invite. Please try again.");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { motion } from "motion/react";
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { use, useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
|
||||
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 { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { setRedirectPath } from "@/lib/auth-utils";
|
||||
import {
|
||||
trackWorkspaceInviteAccepted,
|
||||
trackWorkspaceInviteDeclined,
|
||||
trackWorkspaceUserAdded,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
|
|
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
|
|||
setAccepted(true);
|
||||
setAcceptedData(result);
|
||||
|
||||
// Track invite accepted and user added events
|
||||
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
|
||||
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
||||
// workspace_invite_accepted + workspace_user_added are now emitted
|
||||
// server-side (rbac_routes.accept_invite) — the server redirect is
|
||||
// the authoritative join point.
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
|
|
|
|||
|
|
@ -8,18 +8,11 @@ import type {
|
|||
} from "@/contracts/types/automation.types";
|
||||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||
import {
|
||||
trackAutomationCreated,
|
||||
trackAutomationCreateFailed,
|
||||
trackAutomationDeleted,
|
||||
trackAutomationDeleteFailed,
|
||||
trackAutomationStatusChanged,
|
||||
trackAutomationTriggerAdded,
|
||||
trackAutomationTriggerAddFailed,
|
||||
trackAutomationTriggerRemoved,
|
||||
trackAutomationTriggerRemoveFailed,
|
||||
trackAutomationTriggerUpdated,
|
||||
trackAutomationTriggerUpdateFailed,
|
||||
trackAutomationUpdated,
|
||||
trackAutomationUpdateFailed,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
|
|||
mutationFn: async (request: AutomationCreateRequest) => {
|
||||
return automationsApiService.createAutomation(request);
|
||||
},
|
||||
onSuccess: (automation, variables) => {
|
||||
onSuccess: (_automation, variables) => {
|
||||
invalidateList(variables.workspace_id);
|
||||
toast.success("Automation created");
|
||||
trackAutomationCreated({
|
||||
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,
|
||||
});
|
||||
// automation_created is now emitted server-side (AutomationService.create).
|
||||
},
|
||||
onError: (error: Error, variables) => {
|
||||
console.error("Error creating automation:", error);
|
||||
|
|
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
|||
invalidateDetail(vars.automationId);
|
||||
invalidateList(automation.workspace_id);
|
||||
toast.success("Automation updated");
|
||||
// A status-only patch (pause/resume/archive) is a distinct action from a
|
||||
// definition/name edit, so split it into its own event.
|
||||
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,
|
||||
});
|
||||
}
|
||||
// automation_updated / automation_status_changed are now emitted
|
||||
// server-side (AutomationService.update).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating automation:", error);
|
||||
|
|
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
|||
invalidateList(vars.workspaceId);
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Automation deleted");
|
||||
trackAutomationDeleted({
|
||||
automation_id: vars.automationId,
|
||||
workspace_id: vars.workspaceId,
|
||||
});
|
||||
// automation_deleted is now emitted server-side (AutomationService.delete).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error deleting automation:", error);
|
||||
|
|
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
|
|||
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
||||
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
||||
},
|
||||
onSuccess: (trigger, vars) => {
|
||||
onSuccess: (_trigger, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger added");
|
||||
trackAutomationTriggerAdded({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: trigger.id,
|
||||
trigger_type: trigger.type,
|
||||
enabled: trigger.enabled,
|
||||
has_cron: !!trigger.params?.cron,
|
||||
});
|
||||
// automation_trigger_added is now emitted server-side (TriggerService.add).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error adding trigger:", error);
|
||||
|
|
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
|
|||
onSuccess: (_, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger updated");
|
||||
const change: "enabled" | "params" | "other" = vars.patch.params
|
||||
? "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,
|
||||
});
|
||||
// automation_trigger_updated is now emitted server-side (TriggerService.update).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating trigger:", error);
|
||||
|
|
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
|
|||
onSuccess: (vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger removed");
|
||||
trackAutomationTriggerRemoved({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
});
|
||||
// automation_trigger_removed is now emitted server-side (TriggerService.remove).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error removing trigger:", error);
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
|
|||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
trackConnectorSetupFailure,
|
||||
trackConnectorSetupStarted,
|
||||
trackIndexWithDateRangeOpened,
|
||||
|
|
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
|
|||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
trackConnectorConnected(
|
||||
Number(workspaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert
|
||||
// listener in search_source_connectors_routes.py) — covers the
|
||||
// OAuth callback redirect the frontend often never observes.
|
||||
|
||||
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
|
||||
|
||||
|
|
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
|
|||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
// Track webcrawler connector connected
|
||||
trackConnectorConnected(
|
||||
Number(workspaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
connector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
|
|
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
|
|||
// Store connectingConnectorType before clearing it
|
||||
const currentConnectorType = connectingConnectorType;
|
||||
|
||||
// Track connector connected event for non-OAuth connectors
|
||||
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
// Find connector title from constants
|
||||
const connectorInfo = OTHER_CONNECTORS.find(
|
||||
|
|
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
|
|||
id: editingConnector.id,
|
||||
});
|
||||
|
||||
// Track connector deleted event
|
||||
trackConnectorDeleted(
|
||||
Number(workspaceId),
|
||||
editingConnector.connector_type,
|
||||
editingConnector.id
|
||||
);
|
||||
// connector_deleted is now emitted server-side
|
||||
// (search_source_connectors_routes.delete_search_source_connector).
|
||||
|
||||
toast.success(
|
||||
editingConnector.connector_type === "MCP_CONNECTOR"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackWorkspaceCreated } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
|
|
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
|
|||
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
|
||||
// route straight to onboarding vs. new-chat on the first hop.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
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 { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import {
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
|
||||
import { getWorkspaceIdParam } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
|
|||
const queryClient = useQueryClient();
|
||||
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
// incentive_page_viewed removed — redundant with $pageview.
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
|
|
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
|
|||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
||||
}
|
||||
// incentive_task_completed is now emitted server-side
|
||||
// (incentive_tasks_routes.complete_task) where credit is granted.
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import type { ProcessingMode } from "@/contracts/types/document.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
trackDocumentUploadSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
|
||||
import {
|
||||
getAcceptedFileTypes,
|
||||
getSupportedExtensions,
|
||||
|
|
@ -380,13 +376,14 @@ export function DocumentUploadTab({
|
|||
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") });
|
||||
setFolderUpload(null);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
@ -421,7 +418,7 @@ export function DocumentUploadTab({
|
|||
onSuccess: () => {
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(100);
|
||||
trackDocumentUploadSuccess(Number(workspaceId), files.length);
|
||||
// Ingestion outcome now server-side (document_processing_*).
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
onSuccess?.();
|
||||
},
|
||||
|
|
@ -429,7 +426,6 @@ export function DocumentUploadTab({
|
|||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(0);
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -156,6 +156,40 @@ transport.
|
|||
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.
|
||||
|
||||
## 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
|
||||
|
||||
- Frontend/browser OpenTelemetry.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
|
||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||
import { trackWeeklyUser } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
|
||||
|
|
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
|
|||
try {
|
||||
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
|
||||
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 }));
|
||||
void consume(started.run_id, controller.signal);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,30 @@ function blockRefreshRetry(key: string): void {
|
|||
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 = {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
|
|
@ -281,29 +305,20 @@ class BaseApiService {
|
|||
throw new AbortedError();
|
||||
}
|
||||
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."
|
||||
);
|
||||
// Network failures are genuine outages worth tracking.
|
||||
captureApiException(networkError, url, options?.method);
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
console.error("Request failed:", JSON.stringify(error));
|
||||
if (!(error instanceof AuthenticationError)) {
|
||||
import("posthog-js")
|
||||
.then(({ default: posthog }) => {
|
||||
posthog.captureException(error, {
|
||||
api_url: url,
|
||||
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");
|
||||
});
|
||||
// Only 5xx server faults are unexpected. 4xx (validation, authz, 404)
|
||||
// are expected behavior — capturing them was billable error-tracking
|
||||
// noise. AuthenticationError (401) is a 4xx and stays excluded.
|
||||
if (error instanceof AppError && error.status >= 500) {
|
||||
captureApiException(error, url, options?.method);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import {
|
|||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import {
|
||||
trackChatBlocked,
|
||||
trackChatCreated,
|
||||
trackChatErrorDetailed,
|
||||
trackChatMessageSent,
|
||||
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.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;
|
||||
// Update URL silently using browser API (not router.replace) to avoid
|
||||
|
|
|
|||
|
|
@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
|
|||
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
|
||||
* ad-blockers that interfere with posthog-js can never break app
|
||||
* 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:
|
||||
* - 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
|
||||
* Events follow a consistent naming convention: category_action
|
||||
*/
|
||||
|
||||
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") {
|
||||
safeCapture("auth_login_attempt", { method });
|
||||
}
|
||||
|
||||
export function trackLoginSuccess(method: "local" | "google") {
|
||||
safeCapture("auth_login_success", { method });
|
||||
}
|
||||
|
||||
export function trackLoginFailure(method: "local" | "google", error?: string) {
|
||||
safeCapture("auth_login_failure", { method, error });
|
||||
}
|
||||
|
|
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
|
|||
safeCapture("auth_registration_attempt");
|
||||
}
|
||||
|
||||
export function trackRegistrationSuccess() {
|
||||
safeCapture("auth_registration_success");
|
||||
}
|
||||
|
||||
export function trackRegistrationFailure(error?: string) {
|
||||
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(
|
||||
workspaceId: number,
|
||||
chatId: number,
|
||||
|
|
@ -141,7 +82,6 @@ export function trackChatMessageSent(
|
|||
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
|
||||
message_length: options?.messageLength,
|
||||
});
|
||||
trackWeeklyUser("chat_message", workspaceId);
|
||||
}
|
||||
|
||||
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`
|
||||
* so WAU / retention queries on the authenticated event stay clean while
|
||||
* 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: {
|
||||
modelSlug: string;
|
||||
|
|
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// DOCUMENT EVENTS
|
||||
// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
|
||||
// ============================================
|
||||
|
||||
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
|
||||
// human-readable title and its group (oauth/composio/crawler/other) are
|
||||
// auto-attached from the shared registry in `connector-constants.ts`, so
|
||||
// adding a new connector to that list is the only change required for it
|
||||
// to show up correctly in PostHog dashboards.
|
||||
// group (oauth/composio/crawler/other) is auto-attached from the shared
|
||||
// registry, so adding a new connector to that list is the only change
|
||||
// required for it to show up correctly in PostHog dashboards.
|
||||
|
||||
export type ConnectorEventStage =
|
||||
| "setup_started"
|
||||
| "setup_success"
|
||||
| "setup_failure"
|
||||
| "oauth_initiated"
|
||||
| "connected"
|
||||
| "deleted"
|
||||
| "synced";
|
||||
| "oauth_initiated";
|
||||
|
||||
export interface ConnectorEventOptions {
|
||||
workspaceId?: number | null;
|
||||
|
|
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
|
|||
/**
|
||||
* Generic connector lifecycle tracker. Every connector analytics event
|
||||
* 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(
|
||||
stage: ConnectorEventStage,
|
||||
|
|
@ -327,7 +235,6 @@ export function trackConnectorEvent(
|
|||
error: options.error,
|
||||
}),
|
||||
connector_type: meta.connector_type,
|
||||
connector_title: meta.connector_title,
|
||||
connector_group: meta.connector_group,
|
||||
is_oauth: meta.is_oauth,
|
||||
...(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
|
||||
// ============================================
|
||||
|
||||
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: {
|
||||
os: string;
|
||||
placement: "sidebar_collapsed" | "sidebar_expanded";
|
||||
|
|
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// SEARCH SPACE INVITE EVENTS
|
||||
// ============================================
|
||||
|
||||
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
|
||||
// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
|
||||
// ============================================
|
||||
|
||||
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() {
|
||||
safeCapture("incentive_page_viewed");
|
||||
}
|
||||
|
||||
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
|
||||
safeCapture("incentive_task_completed", {
|
||||
task_type: taskType,
|
||||
credit_micros_rewarded: creditMicrosRewarded,
|
||||
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
|
||||
safeCapture("workspace_invite_declined", {
|
||||
workspace_name: workspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackIncentiveTaskClicked(taskType: string) {
|
||||
safeCapture("incentive_task_clicked", {
|
||||
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 }) {
|
||||
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 }) {
|
||||
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 }) {
|
||||
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 }) {
|
||||
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: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
|
|
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(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: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue